Skip to content

Commit c9e6f19

Browse files
committed
Cover the lock paths the keep-alive added
Patch coverage came in at 54.3% against the 70% gate. Two gaps, both mine: Lock had NO test at all before this branch, so every line the keep-alive added to it was uncovered. It is the one form that cannot abort its caller when the lock is lost, so what it can be held to is narrower: that it actually excludes, that release actually frees, and that the keep-alive interferes with neither. The exclusion test holds the lock across several keep-alive ticks, so a keep-alive that relinquished on a healthy connection would show up as the second replica getting in. 0.0% to 92.3%. And processHost's ErrLockLost branch. Reporting 'did not run' is right even though the callback did run, because nothing is acked until a batch completes, so the events stay in flight for redelivery. What must not happen is the worker treating it as a completed batch and draining on. 77.4% to 96.8%.
1 parent fbcc839 commit c9e6f19

2 files changed

Lines changed: 112 additions & 0 deletions

File tree

server/coordination/leader/leader_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,3 +463,73 @@ func TestLockSurvivesAnIdleConnectionClose(t *testing.T) {
463463
require.ErrorIs(t, err, sentinel, "the callback's diagnosis is more specific than ErrLockLost and must win")
464464
})
465465
}
466+
467+
// TestLockGrantsExclusionAndReleases covers the bare Lock form, which had no test at all before issue #721 added a keep-alive to it.
468+
//
469+
// It is the one lock form that cannot abort its caller when the lock is lost, because there is no callback context to cancel, so
470+
// what it can be held to is narrower: it must actually exclude, its release must actually free the lock, and the keep-alive it now
471+
// runs must not interfere with either.
472+
func TestLockGrantsExclusionAndReleases(t *testing.T) {
473+
t.Parallel()
474+
dbA, dbB := replicaDBs(t)
475+
// Keep-alive well inside the test's own lifetime, so the ping fires while the lock is held rather than after it is released.
476+
coordA := leader.NewMySQL(dbA, slog.Default(), leader.WithKeepAliveInterval(10*time.Millisecond))
477+
coordB := leader.NewMySQL(dbB, slog.Default(), leader.WithKeepAliveInterval(10*time.Millisecond))
478+
479+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
480+
defer cancel()
481+
lockName := uniqueLockName()
482+
483+
release, err := coordA.Lock(ctx, lockName)
484+
require.NoError(t, err)
485+
486+
// Held long enough for several keep-alive ticks, so a keep-alive that broke the lock (by relinquishing on a healthy
487+
// connection, say) would show up as the second replica getting in.
488+
time.Sleep(100 * time.Millisecond)
489+
490+
secondAcquired := make(chan struct{})
491+
go func() {
492+
r, lerr := coordB.Lock(ctx, lockName)
493+
if lerr == nil {
494+
r()
495+
close(secondAcquired)
496+
}
497+
}()
498+
select {
499+
case <-secondAcquired:
500+
t.Fatal("a second replica acquired a lock the first still holds")
501+
case <-time.After(200 * time.Millisecond):
502+
}
503+
504+
release()
505+
506+
select {
507+
case <-secondAcquired:
508+
case <-time.After(10 * time.Second):
509+
t.Fatal("release did not free the lock; the second replica never acquired it")
510+
}
511+
}
512+
513+
// TestLockReleaseIsIdempotentUnderCancelledContext pins that a lock taken on a context that is later cancelled still frees on
514+
// release. The boot sequence is the caller here, and a shutdown racing the migration lock must not leave it held for the next
515+
// replica to block on: release runs on a cancellation-stripped context precisely so this holds.
516+
func TestLockReleaseIsIdempotentUnderCancelledContext(t *testing.T) {
517+
t.Parallel()
518+
db, _ := replicaDBs(t)
519+
coord := leader.NewMySQL(db, slog.Default(), leader.WithKeepAliveInterval(10*time.Millisecond))
520+
lockName := uniqueLockName()
521+
522+
ctx, cancel := context.WithCancel(context.Background())
523+
release, err := coord.Lock(ctx, lockName)
524+
require.NoError(t, err)
525+
526+
cancel() // the shutdown arrives while the lock is held
527+
release()
528+
529+
// The lock must be free: a fresh acquire on an uncancelled context succeeds immediately.
530+
freshCtx, freshCancel := context.WithTimeout(context.Background(), 10*time.Second)
531+
defer freshCancel()
532+
again, err := coord.Lock(freshCtx, lockName)
533+
require.NoError(t, err, "a lock released after its context was cancelled must still be free")
534+
again()
535+
}

server/detection/internal/pipeline/processor_hostclaim_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"strings"
7+
"sync/atomic"
78
"testing"
89

910
"github.com/stretchr/testify/assert"
@@ -225,3 +226,44 @@ func TestHostCandidatesWindow(t *testing.T) {
225226
})
226227
}
227228
}
229+
230+
// lostLockCoordinator reports that it acquired the host lock, ran the callback, and then discovered the lock had gone: the shape
231+
// leader.DoOnceIfLeader returns after issue #721 when the connection holding the lock dies mid-callback.
232+
type lostLockCoordinator struct {
233+
stubCoordinator
234+
ran atomic.Bool
235+
}
236+
237+
func (c *lostLockCoordinator) DoOnceIfLeader(ctx context.Context, _ string, fn func(context.Context) error) (bool, error) {
238+
c.ran.Store(true)
239+
// The callback DOES run, which is the whole difficulty: work may have been partially done before the lock went.
240+
if err := fn(ctx); err != nil {
241+
return true, err
242+
}
243+
return true, leader.ErrLockLost
244+
}
245+
246+
// TestProcessHostTreatsALostLockAsNotRun pins how the processor handles a host whose claim lock went while it was working
247+
// (issue #721's ErrLockLost, reaching the processor through the per-host claim added in #717).
248+
//
249+
// Reporting "did not run" is right even though the callback did run: nothing is acknowledged until a batch completes, so the events
250+
// stay in flight and are redelivered when the claim lease expires. What must not happen is the worker treating it as a completed
251+
// batch and draining on, which would leave a host's events folded by two claimers.
252+
func TestProcessHostTreatsALostLockAsNotRun(t *testing.T) {
253+
t.Parallel()
254+
coord := &lostLockCoordinator{}
255+
proc, err := NewProcessor(&scriptedEventLog{}, nil, nil, ProcessorOptions{
256+
Logger: discardLogger(),
257+
Batch: 10,
258+
Concurrency: 1,
259+
Coordinator: coord,
260+
ConnBudget: 25,
261+
})
262+
require.NoError(t, err)
263+
264+
claimed, ran := proc.processHost(context.Background(), "host-a")
265+
266+
assert.True(t, coord.ran.Load(), "the coordinator did hand the callback the lock before losing it")
267+
assert.False(t, ran, "a lost lock must not read as a completed batch; the worker has to move to another host")
268+
assert.Zero(t, claimed, "and it must not report progress it cannot vouch for")
269+
}

0 commit comments

Comments
 (0)