@@ -3,6 +3,7 @@ package common
33import (
44 "context"
55 "fmt"
6+ "sync"
67 "testing"
78 "time"
89
@@ -337,3 +338,138 @@ func TestEventCleanerStartStopLifecycle(t *testing.T) {
337338 time .Sleep (50 * time .Millisecond )
338339 })
339340}
341+
342+ // Start and Stop race against the cleanup goroutine. Run under -race.
343+ func TestEventCleaner_StartStopUnderRace (t * testing.T ) {
344+ for i := 0 ; i < 20 ; i ++ {
345+ database := newTestCleanerDB (t , nil )
346+ cleaner := NewEventCleaner (database , intPtr (3600 ), intPtr (0 ), "test-chain" , zerolog .Nop ())
347+ // Fast enough that the goroutine is inside performCleanup while Stop runs.
348+ cleaner .cleanupInterval = time .Millisecond
349+
350+ require .NoError (t , cleaner .Start (context .Background ()))
351+ cleaner .Stop ()
352+ }
353+ }
354+
355+ // Concurrent Stop calls must not double close the channel or return before the
356+ // goroutine has exited.
357+ func TestEventCleaner_ConcurrentStop (t * testing.T ) {
358+ database := newTestCleanerDB (t , nil )
359+ cleaner := NewEventCleaner (database , intPtr (3600 ), intPtr (0 ), "test-chain" , zerolog .Nop ())
360+ cleaner .cleanupInterval = time .Millisecond
361+
362+ require .NoError (t , cleaner .Start (context .Background ()))
363+
364+ var wg sync.WaitGroup
365+ for i := 0 ; i < 8 ; i ++ {
366+ wg .Add (1 )
367+ go func () {
368+ defer wg .Done ()
369+ cleaner .Stop ()
370+ }()
371+ }
372+ wg .Wait ()
373+
374+ assert .False (t , cleaner .running )
375+ }
376+
377+ // Concurrent Start calls must leave exactly one goroutine running.
378+ func TestEventCleaner_ConcurrentStart (t * testing.T ) {
379+ database := newTestCleanerDB (t , nil )
380+ cleaner := NewEventCleaner (database , intPtr (3600 ), intPtr (0 ), "test-chain" , zerolog .Nop ())
381+ cleaner .cleanupInterval = time .Millisecond
382+
383+ var mu sync.Mutex
384+ started := 0
385+
386+ var wg sync.WaitGroup
387+ for i := 0 ; i < 8 ; i ++ {
388+ wg .Add (1 )
389+ go func () {
390+ defer wg .Done ()
391+ if err := cleaner .Start (context .Background ()); err == nil {
392+ mu .Lock ()
393+ started ++
394+ mu .Unlock ()
395+ }
396+ }()
397+ }
398+ wg .Wait ()
399+
400+ assert .Equal (t , 1 , started , "more than one cleanup goroutine was started" )
401+ cleaner .Stop ()
402+ }
403+
404+ // Stop must not return while a cleanup is still in flight, otherwise the caller
405+ // can close the chain database underneath an in-flight query.
406+ //
407+ // Held open with a write transaction so the goroutine is genuinely blocked
408+ // inside performCleanup while Stop is called. Without that, the goroutine exits
409+ // so fast that a Stop which does not wait looks identical to one that does.
410+ func TestEventCleaner_StopWaitsForInFlightCleanup (t * testing.T ) {
411+ database := newTestCleanerDB (t , nil )
412+ cleaner := NewEventCleaner (database , intPtr (3600 ), intPtr (0 ), "test-chain" , zerolog .Nop ())
413+ cleaner .cleanupInterval = time .Millisecond
414+
415+ // Start first: the initial cleanup is synchronous and would block on the lock.
416+ require .NoError (t , cleaner .Start (context .Background ()))
417+
418+ // Take the write lock so the next ticked cleanup blocks on DELETE.
419+ tx := database .Client ().Begin ()
420+ require .NoError (t , tx .Error )
421+ require .NoError (t , tx .Exec (
422+ "CREATE TABLE IF NOT EXISTS lock_probe (id INTEGER PRIMARY KEY)" ).Error )
423+ require .NoError (t , tx .Exec ("INSERT INTO lock_probe (id) VALUES (1)" ).Error )
424+
425+ time .Sleep (50 * time .Millisecond ) // let a tick land and block
426+
427+ stopped := make (chan struct {})
428+ go func () {
429+ cleaner .Stop ()
430+ close (stopped )
431+ }()
432+
433+ select {
434+ case <- stopped :
435+ tx .Rollback ()
436+ t .Fatal ("Stop returned while a cleanup was still in flight" )
437+ case <- time .After (200 * time .Millisecond ):
438+ }
439+
440+ tx .Rollback () // release the lock; the cleanup can now finish
441+
442+ select {
443+ case <- stopped :
444+ case <- time .After (5 * time .Second ):
445+ t .Fatal ("Stop did not return after the cleanup finished" )
446+ }
447+ }
448+
449+ // Cancelling the context stops the goroutine, and a later Stop is still safe.
450+ func TestEventCleaner_ContextCancelThenStop (t * testing.T ) {
451+ database := newTestCleanerDB (t , nil )
452+ cleaner := NewEventCleaner (database , intPtr (3600 ), intPtr (0 ), "test-chain" , zerolog .Nop ())
453+ cleaner .cleanupInterval = time .Millisecond
454+
455+ ctx , cancel := context .WithCancel (context .Background ())
456+ require .NoError (t , cleaner .Start (ctx ))
457+ cancel ()
458+ time .Sleep (20 * time .Millisecond )
459+
460+ cleaner .Stop () // must not hang or panic
461+ assert .False (t , cleaner .running )
462+ }
463+
464+ // Restart after Stop gets a fresh channel rather than reusing the closed one.
465+ func TestEventCleaner_RestartAfterStop (t * testing.T ) {
466+ database := newTestCleanerDB (t , nil )
467+ cleaner := NewEventCleaner (database , intPtr (3600 ), intPtr (0 ), "test-chain" , zerolog .Nop ())
468+ cleaner .cleanupInterval = time .Millisecond
469+
470+ require .NoError (t , cleaner .Start (context .Background ()))
471+ cleaner .Stop ()
472+
473+ require .NoError (t , cleaner .Start (context .Background ()), "restart was refused" )
474+ cleaner .Stop ()
475+ }
0 commit comments