Structured concurrency design #130
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
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?
nidus-sync currently has a problem shutting down the backend. Shutdown on a SIGINT is taking 20 seconds or more even on a dev machine where the process should be totally idle and shutdown should be 10s of milliseconds tops. I have a suspicion that it's caused by waiting for timeouts on some contexts rather than gracefully shutting down goroutines. A recent shutdown of nidus-sync showed this in the log:
I'd like you to research the various goroutine/concurrency patterns in the nidus-sync codebase. Try to group them by pattern with an eye toward any patterns that may be missing a canceleable context, leaking a goroutine, or failing to check the context when waiting for other signals. Any of these could contribute to slow shutdown.
Additionally, I'm considering using structured concurrency from a library like https://github.com/sourcegraph/conc. I'd be interested in an assesment of the feasibility of refactoring to use conc as part of the study.
Goroutine/Concurrency Pattern Analysis
I've done a thorough audit of every goroutine and concurrency pattern in nidus-sync. Here is a catalog grouped by pattern, followed by shutdown delay root cause analysis and a
concfeasibility assessment.Pattern Catalog
Pattern 1: Global WaitGroup-Managed Background Loops
Files:
platform/start.go,platform/planet.golistenForJobswaitGroupWaitForNotification(ctx-aware)commsemail.StartWebsocketwaitGroupconn.ReadMessage()(NOT ctx-aware)StartPlanetAuthCheckwaitGroupselectonctx.Done()withtime.AfterProblems:
select { case <-ctx.Done(): return; default: conn.ReadMessage() }pattern appears cancellable but is not. Thedefaultbranch is chosen immediately andconn.ReadMessage()blocks indefinitely. Aftercancel()in main.go, the goroutine cannot be interrupted — it stays blocked on the websocket read until the server closes or a TCP timeout fires. This is the primary cause of the 20+ second shutdown delay.conn.SetReadDeadline(time.Now().Add(5*time.Second))before each read, or launch a goroutine that callsconn.Close()when ctx is cancelled.Pattern 2: Unmanaged Fire-and-Forget Background Goroutines
File:
main.goplanet.StartSync(line 163)http.ListenAndServereturns an error (never)server.Shutdown)server.Shutdownkills the listeneraddWaitingJobsgoroutine (start.go:90)handleJobhangs on an external API, this goroutine leaksFindings:
http.ListenAndServewhich only returns on a fatal error. Low-risk but technically a leak.addWaitingJobs(start.go:61) spawns a goroutine that processes any pending database jobs found at startup. This goroutine is NOT tracked in the WaitGroup. If a job handler hangs (e.g., external API timeout), the goroutine continues pastWaitForExit(). Jobs should either be tracked in the WaitGroup or moved into thelistenForJobsnotification system.Pattern 3: SSE Event Dispatcher Goroutine
File:
api/event.go:68Problems:
conn.chanEventwhich is an unbuffered channel. If the SSE client has disconnected but the entry hasn't been removed fromconnectionsSSEyet, the dispatcher blocks forever on that send. The SSE handler cleans up viadefer delete(connectionsSSE, &connection)whenr.Context().Done()fires, but there's a race.EventShutdown()→server.Shutdown()→cancel()→close(chan_envelope)→WaitForExit(). Ifchan_envelopeis full (buffer=10) because the dispatcher is blocked on SSE clients,EventShutdown's goroutine blocks. Theclose(chan_envelope)then causes the dispatcher's range loop to exit, but if an SSE client channel isn't being drained, it blocks iterating. Worse: a sender goroutine racing with the close will panic (send on closed channel).Pattern 4: Event System Fire-and-Forget
File:
platform/event.go,platform/event/event.goEvery
Created(),Updated(),UpdatedUser(), andShutdown()call firesgo Send(...). These push onto a buffered channel (buffer=10). If the channel fills up, every event-sending goroutine blocks forever. HTTP handler call sites with bursts of events could get stuck.Pattern 5: HTTP Handler Fire-and-Forget
Files:
api/twilio.go:137,api/voipms.go:98These use
context.Background()instead ofr.Context(). Acceptable since webhook work should continue after the response, but no timeout/cancellation propagates from the request.Pattern 6: ArcGIS Fieldseeker (Commented Out)
File:
platform/arcgis.gorefreshFieldseekerDatahas a notable bug: it createsworkerCtxfromcontext.Background()instead ofbackground_ctx. If re-enabled, nested worker goroutines wouldn't be cancelled when the parent cancels. Also,defer cancel()in the infinite loop only runs on function return (last iteration only). All currently dormant since the function is commented out inStartAll.Pattern 7: Worker Pools
Files:
platform/municipal.go,platform/csv/geocode.goBoth use the semaphore + WaitGroup pattern. Issues:
context.Background()for DB inserts, ignoring parent context. On shutdown, workers keep inserting until their work is done.//nolint:unused, so dormant.Pattern 8: Vision Auto-Scaler + Worker
Files:
platform/vision/scaler/scaler.go,cmd/nidus-vision-worker/main.goThese are the cleanest patterns.
Engineproperly usescontext.WithCancel,Stop()waits viawg.Wait(), and the loop checksctx.Done(). The worker usessignal.Notify+ cancel correctly.Root Cause: Why Shutdown Takes 20+ Seconds
Smoking gun: Forward Email websocket (
comms/email/websocket.go:72-77)Even after
cancel()fires in main.go,ReadMessage()does not unblock. The gorilla/websocket library provides no mechanism to interrupt a read via context. The read only returns when:Since Forward Email's server doesn't immediately close the websocket on our shutdown, this goroutine blocks for 20+ seconds until some lower-level timeout fires. During that time,
platform.WaitForExit()callingwaitGroup.Wait()is stuck.Secondary contributor: The SSE dispatcher can also block sending to unbuffered SSE client channels, adding further delay.
Recommended fix (priority 1):
Secondary fix: Make
conn.chanEventbuffered (size 1) inapi/event.goso the dispatcher doesn't deadlock on slow/disconnected SSE clients.Feasibility: Refactoring to
github.com/sourcegraph/concVerdict: Feasible, but the bigger win is fixing the blocking-I/O problems first.
What
concwould improveconc.WaitGroupauto-captures panics and returns errors, replacingsync.WaitGroup+ manual error handling.conc.ContextPoolties goroutines to a context, making cancellation propagation automatic — fixing the patterns where goroutines ignorectx.Done().conccatches panics in child goroutines and re-panics onWait()— the current codebase hasgo func()calls with no recovery at all.conc.Poolwith configurable max goroutines could replace the channel-based semaphore inplatform/municipal.go.What
concwould NOT fixconccan't solveconn.ReadMessage()ignoring context. That requires read deadlines or connection-level cancellation.concdoesn't help with channel selection races or unbuffered channel deadlocks.Migration difficulty by area
concfitplatform/start.gobackground loopsconc.ContextPoolplatform/municipal.goworker poolconc.PoolWithResultsplatform/arcgis.goworkers (dormant)platform/vision/scalerapi/event.goSSE dispatcherapi/twilio.go/api/voipms.goplatform/event.goevent sendsplatform/csv/geocode.go(unused)Recommendation
concwon't help with it.conc— but start small. Migrate just the three WaitGroup goroutines inplatform/start.gotoconc.WaitGroupfor panic safety and cleaner cancellation. Leave the channel-based event system alone.conc.Poolor iterators in the first pass. Start with basicconc.WaitGroupandconc.ContextPoolfor the clearest pain points.Rough migration example
Summary Table
comms/email/websocket.goapi/event.goaddWaitingJobsuntrackedplatform/start.go:90main.go:209go Send()can block on full channelplatform/event/event.gorefreshFieldseekerDatauses Background ctxplatform/arcgis.go:79platform/municipal.go:1242Excellent writeup, thanks for that!
Would you please create a PR for addressing the Critical, high, and medium severity issues from your summary table?
For the
comms/email/websocket.goissue let's go with launch a goroutine that calls conn.Close() when ctx is cancelled. That seems like it is a bit more complex, but will provide near-immediate shutdown capability, which is very valuable.For the SSE channel going to a buffer channel (1) should be fine.
Adding a waitgroup for
platform/start.goseems pretty straightforward as a fix to me. Your other suggested medium severity fixes seem fine too.Please make a commit per fix area for ease of reviewing. I'd appreciate a detailed explanation of each fix with the commit as well so that I can go over the patterns with the engineering team to avoid producing more of these issues in the future.
PR is up: #131
Five commits as requested:
conn.Close()on ctx cancellation to unblockReadMessage()selectwithdefaultprevents goroutine leaks when the event channel fills upcontext.Background()Each commit has a detailed explanation of the fix. I left a note in the PR about the pre-existing go-planet submodule issue (commit no longer on remote) which causes a build failure in
platform/planet_tile.gounrelated to these changes.