Hash-phase error path abandons and deadlocks the worker pool #6
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?
hashPhasereturns immediately whenrecordRunfails (scan.go:399-402), abandoning the remaining runs. The feeder goroutine (scan.go:375-381) then blocks forever sending onjobs, and every hash worker (scan.go:791-800) blocks forever sending onresultsonce its 1024-slot buffer fills.Today this is masked: the caller calls
fatalfand the process dies with the goroutines still parked. It becomes a real leak the moment the scan runs in-process — which is exactly what #5 (graceful interrupt), #16 (CLI-surface tests) and #18 (race-detector target) all require.Definition of done
hashPhasecancels the feeder and drainsresultsbefore returning an error, or the pool becomes context-aware and unwinds through the same cancellation path as #5.runtime.NumGoroutine()returns to its pre-scan baseline, allowing a short settle window.make checkgreen.Implementation plan (branch
hash-pool-cleanup, frommainat2a055c0):1. Make the scan context-aware, end to end.
ctx context.Contextbecomes the first parameter ofrunScanandsyncScan, sourced fromcmd.Context()in the cobraRunEwrapper(
runEgrows actxparameter). Nothing installs a signal handlerhere — that is #5's job; this PR only builds the cancellation path #5
will hook into, plus uses it for the error path that is broken today.
2. Replace the ad-hoc hash pool with an owned, cancellable one.
A small
hashPooltype owns the feeder goroutine and the workergoroutines. Its context is derived from the scan's, and every blocking
send inside the pool (
jobs <- runin the feeder,results <- ...inthe workers) becomes a
selectagainstctx.Done(). The feeder alwaysclosesjobson the way out, so the workers'range jobsalwaysterminates.
hashPhasedoesdefer pool.stop(), andstopcancels,then drains
resultsuntil every pool goroutine has exited — drainingis the part that matters, because a worker already parked on a send
will not observe the cancellation until someone reads. The loop over
results also selects on
ctx.Done()so an externally cancelled scan(i.e. #5) unwinds the same way. Net effect:
hashPhasereturning earlyfor any reason leaves zero goroutines behind (DoD 1).
3. Walk pool.
It has the same unbounded-blocking-send shape (
events <- ...from thewalk workers and
seedRoot,subdirs <- ...,jobs <- ...from thedispatcher) but it does not leak today, because
walkPhasehas noearly return: it always drains
eventsto close. #5 will introduceexactly such an early return, so the same
select-on-ctx.Done()treatment is applied there, and
dispatchDirsgrows adefer close(jobs)so the workers can never be stranded. Onecorrectness guard comes with it:
syncScanchecksctx.Err()afterthe walk, because a cancelled walk yields a truncated size census and
the update phase would read every unreached file as vanished and delete
its record. Zero behaviour change today (the context is never cancelled
in production yet); it is what keeps #5 from having to re-plumb this.
4. Test (DoD 2 and 3),
scan_test.go.Injection is a real database write failure, not a fake: the test
pre-creates the database at
SFDUPES_DATABASEwith the normal schemaplus
CREATE TRIGGER ... BEFORE INSERT ON files BEGIN SELECT RAISE(ABORT, 'injected write failure'); END. Reads (the record indexload) still work; the first batch commit inside the hash phase fails,
which is exactly the
recordRunerror path in question. The tree is afew thousand more empty files than
updateBatchSize, so the failurelands partway through the phase with well over
workQueueDepthrunsstill queued — that is what guarantees parked workers if the fix is
absent. Empty files are never opened by the hasher, so the fixture
costs directory entries and no I/O. The test drives the real entry
point (
run([]string{cmdScan, dir}, ...)), assertsexitFatal, andthen polls
runtime.NumGoroutine()back to its pre-scan baseline witha bounded settle loop (poll, not a fixed sleep). I will verify the test
genuinely fails with the production change reverted.
5.
TODO.mdCompleted Steps entry in the same commit;make fmt,then both
make checkandmake dockergreen before pushing; PRagainst
maintitled... (closes #6), labelledneeds-review,assigned to
clawbot.Correction to step 3, recorded during the rework of #31. The claim
above that "the update phase would read every unreached file as
vanished and delete its record" overstates what happens today. With the
guard deleted,
updatePhasedeletes nothing: its firstBeginTxfailson the same cancelled context first. The guard is defence in depth, not
the sole barrier against data loss. It is still the right thing to
have — it is the barrier that survives #5 deciding an interrupted scan
may commit what it has, and it discards the partial census at the phase
boundary instead of letting a confusing
begin transaction: context canceledsurface from deep inside theupdate phase.