Skip to content

Commit 4d5a067

Browse files
atharrva01AkramBitar
authored andcommitted
fix: address review on the reclaim re-validation and the tests
Re-validation now matches the collector's delete phase: transaction and last access, not transaction alone. Comparing the txID only left an ABA gap, since the entry can be unlocked and re-locked under the same txID while the shard lock is released, and reclaiming on that stale verdict would drop a fresh entry. The last access is read before the refresh that would clobber it. The failure log no longer pairs the current holder with the previously observed holder's status. When the entry changed hands it says so and names the transaction the status actually belongs to. TestReclaimDoesNotHoldShardLockDuringStatusLookup stops the collector before arming the hook. A long sleep timeout was not enough: scan runs a full pass before its first sleep, so it could consume the one-shot and park there itself, leaving the reclaim's lookup to pass through and the test passing without ever holding a lookup open inside Lock, including on the unfixed code. Both test bounds drop under stopTimeout so a failing run lets Stop join the scan goroutine rather than leaving it parked past the end of the test, and the const block moves above the doc comment it had split from its function. The note about the hook blocking under the shard lock is no longer true after this PR and now describes the collector's lookup phase. Signed-off-by: atharrva01 <atharvaborade568@gmail.com>
1 parent 9d09014 commit 4d5a067

2 files changed

Lines changed: 50 additions & 27 deletions

File tree

