Skip to content

Commit e925b17

Browse files
committed
fix(recovery): guard claim fan-out send on context cancellation to unblock Stop
Signed-off-by: AkramBitar <akram@il.ibm.com>
1 parent f5000f3 commit e925b17

3 files changed

Lines changed: 142 additions & 11 deletions

File tree

docs/services/storage/recovery.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,8 @@ All three terminal statuses (`Confirmed`, `Deleted`, `Orphan`) are excluded from
170170

171171
## Thread Safety
172172

173-
The Manager is thread-safe and can be safely started/stopped from multiple goroutines. The Handler implementation must also be thread-safe as it will be called concurrently by multiple workers.
173+
The Manager is thread-safe and can be safely started/stopped from multiple goroutines. The Handler implementation must also be thread-safe as it will be called concurrently by multiple workers.
174+
175+
## Shutdown Behaviour
176+
177+
`Stop()` cancels the manager context and waits for the recovery loop to return. If a sweep is mid-batch, the fan-out to the worker pool aborts on cancellation: workers stop after their in-flight `Handler.Recover()` call and any claims not yet dispatched are simply left undispatched. Those rows keep their `Pending` status, so they become eligible again once their lease (`leaseDuration`) expires and are picked up by the next sweep — on this or another replica. The aborted sweep reports a `recovery fan-out cancelled` error, which the loop logs before exiting.

token/services/storage/services/recovery/manager.go

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -248,16 +248,7 @@ func (m *Manager) recoverTransactions(ctx context.Context) error {
248248
go m.worker(ctx, &workerWG, work, errCh)
249249
}
250250

251-
// ClaimPendingTransactions reads directly from the requests table where
252-
// tx_id is the primary key, so each claim is already unique. Fan out
253-
// straight to the workers; nil entries are defensive but should never
254-
// occur in practice.
255-
for _, claim := range records {
256-
if claim == nil {
257-
continue
258-
}
259-
work <- claim
260-
}
251+
fanOutErr := m.fanOut(ctx, records, work)
261252
close(work)
262253

263254
workerWG.Wait()
@@ -283,9 +274,43 @@ func (m *Manager) recoverTransactions(ctx context.Context) error {
283274
m.logger.Debugf("completed recovery sweep: claimed=%d, all succeeded", len(records))
284275
}
285276

277+
if fanOutErr != nil {
278+
return errors.Join(fanOutErr, firstErr)
279+
}
280+
286281
return firstErr
287282
}
288283

