fix: shed load and survive shutdown instead of blocking and panicking in Submit - #65
Open
GautamSharma99 wants to merge 1 commit into
Open
Conversation
… in Submit worker.Pool.Submit was a bare channel send, called synchronously from inside the Fiber request handlers. Two failures followed. Once JOB_BUFFER_SIZE jobs were queued and every worker was busy, the send blocked and parked the request goroutine, so POST /v1/scrape and POST /v1/url-scraper stopped responding rather than shedding load. Clients saw connections hang until they timed out, with no log line to explain it. The batch endpoint is the easiest way in: it submits up to 10 URLs in a tight loop with no admission control. And Drain closes the job channel, so any Submit racing it panicked with "send on closed channel" and took the process down. The window is real rather than theoretical: main.go bounds app.ShutdownWithContext at 30s, but a sync scrape can legitimately still be inside its handler at 120s since Anakin-Inc#12 raised the ceiling, so Drain can close the channel while a handler is live. Submit now returns an error — ErrQueueFull when the queue is at capacity, ErrPoolClosed once draining has begun — and never blocks. The handlers translate both into 503 with Retry-After, and mark the already-created job row failed so it is not left pending forever with nothing to pick it up. A batch marks each rejected child individually and only returns 503 when nothing at all could be queued. A single RWMutex covers both problems: Submit holds it for reading across the non-blocking send, and Drain takes it for writing before closing, so no send can be in flight at the moment of the close. Drain is also idempotent now. The doc comment said "Non-blocking if buffer has space", which framed the safe case as the contract; it now states the refusal behaviour. internal/worker had no test file. All three defects are reproduced against the previous implementation: the full-queue test times out after 5s waiting for Submit to return, the race test panics with "send on closed channel", and the idempotence test panics with "close of closed channel". Verified end to end against a slow origin with JOB_BUFFER_SIZE=5 and WORKER_POOL_SIZE=1: of 20 concurrent submissions, 6 were accepted (1 in flight plus 5 buffered) and 14 returned 503 in under 3ms each, rather than hanging. Coverage: internal/worker 0% -> 96.6%. Fixes Anakin-Inc#58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #58.
Branched from master and independent of my other open PRs — nothing else touches
internal/worker.The two failures
worker.Pool.Submitwas a bare channel send, called synchronously from inside the Fiber request handlers.It blocked instead of shedding load. Once
JOB_BUFFER_SIZEjobs were queued and every worker was busy, the send parked the request goroutine, soPOST /v1/scrapeandPOST /v1/url-scraperstopped responding. Clients saw connections hang until they timed out, with no log line to explain it.It panicked during shutdown.
Draincloses the job channel, so aSubmitracing it died withsend on closed channel, taking the process down. That window is real rather than theoretical:main.goboundsapp.ShutdownWithContextat 30s, but a sync scrape can legitimately still be inside its handler at 120s since #12 raised the ceiling — soDraincan close the channel while a handler is live.The fix
Submitnow returns an error and never blocks:ErrQueueFullat capacity,ErrPoolClosedonce draining has begun.A single
RWMutexcovers both problems.Submitholds it for reading across the non-blocking send;Draintakes it for writing before closing. Taking the write lock has waited out every in-flightSubmit, so no send can be in flight at the moment of the close — the race is eliminated by construction rather than narrowed.Drainis idempotent too. Because the send isselect/default, the read lock is never held long enough to matter.The handlers translate both errors into
503withRetry-After, and mark the already-created job row failed — otherwise it sits pending forever with nothing to pick it up. A batch marks each rejected child individually and only returns 503 when nothing at all could be queued, so a partially-accepted batch still gets its id back.The doc comment said "Non-blocking if buffer has space", which framed the safe case as the contract; it now states the refusal behaviour.
Verification
internal/workerhad no test file. All three defects reproduce against the previous implementation:The race test runs 8 concurrent submitters against
Drainover 20 attempts, so it isn't relying on one lucky interleaving.I also ran the issue's own reproduction end to end. Server with
JOB_BUFFER_SIZE=5 WORKER_POOL_SIZE=1against a 10s origin, 20 concurrent submissions:Previously the 14 would have hung until a worker freed a slot. Now they are refused immediately with a backoff hint.
The handler-side tests use a pool with no workers and no buffer, so every
Submitis refused deterministically — same state a real pool reaches under saturation, with no timing dependence.Coverage:
internal/worker0% → 96.6%.Full CI parity locally —
gofmt -lclean,go build ./...,go vet ./...,go test -race ./...all pass.Notes for the reviewer
Submitnow returns an error. All three call sites are inscraper.goand are updated; there are no others.Retry-Aftergives clients something actionable. Happy to tune the retry hint or the message.scraper_queue_test.goand its fake is namedstubStore, so it does not collide with thescraper_test.go/fakeStoreadded by fix: test the shipping sync-timeout clamp, and fix an overflow it hid #60.processingforever when they hitJOB_TIMEOUT— the failure is written with the already-expired job context #32 (terminal state written with an expired context). They touch the samebgCancel()/Drain()shutdown path, so fix: record terminal job state with a fresh context so timed-out jobs don't hang inprocessing#33 and this PR are worth landing near each other, but they fix different defects and do not overlap in code.## UnreleasedCHANGELOG heading, and possibly a small one with fix: test the shipping sync-timeout clamp, and fix an overflow it hid #60 inScrapeSync— different hunks, so it should auto-merge. Happy to rebase whichever lands last.