fix(backend): close SSE reconcile race that could permanently drop a user's event triggers - #32
fix(backend): close SSE reconcile race that could permanently drop a user's event triggers#32LukasHirt wants to merge 1 commit into
Conversation
ac26ece to
f714fe8
Compare
…user's event triggers Two related bugs in the per-user SSE consumer manager (pkg/sse/manager.go) combined to cause an up-to-30s or, in the worst case, permanent gap between a workflow's event trigger becoming active and its uploads actually firing: 1. consumeForUser inserted its m.active[userID] entry *before* calling GetAutomation, but never removed it if GetAutomation failed (e.g. the user has an event trigger but hasn't connected automation yet, or their app-password was revoked). Because the entry never left m.active, every later reconcile() pass treated that user as "already active" and never retried the consumer — even once automation was connected. Fixed by having consumeForUser remove its own entry on that one failure path. The other return paths (ctx cancelled by reconcile's removal logic or by shutdown) already have their entries removed by the canceller, so self-removal there is skipped to avoid racing with a fresher entry a later reconcile pass may have already installed for the same userID — guarded with a per-entry generation id (activeConsumer.id) so the cleanup only ever deletes the entry it actually owns. 2. reconcile() only ran on a fixed 30s ticker, so a workflow's event trigger being added/enabled, or a user's automation being connected, could miss an upload that happened in the blind window before the next tick opened that user's SSE stream. Added Manager.Kick(), a non-blocking, drop-if-pending signal that Start's select loop also listens on, and wired it into the two write paths that can affect the wanted-consumer set: WorkflowsHandler.syncTriggerIndex (workflow create/update) and automation.Service.Connect. Tests added to pkg/sse/manager_test.go under TDD, watched failing against the old code before the fix: - TestReconcileRetriesConsumerAfterTransientGetAutomationFailure - TestKickTriggersImmediateReconcile Full backend suite (go build, go vet, go test ./... -race) passes. Signed-off-by: Lukas Hirt <info@hirt.cz>
f714fe8 to
79f4285
Compare
dj4oC
left a comment
There was a problem hiding this comment.
Review: fix(backend): close SSE reconcile race that could permanently drop a user's event triggers
Stats: +204/-26 across 7 files — a concurrency fix, so I traced the actual interleavings rather than reading the diff at face value.
Overview
Fixes two real bugs in the per-user SSE consumer manager: (1) a consumer that failed its initial GetAutomation call left a permanent, never-retried "active" entry, since nothing removed it; (2) reconcile() only ran on a fixed 30s ticker, so a newly-added trigger or newly-connected automation could miss events for up to that long. The fix adds a per-entry generation id to activeConsumer so a goroutine's deferred self-cleanup can never clobber a newer entry for the same user, plus a non-blocking Kick() channel wired into the two write paths that can affect the wanted-consumer set.
What I specifically traced through
This is exactly the kind of change where the fix for one race can quietly introduce another, so I worked through the interleavings by hand rather than trusting the doc comments at face value:
- The generation-id guard is doing real work, not just defensive decoration. I constructed the scenario it's meant to prevent: a slow
GetAutomationcall for user U (id=5) is still in flight when U's trigger is removed (reconcile's stop-loop cancels+deletes the id=5 entry), then immediately re-added before the old goroutine notices (a new reconcile starts a fresh id=6 goroutine). When the old (id=5) goroutine'sGetAutomationfinally returns — possibly because its ctx was cancelled — it callsdeactivate(U, 5), and thec.id == idcheck correctly refuses to delete the newer id=6 entry. Without the id, this would be a real "dead goroutine deletes a live one's bookkeeping" bug. - The claim that every other return path in
consumeForUseris ctx-caused — which is what the fix's whole "only this one path needs self-cleanup" premise rests on — I checked against the full, unchanged function body (fetched frommain), not just the diff. Every return in the reconnect loop is gated byctx.Done()/ctx.Err() != nil. The claim holds; the fix isn't missing a second spot that needed the same treatment. reconcile()'s stop-loop callscancel()thendelete()on the same entry while holdingm.mufor the whole function — so by the time any other code (includingdeactivate) can acquire the lock, both halves of that removal have already happened atomically. No window where a cancelled goroutine could see a half-updated map.TestKickTriggersImmediateReconcile's "warm-up trigger" indirection is solving a real problem, not just being cautious: without first confirming (viawaitForon a pre-existing trigger) thatStart's initial synchronousreconcile()has already run and the goroutine has moved into itsselectloop, the test's owntriggers.entries = append(...)would race against that initial reconcile's unsynchronized read of the same slice — and the test could pass "by accident" if Start's first pass happened to catch the new entry on its own, which wouldn't actually proveKick()did anything. The warm-up establishes a real happens-before relationship before the mutation, which is the right fix for a test that's specifically trying to prove an event-driven (not ticker-driven) code path fired.
I didn't find a bug in any of this — it's a correct, well-reasoned fix for a genuinely subtle pair of races.
Code quality & style
- Scoping
Kick()to only fire onentry.TriggerType == "event"(not "schedule") insyncTriggerIndexis correctly targeted — a schedule-trigger save has nothing to do with the SSE manager, so there's no reason to wake it. Worth noting there's an intentional asymmetry: adding/enabling an event trigger kicks immediately, but removing/disabling one doesn't — which is the right call, since a late consumer shutdown (up to 30s of an unnecessary open SSE stream) is harmless, while a late consumer start (up to 30s of silently missed uploads) is the actual bug being fixed. - Two separate one-method
ReconcileKickerinterfaces (inautomationandservice) rather than a shared type is consistent with how this codebase already defines interfaces locally at the point of use elsewhere (Executor,FileClient, etc.), not a new pattern to question. kick may be nilbeing explicitly documented and handled (if s.kick != nil) rather than assumed makes the existing tests'nilargument obviously safe rather than something to double-check.
Cross-PR note
NewWorkflowsHandler's constructor signature gains a kick parameter here, and syncTriggerIndex's body is touched by this PR and by two other currently-open PRs (#30 adds a webhook case to the same function's switch statement; #31 adds an entry.SpaceID = ... line to the same event-trigger branch). Once this PR's signature change lands, PR #30's own test file (which calls NewWorkflowsHandler(...) with the current 5-argument shape) will need a nil kick argument added, the same mechanical update this PR already made to automation_test.go/renew_test.go. Worth sequencing accordingly rather than a surprise at merge time.
Test coverage
Both new tests are regression tests in the proper TDD sense (written to fail against the old code first, per the PR body) and each is targeted at exactly one of the two bugs. Combined with the existing TestReconcileStartsAndStopsConsumersAsTriggersChange (confirmed still passing, covering the removal path this fix deliberately didn't touch), coverage is appropriately complete for the scope of this change. The PR body also notes go test ./... -race was run locally specifically to validate the new mutex/generation-id logic — exactly the right extra step for this kind of change, even though it's not part of CI.
Summary
A correct, carefully-reasoned fix for a real production bug (permanently dead consumers, up to 30s of missed events on new triggers). I traced the concurrency claims by hand rather than taking them at face value and didn't find a gap. Nothing here blocks merge.
🤖 Generated with Claude Code
Summary
The per-user SSE consumer manager (
backend/pkg/sse/manager.go) had two related timing/race bugs that could cause event-triggered workflows to silently never fire uploads:Dead consumer never retried.
consumeForUserinserted itsm.active[userID]entry before callingGetAutomation, but never removed it ifGetAutomationfailed (e.g. the user has an event trigger but hasn't connected automation yet, or their app-password was revoked). Since the entry never leftm.active, every laterreconcile()pass saw the user as "already active" and never retried the consumer, even after automation was later connected — the consumer was dead forever.Fix:
consumeForUsernow removes its ownm.activeentry when it exits due to aGetAutomationfailure. All the other return paths happen becausectxwas cancelled byreconcile's removal logic or by shutdown — those already remove the entry themselves, soconsumeForUsermust not also delete it there (that would race with a fresher entry a laterreconcilepass may have already installed for the sameuserID). This is guarded with a small per-entry generation id (activeConsumer.id) so self-cleanup only ever deletes the entry it actually owns.Up-to-30s blind window.
reconcile()only ran on a fixed 30s ticker, so a workflow's event trigger being created/enabled, or a user's automation being connected, could miss an upload that happened before the next tick opened that user's SSE stream.Fix: added
Manager.Kick()— a non-blocking, drop-if-pending signal — thatStart's select loop also listens on alongside the ticker. Wired it into the two write paths that can affect the wanted-consumer set:WorkflowsHandler.syncTriggerIndex(workflow create/update, inpkg/service/workflows.go) andautomation.Service.Connect(inpkg/automation/automation.go). Both take an optionalKick()-satisfying interface so they stay decoupled from the concrete*sse.Managertype;backend/pkg/command/server.gowas reordered to construct thesse.Managerfirst and wire it into both.Test plan
TDD: wrote the following tests against the old code first and watched them fail for the right reason, then implemented the fix and watched them pass.
TestReconcileRetriesConsumerAfterTransientGetAutomationFailure(new,pkg/sse/manager_test.go) — reproduces bug chore(deps): Bump actions/setup-node from 6.3.0 to 7.0.0 #1: automation missing on first reconcile, connected later, asserts the consumer is retried.TestKickTriggersImmediateReconcile(new,pkg/sse/manager_test.go) — reproduces bug chore(deps): Bump actions/checkout from 6.0.2 to 7.0.0 #2: uses a "warm-up" trigger to proveStart's select loop is actually running (avoiding a scheduling race), then assertsKick()picks up a newly-added trigger well within the (1-hour, in the test) ticker interval.pkg/ssetests continue to pass, includingTestReconcileStartsAndStopsConsumersAsTriggersChange(removal-path coverage).Ran locally, matching CI's backend job exactly:
go build ./...go vet ./...go test ./...go test ./... -race(extra, not in CI but run to validate the new mutex/generation-id logic)go build -tags=e2e ./.../go vet -tags=e2e ./...(constructor signature changes didn't break e2e compilation)No unrelated code touched — the share-file action node commits on this branch's ancestry were left untouched; this PR only modifies
pkg/sse/manager.go(+tests),pkg/service/workflows.go,pkg/automation/automation.go, andpkg/command/server.gowiring.