284+
// fanOut dispatches the claimed records to the worker pool.
285+
//
286+
// ClaimPendingTransactions reads directly from the requests table where tx_id
287+
// is the primary key, so each claim is already unique. Fan out straight to the
288+
// workers; nil entries are defensive but should never occur in practice.
289+
//
290+
// The send is guarded on ctx.Done() — mirroring the cleanup manager — because
291+
// the workers return from their own select as soon as the context is cancelled,
292+
// without draining what is left in work. An unguarded send would then block
293+
// forever with no receiver, so close(work) and the deferred wg.Done() of the
294+
// recovery loop would never run and Stop's wg.Wait() would hang while holding
295+
// m.mu, wedging every later Start/Stop call.
296+
func (m *Manager) fanOut(ctx context.Context, records []*ttxdb.RecoveryClaim, work chan<- *ttxdb.RecoveryClaim) error {
297+
for i, claim := range records {
298+
if claim == nil {
299+
continue
300+
}
301+
select {
302+
case work <- claim:
303+
// dispatched
304+
case <-ctx.Done():
305+
m.logger.Debugf("recovery fan-out cancelled: %d of %d claim(s) not dispatched", len(records)-i, len(records))
306+
307+
return errors.Wrapf(ctx.Err(), "recovery fan-out cancelled")
308+
}
309+
}
310+
311+
return nil
312+
}
313+
289314
func (m *Manager) worker(ctx context.Context, wg *sync.WaitGroup, work <-chan *ttxdb.RecoveryClaim, errCh chan<- error) {
290315
defer wg.Done()
291316

token/services/storage/services/recovery/manager_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ SPDX-License-Identifier: Apache-2.0
77
package recovery_test
88

99
import (
10+
"context"
1011
"errors"
12+
"strconv"
13+
"sync"
1114
"testing"
1215
"time"
1316

@@ -306,6 +309,105 @@ func TestManager_NoPromotionWhenGracePeriodDisabled(t *testing.T) {
306309
assert.Equal(t, 0, mockDB.SetStatusCallCount(), "grace period disabled should never call SetStatus")
307310
}
308311

312+
// TestManager_StopDuringFanOutDoesNotDeadlock walks the exact failure scenario
313+
// reported in issue #2038, step by step:
314+
//
315+
// 1. recoveryLoop's goroutine is mid-fan-out, with claims still queued to send
316+
// to work — forced here by returning 64 claims to a single worker that is
317+
// parked inside Recover, so the unbuffered send has no receiver.
318+
// 2. Stop() cancels m.ctx — called from a goroutine so the test can observe it
319+
// hanging instead of hanging with it.
320+
// 3. All WorkerCount workers observe ctx.Done() in their select and exit before
321+
// draining the remaining items from work — the worker is released after the
322+
// cancellation, so both its select arms are ready and it takes ctx.Done().
323+
// 4. The fan-out loop's next `work <- claim` send now has no receiver and, when
324+
// unguarded, blocks forever.
325+
// 5. That send sits in the goroutine wg.Done() is deferred on, so the deferred
326+
// call never runs.
327+
// 6. Stop()'s m.wg.Wait() — held under m.mu — hangs indefinitely, and every
328+
// later Start()/Stop() deadlocks on m.mu; asserted by the final restart.
329+
//
330+
// Verified to fail (10s timeout at step 6) against the unguarded send and to
331+
// pass with the ctx.Done()-guarded fan-out.
332+
func TestManager_StopDuringFanOutDoesNotDeadlock(t *testing.T) {
333+
logger := logging.MustGetLogger()
334+
mockDB := &mock2.Storage{}
335+
mockHandler := &mock2.Handler{}
336+
config := recovery2.Config{
337+
Enabled: true,
338+
TTL: 10 * time.Millisecond,
339+
ScanInterval: 10 * time.Millisecond,
340+
BatchSize: 100,
341+
WorkerCount: 1,
342+
LeaseDuration: time.Second,
343+
AdvisoryLockID: 1,
344+
InstanceID: "test-instance",
345+
}
346+
347+
// Step 1: many more claims than workers, so the fan-out loop is guaranteed to
348+
// still have undispatched claims when the context is cancelled. 64 also makes
349+
// it statistically certain (1 - 2^-63) that the worker's select picks
350+
// ctx.Done() before draining the batch on its own.
351+
const claimCount = 64
352+
records := make([]*ttxdb.RecoveryClaim, claimCount)
353+
for i := range records {
354+
records[i] = &ttxdb.RecoveryClaim{TxID: "tx" + strconv.Itoa(i)}
355+
}
356+
357+
leadership := &mock2.Leadership{}
358+
leadership.CloseReturns(nil)
359+
mockDB.AcquireRecoveryLeadershipReturns(leadership, true, nil)
360+
mockDB.ClaimPendingTransactionsReturns(records, nil)
361+
mockDB.ReleaseRecoveryClaimReturns(nil)
362+
363+
var once sync.Once
364+
inWorker := make(chan struct{})
365+
release := make(chan struct{})
366+
mockHandler.RecoverStub = func(_ context.Context, _ string) error {
367+
// Hold the single worker inside Recover so the fan-out loop is parked
368+
// on its send, then let it go only after Stop() cancelled the context.
369+
once.Do(func() {
370+
close(inWorker)
371+
<-release
372+
})
373+
374+
return nil
375+
}
376+
377+
manager := recovery2.NewManager(logger, mockDB, mockHandler, config)
378+
require.NoError(t, manager.Start())
379+
380+
select {
381+
case <-inWorker:
382+
case <-time.After(10 * time.Second):
383+
t.Fatal("timed out waiting for the recovery sweep to reach a worker")
384+
}
385+
386+
// Step 2: cancel m.ctx via Stop() while the fan-out is parked on its send.
387+
stopped := make(chan error, 1)
388+
go func() {
389+
stopped <- manager.Stop()
390+
}()
391+
392+
// Step 3: let Stop() cancel the context first, then unblock the worker so it
393+
// returns to its select with both ctx.Done() and the fan-out send ready.
394+
time.Sleep(50 * time.Millisecond)
395+
close(release)
396+
397+
// Steps 4-5-6: with the unguarded send this never returns.
398+
select {
399+
case err := <-stopped:
400+
require.NoError(t, err)
401+
case <-time.After(10 * time.Second):
402+
t.Fatal("Stop() deadlocked while the sweep was fanning out claims")
403+
}
404+
405+
// Step 6, second half: the manager must not be wedged — m.mu was released, so
406+
// a subsequent Start()/Stop() pair still works.
407+
require.NoError(t, manager.Start())
408+
require.NoError(t, manager.Stop())
409+
}
410+
309411
func TestDefaultConfig(t *testing.T) {
310412
config := recovery2.DefaultConfig()
311413

0 commit comments

Comments
 (0)