fix(batch): panic recovery, deadlock-safe error sends, bounded prealloc, Done() race#65
fix(batch): panic recovery, deadlock-safe error sends, bounded prealloc, Done() race#65MasterOfBinary wants to merge 14 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #65 +/- ##
==========================================
+ Coverage 96.76% 98.03% +1.26%
==========================================
Files 12 12
Lines 371 407 +36
==========================================
+ Hits 359 399 +40
+ Misses 9 6 -3
+ Partials 3 2 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request introduces several hardening improvements to the batch processing engine. Key changes include guarding the read of b.done with a mutex to prevent data races, making error channel sends context-aware to avoid deadlocks when the error buffer is full, recovering from panics in user processors, capping slice pre-allocation to prevent out-of-memory crashes from huge configurations, and refactoring timers to use time.Timer with Reset to prevent timer leaks. Extensive unit and integration tests have been added to verify these fixes. There are no review comments, so no feedback is provided.
da42871 to
1fd4f94
Compare
27ee6f5 to
a042228
Compare
1fd4f94 to
f689df1
Compare
3b59001 to
d49ace6
Compare
A pathological config with a huge MinItems and MaxItems==0 (reachable via DynamicConfig.UpdateBatchSize(huge, 0)) caused waitForItems to call make([]*Item[T], 0, MinItems), attempting a multi-terabyte allocation that crashes/OOMs the process. Add clampPreallocCap, a pure helper that bounds the initial slice capacity to maxPreallocCap (4096) and treats MaxItems as a downward cap. The slice still grows via append as needed; only the allocation hint is bounded. Wire it into waitForItems. Behavior is unchanged for all normal configurations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Internal sends to b.errs in doReader and the per-batch processor goroutine were unguarded blocking sends. Once the error buffer filled and nobody drained it, the pipeline deadlocked and cancel() could not free it, so Done() never closed. Make each internal b.errs send a context-aware select, and add a <-ctx.Done() arm to doReader's main loop that closes b.items and returns. A cancelled context now always unblocks a wedged reader and any wedged per-batch goroutine, letting the pipeline complete (errs and done close). Draining the error channel remains the documented caller responsibility; this only restores the ability of context cancellation to break out of a wedge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-batch goroutine called proc.Process with no recover, so a panic in a
user Processor crashed the entire host process.
Add a deferred recover inside the goroutine, declared after defer wg.Done() so
that (deferred-LIFO) recover runs first and wg.Done() still fires. The recovered
panic is wrapped as a ProcessorError ("processor panic: <value>") and delivered
via a context-aware send, so the pipeline completes cleanly (errs and done
close) instead of taking down the process.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
waitForItems created its min/max timers with time.After and re-armed the max timer with a fresh time.After on every idle fire, leaking a runtime timer each cycle for the lifetime of the batch. Refactor to a single *time.Timer per bound created with time.NewTimer, stop both on every return path via a deferred Stop, and re-arm the max timer with Reset (the channel is already drained by the receiving case, so Reset is safe). The select waits on nil channels when a bound is unset, preserving the existing ignore-unset-timer behavior. Pure refactor; batching behavior is unchanged. Add TestMaxTimeMultipleIdleCyclesThenLateItem, which drives several idle MaxTime cycles then delivers a late item and asserts it is still processed, locking the re-arm behavior against this refactor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Done() read b.done without holding b.mu, while Go assigns b.done under the lock, which the race detector flags as a data race. Take b.mu in Done() to read the field into a local, release immediately, then return the channel (or the pre-closed sentinel). The lock is never held while waiting on the channel, so this introduces no deadlock. Add TestDoneRace, which hammers Done() concurrently with Go() and is clean under -race after the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add TestConcurrentGoPanics, which starts a batch against a blocked source and asserts a second concurrent Go call panics with the documented message "Concurrent calls to Batch.Go are not allowed", and TestSourceErrorMessage, which asserts SourceError.Error() formats as "source error: <cause>". Both cover existing behavior; no production change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "items at indexes 0, 3, 6 ... 1 more error" comment reflected a
misunderstanding: errorPerItemProcessor fails items by their per-batch local
index (i%FailEvery), not by a fixed set of global indices, so which items fail
depends on batch boundaries.
Rewrite the comment to explain the real per-batch behavior and, with the
deterministic MinItems=5 boundaries (batches [0..4] and [5..8]), strengthen the
assertion to verify the exact set of failing item IDs {0, 3, 5, 8} by parsing
the ID out of each error, instead of only counting four errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The engine wraps with &SourceError{} and &ProcessorError{}, so errors.As only
matches the pointer-target form, errors.As(err, new(*SourceError)); the
value-target form does not match because *SourceError is not assignable to
SourceError. Add a godoc note with an example to both SourceError and
ProcessorError so callers use the correct form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the deadlock-safe ctx-aware error sends (a cancelled context can still break a pipeline wedged on a full, undrained error buffer), but remove the proactive `case <-ctx.Done()` arm from doReader's main select. That arm — which stops reading on cancel rather than relying on the Source — becomes the opt-in CancelStop behavior in the forthcoming WithCancelMode PR, so the default here matches the existing contract (rely on the Source to stop; already-read items are still processed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the hardening tests to Go's (<-chan error, error) signature and remove TestConcurrentGoPanics — a second Go now returns ErrBatchUsed instead of panicking, which single_use_test.go already covers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
f689df1 to
027b25a
Compare
…el-escape paths Replaces the five duplicated select-based error sends with one sendErr helper, and adds tests for the two escape paths nothing exercised: the item-error send wedge and the nil-channel source report. Closes the codecov patch gap (4 uncovered ctx.Done arms). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…panic error identity Addresses three review findings: - sendErr now tries a non-blocking send first, so a canceled context never randomly preempts a send that would succeed. Cancellation only escapes a genuinely blocked send; error delivery to a draining consumer is deterministic again. Early-abort on failed sends is kept: it can now only trigger in the wedged state (buffer full + canceled), where further processing would only produce undeliverable errors. - Panic detection uses a completion flag instead of recover()'s return value, so panic(nil) (recover() == nil under this module's Go 1.18 semantics) is still reported as a ProcessorError. - Error-valued panic payloads are wrapped with %w, preserving errors.Is/errors.As identity through ProcessorError. Also deletes the unreachable nil-processor guard in the batch loop (Go filters nil processors at start). All three regression tests fail against the previous commit and pass with the fixes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers sendErr's slow-path success arm deterministically: with a 1-slot error buffer and a consumer that sleeps between receives, a send that finds the buffer full must block and deliver once a slot frees — never drop while the context is live. Previously this arm was only covered by scheduler luck (codecov patch flagged it on CI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Hardens the batch engine against four failure modes found in a full-repo review. Each fix was written test-first: the panic test crashed the process and the
Done()test reported a data race before the fixes.Fixes
Processor.Processis recovered and surfaced as aProcessorErrorinstead of crashing the host process.ctx.Done(), so a full, undrained error buffer can no longer permanently wedge the pipeline.waitForItemscaps its slice pre-alloc, so a hugeMinItems(reachable at runtime viaDynamicConfig.UpdateBatchSize) can't cause a makeslice panic / OOM.Done()data race —Done()now readsb.doneunder the mutex.Also included
Gopanic,SourceError.Error()formatting, idle-timer re-arm, exact failing-item-ID assertions.errors.Aspointer-target form (new(*SourceError)) documented on both error types (see errors.As with a value target never matches SourceError/ProcessorError #91 for the underlying type-identity question).Compatibility
No exported API changes; behavior changes are confined to the fixed bugs and are strictly safer. Proactive stop-on-cancel is deferred to #76 (
WithCancelMode).Testing
go test -race ./...green,go vetandgofmtclean on the branch merged with current master.🤖 Generated with Claude Code