Summary
When BasePolicy.TimeoutDelay > 0 and a single-key command times out before the 8-byte proto header arrives, singleCommand.salvageConn discards nothing and returns the connection to the pool with the entire unread response still in the socket. Every subsequent command that picks up that connection reads the previous command's response and returns it to the caller as its own record — no error, no timeout, just the wrong data, and the connection stays shifted by one response.
A read timeout before the header arrives is the ordinary case, not an exotic one: it is what a single lost response packet looks like when SocketTimeout is shorter than the TCP retransmission timeout.
With TimeoutDelay = 0 the same scenario is handled correctly — the connection is closed.
Environment
github.com/aerospike/aerospike-client-go/v8v8.8.0- Go 1.26.0, linux/amd64
- Aerospike Community Edition 6.1.0.3 (the bug is entirely client-side)
salvageConn at the current tip of the v8 branch (d68cca2e, 2026-08-27) is byte-identical to v8.8.0, so this is not something already fixed since the release.
Reproduction
Self-contained, no fault injection inside the client: a TCP proxy sits between the client and the server and delays the server→client direction by 60 ms, while SocketTimeout is 20 ms. main.go is attached below.
docker run -d --name as-repro -p 13000:3000 \
-e NAMESPACE=test -e MEM_GB=1 aerospike/aerospike-server:6.1.0.3
go mod init repro && go get github.com/aerospike/aerospike-client-go/v8@v8.8.0
go run . 127.0.0.1:13000The program writes key0=RECORD-0 and key1=RECORD-1, lets one Get(key0) die on its socket deadline, waits for the salvage goroutine to finish, and then performs six ordinary reads alternating between the two keys.
Output:
TimeoutDelay=0 (salvage disabled)
connections closed by the client: 1, connections-recovered: 0
PASS: 6/6 subsequent reads returned the right record
TimeoutDelay=300ms (salvage enabled)
read 0: asked for RECORD-1, server returned "RECORD-0"
read 1: asked for RECORD-0, server returned "RECORD-1"
read 2: asked for RECORD-1, server returned "RECORD-0"
read 3: asked for RECORD-0, server returned "RECORD-1"
read 4: asked for RECORD-1, server returned "RECORD-0"
read 5: asked for RECORD-0, server returned "RECORD-1"
connections closed by the client: 0, connections-recovered: 1
FAIL: 6 of 6 subsequent reads returned the wrong recordconnections-recovered reports 1 for a connection that is in fact corrupted. That counter is incremented in exactly one place in the whole client — the last line of singleCommand.salvageConn — so it also serves as proof that this code path, and not something else, is what ran.
All six reads fail rather than just the one whose key hashes to the same sub-heap, because salvageConn returns the connection through node.PutConnection(conn), which always uses hint 0, and connectionHeap.Poll falls back to a round-robin across every sub-heap.
SendKey = true does not help — the response is parsed as a valid record regardless of which key was requested.
Root cause
1. discardedCount is wrong exactly when the connection needs draining most
single_command.go:85:
discardedCount := int(cmd.receiveSize - conn.totalReceived)
for discardedCount > 0 {
...
}
conn.refresh()
node.PutConnection(conn)For a singleCommand, cmd.receiveSize is assigned in exactly one place, record_parser.go:68, i.e. only after the 8-byte proto header has been read and parsed. If the deadline expires before the header arrives, the field still holds whatever it held before:
- On the first attempt of a fresh command it is the zero value, so
discardedCount == 0, the loop never executes, and the connection goes back to the pool untouched, with the full response pending in the socket. This is the case the reproduction exercises. cmd.receiveSizeis never reset between retry iterations of the same*baseCommand(there is no assignment of0to it anywhere in the package, unlikeconn.totalReceived, whichrefresh()clears on every checkout from the pool). So withMaxRetries > 0it can still hold the length parsed during an earlier iteration that ran on a different connection, anddiscardedCountbecomes a non-zero number unrelated to the socket actually being drained — draining too few bytes, or reaching past the end of the response.
conn.refresh() then resets totalReceived to 0, erasing the only remaining trace that the connection is out of sync.
2. The salvage deadline is never applied to the socket
salvageConn assigns conn.deadline = time.Now().Add(timeoutDelay), but then reads through bufio.NewReader(conn.conn) — the raw net.Conn, bypassing Connection.Read/Write, which are the only callers of updateDeadline(), itself the only caller of conn.SetDeadline(...) (connection.go:231, :261, :339). Note also that only Connection.socketDeadline ever reaches the syscall; Connection.deadline is merely an upper bound consulted by updateDeadline().
Nothing on the failure path resets the socket deadline — not Connection.Read, not errToAerospikeErr, not refresh() — so the socket still carries the already-expired deadline of the command that just failed, and the first reader.Discard returns os.ErrDeadlineExceeded immediately with discarded == 0. The code then takes the discarded < discardedCount branch and closes the connection.
In practice this means the salvage path can never succeed in the one case where discardedCount is correct — when the header was received. In that scenario the measured connections-recovered stays at 0, i.e. TimeoutDelay buys nothing and only costs a goroutine.
Taken together with (1): when the mechanism can act it corrupts the connection, and when it would be correct it cannot act.
3. Data race on the command object
The salvage goroutine is started while the retry loop keeps using the same *baseCommand (command.go:3948-3950):
if cmd.conn.salvageConnection && policy.TimeoutDelay > 0 {
go ifc.salvageConn(policy.TimeoutDelay, cmd.conn, cmd.node)
continue
}Reproduced with -race (same program, MaxRetries = 3):
WARNING: DATA RACE
Write at 0x00c000456038 by main goroutine:
aerospike-client-go/v8.(*baseCommand).executeAt()
command.go:3784 +0x532 // cmd.node, err = ifc.getNode(ifc)
...
Previous read at 0x00c000456038 by goroutine 55:
aerospike-client-go/v8.(*singleCommand).salvageConn()
single_command.go:104 +0x437 // applyConnectionRecoveredMetrics(cmd.node)
aerospike-client-go/v8.(*baseCommand).executeAt.gowrap2()
command.go:3950 +0x69
Found 1 data race(s)Worth noting that salvageConn already receives node as a parameter and ignores it in favour of cmd.node.
cmd.conn is shared the same way and does not even need a retry to race: command.go:3915 assigns cmd.conn = nil in the same iteration, immediately after the go statement on :3908, while the goroutine reads cmd.conn at single_command.go:78 and writes it at :93.
cmd.receiveSize is shared too — read by the goroutine at single_command.go:85, reassigned by the next attempt at record_parser.go:68 — which is the concurrent version of the staleness described in (1).
Comparison with the Java client
The Java implementation, ConnectionRecover, handles the case that is missing here: when the timeout occurred before the header arrived (offset == 0), it reads the header itself and derives the remaining length from it via parseProto. It bounds the work with a budget rather than a single deadline — the socket timeout is set to 1 ms and each SocketTimeoutException compares elapsed time against timeoutDelay, calling abort() once the budget is exhausted — and any Throwable also leads to abort(). It is also explicit about what it will not do: recovering compressed multi-record responses throws rather than guessing.
Impact
Silent wrong data returned to the application, on a released version, from a policy field whose documentation describes it as a background recovery optimization. There is no error to catch and no metric that flags it — connections-recovered increments as if the connection were healthy. Once a connection is shifted it stays shifted until IdleTimeout or a network error retires it, so a single timeout can poison one pooled connection for minutes.
Suggested fix
In singleCommand.salvageConn:
- Set a real socket deadline bounded by
timeoutDelay(go throughConnection.Read, or callconn.conn.SetDeadlineexplicitly) before reading anything. - If
conn.totalReceived < 8, read the remainder of the proto header first and take the remaining length from it asint(sz & 0xFFFFFFFFFFFF), measured from the end of the 8-byte header, instead of trustingcmd.receiveSize. Note thatemptySocketis not the right template here: it subtractsheaderLengthbecause it runs at a point where all 30 bytes of_MSG_TOTAL_HEADER_SIZEhave already been consumed. - Refuse to drain compressed responses. For
msgType == 4the bytes left in the socket are governed by the compressed size, whereascmd.receiveSizeholds the uncompressed length, and draining through the rawnet.Connbypasses the inflater and itslimitReaderentirely. Closing the connection is the only safe action, which is the position the Java client takes for the multi-record case. - Close the connection on any error, short read, or deadline expiry, and do not increment
connections-recoveredin that case. - Do not read or write
cmdfields from the salvage goroutine — passreceiveSize,totalReceivedand the node in as arguments.
I can prepare a PR along these lines against the v8 branch, with regression tests for both scenarios (timeout before the header, timeout after the header), if that is welcome.
Related, lower priority
TimeoutDelay is effectively a single-command feature today: baseMultiCommand.salvageConn (multi_command.go:520) just closes the connection, and both scanPartitionCommand.salvageConn (scan_partition_command.go:94) and scanPartitionObjectsCommand.salvageConn (scan_partition_objects_command.go:98) carry a // TODO: implement scan and query commands later. That is safe behaviour, but it may be worth saying so in the policy documentation.
Separately, Node.getConnectionWithHint (node.go:562) accepts a timeoutDelay parameter and never uses it.
main.go — full reproduction
// Minimal reproduction for a data-corruption bug in BasePolicy.TimeoutDelay
// (singleCommand.salvageConn) in aerospike-client-go/v8.
//
// It puts a TCP proxy between the client and the server. The proxy delays the
// server->client direction so that a read command hits its SocketTimeout before
// the 8-byte proto header arrives. That is what an ordinary lost response packet
// looks like to the client.
//
// With TimeoutDelay > 0 the client hands the connection to salvageConn, which
// computes discardedCount = cmd.receiveSize - conn.totalReceived. Both are 0,
// because cmd.receiveSize is only assigned after the header has been parsed, so
// nothing is discarded and the connection is returned to the pool with the whole
// unread response still sitting in the socket. Every later command on that
// connection reads the previous command's response and returns it as its own,
// with no error.
//
// Run:
//
// docker run -d --name as-repro -p 13000:3000 \
// -e NAMESPACE=test -e MEM_GB=1 aerospike/aerospike-server:6.1.0.3
// go mod init repro && go get github.com/aerospike/aerospike-client-go/v8@v8.8.0
// go run . 127.0.0.1:13000
package main
import (
"fmt"
"net"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
as "github.com/aerospike/aerospike-client-go/v8"
)
var namespace = "test" // override with AS_NAMESPACE
const (
setName = "salvage"
binName = "v"
socketTimeout = 20 * time.Millisecond
totalTimeout = 150 * time.Millisecond
timeoutDelay = 300 * time.Millisecond
responseDelay = 60 * time.Millisecond // > socketTimeout, so the read times out
)
// proxy forwards TCP and can delay the server->client direction on demand.
type proxy struct {
ln net.Listener
target string
delayNs atomic.Int64
closed atomic.Int64 // connections torn down, i.e. the client closed its side
}
func newProxy(target string) (*proxy, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
p := &proxy{ln: ln, target: target}
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go p.handle(c)
}
}()
return p, nil
}
func (p *proxy) handle(client net.Conn) {
server, err := net.Dial("tcp", p.target)
if err != nil {
client.Close()
return
}
var once sync.Once
shut := func() { once.Do(func() { client.Close(); server.Close() }) }
go func() { // client -> server, never delayed
buf := make([]byte, 64*1024)
for {
n, rerr := client.Read(buf)
if n > 0 {
if _, werr := server.Write(buf[:n]); werr != nil {
break
}
}
if rerr != nil {
p.closed.Add(1)
break
}
}
shut()
}()
buf := make([]byte, 64*1024) // server -> client, delayed on demand
for {
n, rerr := server.Read(buf)
if n > 0 {
if d := time.Duration(p.delayNs.Load()); d > 0 {
time.Sleep(d)
}
if _, werr := client.Write(buf[:n]); werr != nil {
break
}
}
if rerr != nil {
break
}
}
shut()
}
func hostPort(addr string) (string, int) {
h, ps, _ := net.SplitHostPort(addr)
p, _ := strconv.Atoi(ps)
return h, p
}
func run(delay time.Duration) (failures int, err error) {
target := "127.0.0.1:13000"
if len(os.Args) > 1 {
target = os.Args[1]
}
p, err := newProxy(target)
if err != nil {
return 0, err
}
host, port := hostPort(p.ln.Addr().String())
cp := as.NewClientPolicy()
cp.Timeout = 5 * time.Second
cp.ConnectionQueueSize = 4
cp.MinConnectionsPerNode = 2
cp.LimitConnectionsToQueueSize = true
client, aerr := as.NewClientWithPolicyAndHost(cp, as.NewHost(host, port))
if aerr != nil {
return 0, aerr
}
defer client.Close()
client.EnableMetrics(as.DefaultMetricsPolicy())
relaxed := as.NewWritePolicy(0, 0)
relaxed.SocketTimeout = 3 * time.Second
relaxed.TotalTimeout = 3 * time.Second
for i := 0; i < 2; i++ {
key, _ := as.NewKey(namespace, setName, fmt.Sprintf("key%d", i))
if aerr := client.PutBins(relaxed, key, as.NewBin(binName, fmt.Sprintf("RECORD-%d", i))); aerr != nil {
return 0, aerr
}
}
read := as.NewPolicy()
read.MaxRetries = 0
read.SocketTimeout = 3 * time.Second
read.TotalTimeout = 3 * time.Second
key0, _ := as.NewKey(namespace, setName, "key0")
key1, _ := as.NewKey(namespace, setName, "key1")
if _, aerr := client.Get(read, key0); aerr != nil { // warm the pool
return 0, aerr
}
time.Sleep(300 * time.Millisecond)
closedBefore := p.closed.Load()
// The command that dies on its socket deadline before the header arrives.
tight := as.NewPolicy()
tight.MaxRetries = 0
tight.SocketTimeout = socketTimeout
tight.TotalTimeout = totalTimeout
tight.TimeoutDelay = delay
p.delayNs.Store(int64(responseDelay))
_, timeoutErr := client.Get(tight, key0)
p.delayNs.Store(0)
if timeoutErr == nil {
return 0, fmt.Errorf("expected a timeout, got none: raise responseDelay")
}
time.Sleep(600 * time.Millisecond) // let the salvage goroutine finish
// Every later read must return its own record.
for i := 0; i < 6; i++ {
key, want := key1, "RECORD-1"
if i%2 == 1 {
key, want = key0, "RECORD-0"
}
rec, rerr := client.Get(read, key)
if rerr != nil {
failures++
fmt.Printf(" read %d: error: %v\n", i, rerr)
continue
}
got, _ := rec.Bins[binName].(string)
if got != want {
failures++
fmt.Printf(" read %d: asked for %s, server returned %q\n", i, want, got)
}
}
recovered := 0.0
if stats, serr := client.Stats(); serr == nil {
if agg, ok := stats["cluster-aggregated-stats"].(map[string]any); ok {
recovered, _ = agg["connections-recovered"].(float64)
}
}
fmt.Printf(" connections closed by the client: %d, connections-recovered: %.0f\n",
p.closed.Load()-closedBefore, recovered)
return failures, nil
}
func main() {
if ns := os.Getenv("AS_NAMESPACE"); ns != "" {
namespace = ns
}
for _, c := range []struct {
name string
delay time.Duration
}{
{"TimeoutDelay=0 (salvage disabled)", 0},
{"TimeoutDelay=300ms (salvage enabled)", timeoutDelay},
} {
fmt.Printf("%s\n", c.name)
failures, err := run(c.delay)
if err != nil {
fmt.Printf(" setup error: %v\n\n", err)
continue
}
if failures == 0 {
fmt.Printf(" PASS: 6/6 subsequent reads returned the right record\n\n")
} else {
fmt.Printf(" FAIL: %d of 6 subsequent reads returned the wrong record\n\n", failures)
}
}
}Activity
0No activity yet.