CRITICAL: delivery engine and retention reaper both die ~15s after startup (fx OnStart context) #97
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Surfaced by the independent review of PR #95 (#89), which found this pattern in that PR's new sweeper. Checking the rest of the tree shows the same defect is already on
mainin two places, and one of them is the core of the product.This should be treated as release-blocking for 1.0.
The defect
The
context.Contextpassed to an fxOnStarthook is not an application-lifetime context. fx derives it withcontext.WithTimeout(ctx, StartTimeout)whereStartTimeoutdefaults to 15 seconds, and it is cancelled once the start phase ends. Any goroutine that derives its lifetime from it stops shortly after startup.Both live users of the hook context in this repo do exactly that.
1.
internal/delivery/engine.go— the delivery engine (critical)Engine.start(ctx)at line 213 doesctx, cancel := context.WithCancel(ctx)on the OnStart context and then launches everything from it:go e.worker(ctx), line 220)go e.recoverPending(ctx)(line 225)go e.retrySweep(ctx)(line 229)workerselects on<-ctx.Done()and returns. So roughly 15 seconds after startup, every delivery worker exits and the process silently stops delivering webhooks entirely. Inbound events are still received and persisted to the per-webhook event DBs, andNotifystill pushes tasks ontodeliveryChuntil it fills and starts logging "delivery channel full" — but nothing consumes them. The retry sweep and restart recovery are dead too.That is the entire purpose of the application. A 1.0 tagged with this ships a webhook proxy that stops forwarding webhooks a quarter of a minute after it boots.
2.
internal/database/retention.go— the event retention reaperRetentionReaper.start(ctx)at line 75 has the samecontext.WithCancel(ctx)on the hook context.RETENTION_SWEEP_INTERVALdefaults totime.Hour, so in the default configuration the reaper never runs a single sweep — the loop is cancelled 45 minutes before its first tick. The feature delivered in #63 / PR #78 is silently inert, and per-webhook event databases grow without bound exactly as they did before that work landed.Evidence
The PR #95 reviewer verified the mechanism empirically against a real
make buildbinary, not a test double: withRETENTION_SWEEP_INTERVAL=2sand an archive expiring rows every 10s, sweeps fired at T+4s and T+14s and then stopped for the remaining 46s of a 60s run, while rows that expired at T+24/34/44s survived. The loop context's deadline was T+15s. Reproduced in isolation with a scratch test using a 50ms-deadline context.Fix
Background loops must own an application-lifetime context, not the start-phase one:
The existing
OnStophook already callscancel()and waits on thesync.WaitGroup, so shutdown stays correct — the only thing that changes is that the loops survive past startup. Do not simply lengthenStartTimeout; that mistakes the symptom for the cause.Apply to both sites. Ignore the hook's
ctxparameter (name it_) so the trap cannot be reintroduced by someone "fixing" an unused-parameter warning, and leave a comment at each site explaining why the hook context must not be used.Definition of done
Engine.startandRetentionReaper.startderive their loop context fromcontext.Background(), not the OnStart hook context.context.Background()in proves nothing, because that is exactly the bug.OnStopcancels the loop andwg.Wait()returns without hanging. Cover it, so the fix does not trade one lifecycle bug for another.4f5ecb1the only two hooks that take the context are the two above; the rest use_. Confirm that is still true at implementation time.make checkgreen via the repo's own entrypoints.Note on PR #95
PR #95 introduces a third instance of this in its new
internal/delivery/archive_sweeper.go. That one is being fixed in that PR's rework rather than here, so the two do not collide. This issue covers only the two pre-existing sites onmain.Implementation plan for this issue, on branch
issue-97-lifecycle-context:internal/delivery/engine.goandinternal/database/retention.go: extract thelc.Append(fx.Hook{...})call into aregisterHooksmethod, changeOnStartto take_ context.Context, drop the context parameter fromstart(), and root the loop context atcontext.WithCancel(context.Background()). Leave a doc comment at eachstartexplaining why the hook context must not be used, and a//nolint:contextcheckon the hook. Same shape as the fix in PR #95'sArchiveSweeper, so the three sites read identically.internal/delivery/engine_lifecycle_test.goandinternal/database/retention_lifecycle_test.go. Each drives the genuine registered hook (throughregisterHooks, via anExport...shim and a recordingfx.Lifecycle) with an already-cancelledOnStartcontext, then asserts the loop is still doing work — a delivered task for the engine, a reaped event for the reaper. Each also gets a graceful-shutdown test assertingOnStopcancels andwg.Wait()returns inside a bounded timeout, and that the component really is stopped afterwards.fx.Hookin the tree to confirm no other long-lived goroutine roots itself in a hook context.TODO.mdentry in the same commit;make fmt;make checkandscript/cibuildgreen.Out of scope here:
internal/delivery/archive_sweeper.godoes not exist onmain— it belongs to PR #95 and is fixed there.