Files
zeekayandHanzo Dev af99a001fc zapexporter: a peer that stops answering must not stop the exporter
Measured on the busiest node in the fleet, 135 seconds after a routine
cloud restart: 9,854 log records accepted, 0 sent, 0 failed, sending
queue at 197 MB of its 256 MB ceiling and climbing. The agent had logged
"Connected to peer" 30 minutes earlier and had reported nothing wrong
since. Three agents were in that state at once; one was at 94% of its
ceiling, where it would have begun discarding.

Zero throughput and zero errors at the same time is the one combination
nothing downstream survives. exporterhelper never sees a failure, so it
never retries and never backs off; the persistent queue is not a defence
because nothing is draining it; and every counter an operator would
check reads healthy. The queue was the symptom. This is the cause.

luxfi/zap's TCP path sets no deadline anywhere. ConnectDirectID dials
with a 5s timeout and then does a plain blocking handshake read;
Conn.Send writes with no write deadline; Node.Send takes a context and
consults it only on the QUIC path. So a peer that accepts the socket and
then stops reading — precisely what cloud looks like from a node while it
restarts, since the kernel completes the handshake from the listen
backlog long before the process answers — parks the caller indefinitely.
The exporter's own timeout setting cannot reach any of it.

send() then held one mutex across all of that, so the first call to park
took every other consumer with it.

Three changes, smallest first:

  - The mutex guards the node and peer fields and is never held across
    I/O, so consumers stop sharing one another's fate.
  - Every attempt is bounded by the caller's deadline. The blocked call
    keeps running — there is no way to interrupt a read on a socket
    nobody set a deadline on — but the exporter stops waiting on it and
    returns a retryable error, so the batch stays queued and the retry
    machinery works again.
  - After three consecutive abandoned attempts the ZAP node is rebuilt.
    luxfi/zap has no way to drop a single peer, and ConnectDirect is
    documented idempotent, so redialling a wedged peer returns the same
    wedged Conn; rebuilding is the only way to let go of it. The node
    identity nonce moves from per-process to per-node-instance for this:
    a rebuild reusing the old nonce would be refused as a duplicate with
    EOF, permanently, which is worse than the stall it replaces.

Start's dial is bounded too. It was unbounded, so a deaf peer at boot
blocked startup forever and the collector never became ready or tailed a
file — a pre-existing bug the new test found on its first run.

Tests stand up a peer that accepts and never answers, and hold the
connection open so it cannot reset. Against the old code the first hangs
until the test binary is killed, with the stack parked in readMessageRaw
under ConnectDirectID.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 23:30:54 -07:00

140 lines
4.8 KiB
Go

package zapexporter
import (
"context"
"net"
"sync"
"testing"
"time"
"go.opentelemetry.io/collector/component/componenttest"
"go.opentelemetry.io/collector/consumer/consumererror"
"go.opentelemetry.io/collector/exporter/exportertest"
"go.opentelemetry.io/collector/pdata/plog"
)
// deafPeer accepts connections and then does nothing with them: no handshake
// reply, no reads. It is what cloud looks like from a node while it restarts —
// the kernel completes the TCP handshake from the listen backlog long before
// the process is ready to answer anything.
//
// The connections are held, deliberately, so the socket never resets during the
// test. That is the state the fleet was actually in.
func deafPeer(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
var mu sync.Mutex
var held []net.Conn
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
mu.Lock()
held = append(held, c)
mu.Unlock()
}
}()
t.Cleanup(func() {
_ = ln.Close()
mu.Lock()
for _, c := range held {
_ = c.Close()
}
mu.Unlock()
})
return ln.Addr().String()
}
// TestADeafPeerDoesNotFreezeTheExporter is the regression test for the failure
// that cost this fleet hours of telemetry a day.
//
// luxfi/zap's TCP path sets no deadline anywhere: ConnectDirectID dials with a
// timeout and then blocks on a handshake read, and Conn.Send writes with none.
// send() used to hold one mutex across all of that, so a single call parked in
// the kernel stopped every consumer. The exporter then reported zero throughput
// AND zero errors, which is the one combination nothing downstream can recover
// from: exporterhelper never sees a failure, so it never retries, and the
// sending queue fills until it starts refusing.
//
// Measured on the busiest node, 135s after a cloud restart: 9,854 records
// accepted, 0 sent, 0 failed, queue at 197 MB of 256 MB and climbing. The agent
// had already logged "Connected to peer" — it looked healthy from every angle.
//
// The bar is simply that a send to a peer that never answers RETURNS, and
// returns an error, within the caller's deadline.
func TestADeafPeerDoesNotFreezeTheExporter(t *testing.T) {
cfg := NewFactory().CreateDefaultConfig().(*Config)
cfg.Endpoint = deafPeer(t)
e := newExporter(exportertest.NewNopSettings(typeStr), cfg)
if err := e.Start(context.Background(), componenttest.NewNopHost()); err != nil {
t.Fatalf("start: %v", err)
}
defer func() { _ = e.Shutdown(context.Background()) }()
ld := plog.NewLogs()
ld.ResourceLogs().AppendEmpty().ScopeLogs().AppendEmpty().LogRecords().AppendEmpty().Body().SetStr("x")
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() { done <- e.pushLogs(ctx, ld) }()
select {
case err := <-done:
if err == nil {
t.Fatal("a send to a peer that never answered reported success — the batch is gone and nothing will retry it")
}
if consumererror.IsPermanent(err) {
t.Fatalf("a deaf peer is a transient condition and must stay retryable, got a permanent error: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("pushLogs never returned: the exporter is wedged on a peer that never answered, which is the bug this test exists for")
}
}
// TestOneStalledSendDoesNotBlockAnother states the property the old global
// mutex destroyed: consumers are independent. exporterhelper runs several, and
// one parked on a dead socket must not take the rest with it.
func TestOneStalledSendDoesNotBlockAnother(t *testing.T) {
cfg := NewFactory().CreateDefaultConfig().(*Config)
cfg.Endpoint = deafPeer(t)
e := newExporter(exportertest.NewNopSettings(typeStr), cfg)
if err := e.Start(context.Background(), componenttest.NewNopHost()); err != nil {
t.Fatalf("start: %v", err)
}
defer func() { _ = e.Shutdown(context.Background()) }()
ld := plog.NewLogs()
ld.ResourceLogs().AppendEmpty().ScopeLogs().AppendEmpty().LogRecords().AppendEmpty().Body().SetStr("x")
// One caller parks on a long deadline, as a consumer would.
slow, cancelSlow := context.WithTimeout(context.Background(), 8*time.Second)
defer cancelSlow()
go func() { _ = e.pushLogs(slow, ld) }()
time.Sleep(100 * time.Millisecond) // let it get in first
fast, cancelFast := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancelFast()
start := time.Now()
done := make(chan error, 1)
go func() { done <- e.pushLogs(fast, ld) }()
select {
case <-done:
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("the second consumer waited %v on the first one's dead socket — they are still serialised", elapsed)
}
case <-time.After(6 * time.Second):
t.Fatal("a second consumer was held behind the first: one stalled peer still stops the whole exporter")
}
}