Problem
The recovery manager builds its context once at startup, with no deadline
(token/services/storage/services/recovery/manager.go:114):
m.ctx, m.cancel = context.WithCancel(context.Background())
That context is passed straight through runSweep(m.ctx) into leadership acquisition,
ClaimPendingTransactions, and every per-transaction Recover call. Nothing on that path
narrows it, so the only thing that can ever cancel it is Stop().
Recover is a network call. TTXRecoveryHandler.Recover reaches
h.network.GetTransactionStatus(ctx, ...) at token/services/ttx/finality/recovery.go:76,
which queries the ledger for the transaction's status. If that query hangs rather than
failing, an unresponsive peer, a connection that dies without surfacing an error, an orderer
in catch-up, then the worker blocks for as long as the query does.
Why it matters
The blast radius is not one transaction, because leadership is held across the whole sweep.
runSweep (manager.go:216) acquires the advisory lock, defers its release, and only then
calls recoverTransactions, which both claims and processes the batch. So while the leader
is parked on a hung query:
- its own sweep makes no progress
- every other replica's
AcquireRecoveryLeadership returns acquired=false and skips its tick
- no pending transaction on that TMS is recovered by anyone, for the duration of the hang
Recovery is the mechanism that repairs transactions whose finality listener was lost. One
unresponsive peer can therefore disable it across every replica of a TMS, quietly, with
nothing timing out to break it.
There is also nothing an operator can configure to bound this. validateConfig
(manager.go:199) only checks that TTL, ScanInterval, BatchSize, WorkerCount and
LeaseDuration are greater than zero. There is no per-transaction or per-sweep deadline.
Related, worth confirming at the same time
LeaseDuration (default 30s) can expire while the leader is still processing, since nothing
renews it. Today that is masked, because leadership serialises the sweep so no other replica
is claiming while one is at work. It stops being masked if sweeps ever run concurrently on a
path where leadership is not enforced. If a renewal mechanism is wanted, there is already a
precedent in auditdb/locker/postgres, whose heartbeatLoop
(token/services/storage/auditdb/locker/postgres/postgres.go:304) renews at roughly TTL/3.
Problem
The recovery manager builds its context once at startup, with no deadline
(
token/services/storage/services/recovery/manager.go:114):That context is passed straight through
runSweep(m.ctx)into leadership acquisition,ClaimPendingTransactions, and every per-transactionRecovercall. Nothing on that pathnarrows it, so the only thing that can ever cancel it is
Stop().Recoveris a network call.TTXRecoveryHandler.Recoverreachesh.network.GetTransactionStatus(ctx, ...)attoken/services/ttx/finality/recovery.go:76,which queries the ledger for the transaction's status. If that query hangs rather than
failing, an unresponsive peer, a connection that dies without surfacing an error, an orderer
in catch-up, then the worker blocks for as long as the query does.
Why it matters
The blast radius is not one transaction, because leadership is held across the whole sweep.
runSweep(manager.go:216) acquires the advisory lock, defers its release, and only thencalls
recoverTransactions, which both claims and processes the batch. So while the leaderis parked on a hung query:
AcquireRecoveryLeadershipreturnsacquired=falseand skips its tickRecovery is the mechanism that repairs transactions whose finality listener was lost. One
unresponsive peer can therefore disable it across every replica of a TMS, quietly, with
nothing timing out to break it.
There is also nothing an operator can configure to bound this.
validateConfig(
manager.go:199) only checks thatTTL,ScanInterval,BatchSize,WorkerCountandLeaseDurationare greater than zero. There is no per-transaction or per-sweep deadline.Related, worth confirming at the same time
LeaseDuration(default 30s) can expire while the leader is still processing, since nothingrenews it. Today that is masked, because leadership serialises the sweep so no other replica
is claiming while one is at work. It stops being masked if sweeps ever run concurrently on a
path where leadership is not enforced. If a renewal mechanism is wanted, there is already a
precedent in
auditdb/locker/postgres, whoseheartbeatLoop(
token/services/storage/auditdb/locker/postgres/postgres.go:304) renews at roughly TTL/3.