token/services/selector/simple/inmemory/locker.go

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,16 @@ func (d *locker) lockInShard(ctx context.Context, s *shard, owner string, id *to
179179
// The holder observed here is re-validated under the lock below, since it may
180180
// change while the lock is not held.
181181
var (
182-
observedTxID string
183-
observedStatus int
184-
statusResolved bool
182+
observedTxID string
183+
observedLastAccess time.Time
184+
observedStatus int
185+
statusResolved bool
185186
)
186187
if reclaim {
187188
s.mu.RLock()
188189
e, held := s.locked[k]
189190
if held {
190-
observedTxID = e.TxID
191+
observedTxID, observedLastAccess = e.TxID, e.LastAccess
191192
}
192193
s.mu.RUnlock()
193194

@@ -209,30 +210,41 @@ func (d *locker) lockInShard(ctx context.Context, s *shard, owner string, id *to
209210
}
210211
e, ok := s.locked[k]
211212
if ok {
213+
// Read before the refresh below clobbers it: the re-validation compares against
214+
// the value observed during the status lookup.
215+
prevAccess := e.LastAccess
212216
e.LastAccess = time.Now()
213217

214218
if reclaim {
215-
// Second chance. Only act on the status resolved above if the entry is
216-
// still the one it was resolved for: a concurrent Lock(reclaim=true) may
217-
// have handed the token to another transaction in the meantime, and
218-
// reclaiming on a stale status would take a token away from its new
219-
// holder. When it no longer matches, report the token as locked and let
220-
// the caller retry.
219+
// Second chance. Only act on the status resolved above if the entry is still
220+
// exactly the one it was resolved for, matching the collector's delete phase:
221+
// same transaction and same last access. Comparing the transaction alone is
222+
// not enough, since the entry may have been unlocked and re-locked under the
223+
// same txID while the shard lock was released, and reclaiming on that stale
224+
// verdict would drop a fresh entry. When it no longer matches, report the
225+
// token as locked and let the caller retry.
221226
logger.DebugfContext(ctx, "[%s] already locked by [%s], try to reclaim...", id, e)
222-
status := observedStatus
223-
reclaimed := statusResolved && e.TxID == observedTxID && status == ttxdb.Deleted
227+
unchanged := statusResolved && e.TxID == observedTxID && prevAccess.Equal(observedLastAccess)
228+
reclaimed := unchanged && observedStatus == ttxdb.Deleted
224229
if reclaimed {
225230
delete(s.locked, k)
226231
}
227232
if !reclaimed {
228-
logger.DebugfContext(ctx, "[%s] already locked by [%s], reclaim failed, tx status [%s]", id, e, ttxdb.TxStatusMessage[status])
233+
// Only report the status when it belongs to the holder still in place;
234+
// otherwise it describes observedTxID, not e, and pairing the two sends
235+
// a reader after the wrong transaction.
236+
if unchanged {
237+
logger.DebugfContext(ctx, "[%s] already locked by [%s], reclaim failed, tx status [%s]", id, e, ttxdb.TxStatusMessage[observedStatus])
238+
} else {
239+
logger.DebugfContext(ctx, "[%s] already locked by [%s], reclaim failed, entry changed since the status of [%s] was read", id, e, observedTxID)
240+
}
229241
if logger.IsEnabledFor(zapcore.DebugLevel) {
230242
return e.TxID, errors.Errorf("already locked by [%s]", e)
231243
}
232244

233245
return e.TxID, AlreadyLockedError
234246
}
235-
logger.DebugfContext(ctx, "[%s] already locked by [%s], reclaimed successful, tx status [%s]", id, e, ttxdb.TxStatusMessage[status])
247+
logger.DebugfContext(ctx, "[%s] reclaimed from [%s], tx status [%s]", id, observedTxID, ttxdb.TxStatusMessage[observedStatus])
236248
} else {
237249
logger.DebugfContext(ctx, "[%s] already locked by [%s], no reclaim", id, e)
238250
if logger.IsEnabledFor(zapcore.DebugLevel) {

token/services/selector/simple/inmemory/locker_test.go

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,22 @@ func (m *mockTXStatusProvider) GetStatus(_ context.Context, txID string) (ttxdb.
8989
return m.status(txID), "", nil
9090
}
9191

92+
// Bounds for the coordination in TestScannerDoesNotDeleteReclaimed. They are far longer than the
93+
// 20ms scan interval the test drives, so they only fire when something is genuinely wrong, but they
94+
// keep a missed interleaving from turning into a 10 minute package timeout. Both stay under
95+
// stopTimeout so that a failing run still lets t.Cleanup's Stop join the scan goroutine instead of
96+
// leaving it parked in the hook and running its delete phase during later tests. See #2156.
97+
const (
98+
scannerObserveTimeout = 3 * time.Second
99+
hookReleaseTimeout = 3 * time.Second
100+
)
101+
92102
// TestScannerDoesNotDeleteReclaimed verifies the TOCTOU protection in the
93103
// scanner: when the scanner has observed a token as removable (its tx is
94104
// Deleted) and a concurrent Lock(reclaim=true) re-locks that token for a new
95105
// transaction before the scanner deletes, the scanner must NOT delete the
96106
// new entry. The test drives the real scan loop and blocks the scanner's
97107
// status lookup to open the race window deterministically.
98-
// Bounds for the coordination in TestScannerDoesNotDeleteReclaimed. Both are far longer than the
99-
// 20ms scan interval the test drives, so they only fire when something is genuinely wrong, but they
100-
// keep a missed interleaving from turning into a 10 minute package timeout. See #2156.
101-
const (
102-
scannerObserveTimeout = 30 * time.Second
103-
hookReleaseTimeout = 30 * time.Second
104-
)
105-
106108
func TestScannerDoesNotDeleteReclaimed(t *testing.T) {
107109
mock := newMockTXStatusProvider()
108110
tokenID := &token.ID{TxId: "tok1", Index: 0}
@@ -133,9 +135,9 @@ func TestScannerDoesNotDeleteReclaimed(t *testing.T) {
133135
// (the reclaim's) must pass through or they would deadlock
134136
close(entered)
135137
// Bounded: if the test fails before reaching close(release), this
136-
// goroutine must not sit here forever. Note reclaim() calls
137-
// GetStatus while holding the shard lock, so a blocked hook can
138-
// hold that lock too.
138+
// goroutine must not sit here forever. This blocks the collector's
139+
// lookup phase, which deliberately runs without the shard lock, so
140+
// parking here does not wedge the shard.
139141
select {
140142
case <-release:
141143
case <-time.After(hookReleaseTimeout):
@@ -207,18 +209,27 @@ func TestReclaimDoesNotHoldShardLockDuringStatusLookup(t *testing.T) {
207209
txA := "tx-A"
208210

209211
mock.setStatus(txA, ttxdb.Pending)
210-
// A long sleep timeout keeps the collector out of the way: this test is about
211-
// the locking path, not the scanner.
212212
d := NewLocker(mock, time.Hour, time.Hour).(*locker)
213213
t.Cleanup(func() { _ = d.Stop() })
214214
_, err := d.Lock(context.Background(), "w1", tokenID, txA, false)
215215
require.NoError(t, err)
216216

217+
// Stop the collector before arming the hook. A long sleep timeout is not enough
218+
// to keep it away: scan runs a full pass before its first sleep, so it can reach
219+
// GetStatus while the hook is armed, consume the one-shot below and park there
220+
// itself. The reclaim's own lookup would then pass straight through and the test
221+
// would pass without ever holding a lookup open inside Lock, which it does even
222+
// on the unfixed code. This test is about the locking path, so take the collector
223+
// out of the picture entirely.
224+
require.NoError(t, d.Stop())
225+
217226
// Block the next status lookup for tx-A, standing in for a slow transaction store.
218227
inLookup := make(chan struct{})
219228
unblock := make(chan struct{})
220229
var once sync.Once
221230
mock.setGetStatusHook(func(txID string) {
231+
// Only the reclaim looks up tx-A now that the collector is stopped, so the
232+
// one-shot is belt and braces rather than load bearing.
222233
if txID != txA {
223234
return
224235
}

0 commit comments

Comments
 (0)