P6 review: runner shutdown, cancel-while-busy, concurrent submits, batch-off 503, transactional store, CLI 202
Owner review of p6 (Ornith tasks 01-09), with an independent reviewer pass whose findings I
confirmed against the code. Every fix has a regression test that fails on the old code.
internal/batch
- Close let the workers keep draining every live job under the cancelled context, failing
every target; they now stop at once, store nothing on a failure during shutdown, and leave
the job live for MarkInFlightFailed("restart").
- A job cancelled while its in-flight target came back busy stayed in the rotation forever
(depth gauge stuck, job held in memory); it now leaves. The task 03 design had this gap: mine.
- Submit's idempotency lookup, caps and create ran unlocked: 20 concurrent submits by one
caller made 8 jobs. Serialised with submitMu.
- A failed target insert left a queued job no worker would run (holding the caller's cap);
it is marked failed.
- A second concurrent cancel returned true and counted twice.
- Config: zero OR negative values take the defaults (negative Workers panicked the
WaitGroup; a negative TTL or byte budget would sweep or evict everything).
- Retry timing used the pre-fetch time (a 5 s busy answer made RetryWait moot).
- The rotation cursor is kept within [0, len(live)] (it was absolute and drifted on removal).
- The unused in-memory callers map is gone (Submit racing Close wrote to a nil map).
internal/dcache
- PutBatchTargets, SweepBatch and eviction are transactional; the sweep never removes a
queued or running job.
internal/api
- Every /v1/batch endpoint answers 503 when batch is off; POST panicked on the nil runner.
- GET /{id}/{idx} bounds idx by the rows actually returned.
- batchFetch copies a *fetch.Error instead of mutating one the PDF pool may share.
internal/config
- ParseSize no longer overflows to a negative size.
cmd/sift
- sift batch treated 202 Accepted (submit) as an error and exited 1 with no message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+28
-13
@@ -36,13 +36,13 @@ func (r *Runner) worker() {
|
||||
|
||||
for {
|
||||
r.mu.Lock()
|
||||
if r.closed { // stop taking targets at once; live jobs are reaped on the next start
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
j := r.pick(now)
|
||||
if j == nil {
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.scheduleRetryLocked(now)
|
||||
r.cond.Wait() // re-locks on wake; release before the next top-of-loop Lock
|
||||
r.mu.Unlock()
|
||||
@@ -68,10 +68,28 @@ func (r *Runner) worker() {
|
||||
res, err := r.runTarget(r.ctx, j, idx, target)
|
||||
|
||||
r.mu.Lock()
|
||||
now = time.Now() // after the fetch: a busy answer can take seconds to come back
|
||||
j.running = false
|
||||
r.inflight--
|
||||
r.callInflight(r.inflight)
|
||||
|
||||
// Shutting down: a failure now is most likely the cancelled context. Store nothing; the
|
||||
// job stays live in the store for the restart reaper.
|
||||
if r.closed && err != nil {
|
||||
r.cond.Broadcast()
|
||||
r.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// A cancelled job whose in-flight target was only busy is finished: nothing more runs.
|
||||
if errors.Is(err, ErrRetryLater) && j.cancelled {
|
||||
r.removeLocked(j)
|
||||
r.callDepth(len(r.live))
|
||||
r.cond.Broadcast()
|
||||
r.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// A target that was only busy stays pending and is retried after RetryWait, unless it
|
||||
// has been busy for longer than RetryFor, in which case it is stored failed below.
|
||||
if errors.Is(err, ErrRetryLater) {
|
||||
@@ -102,7 +120,6 @@ func (r *Runner) worker() {
|
||||
r.setTerminal(j)
|
||||
}
|
||||
r.removeLocked(j)
|
||||
r.forgetCaller(j.id)
|
||||
r.callDepth(len(r.live))
|
||||
}
|
||||
r.cond.Broadcast()
|
||||
@@ -124,7 +141,9 @@ func (r *Runner) pick(now time.Time) *liveJob {
|
||||
continue
|
||||
}
|
||||
if !now.Before(j.retryAt) { // now >= retryAt; a zero retryAt is always ready
|
||||
r.cursor += i + 1 // absolute; wraps at access, so the rotation survives jobs being added
|
||||
// The job after this one; n (one past the end) means "whatever is appended next,
|
||||
// else wrap to 0" — reads take cursor % n.
|
||||
r.cursor = (r.cursor+i)%n + 1
|
||||
return j
|
||||
}
|
||||
}
|
||||
@@ -168,6 +187,9 @@ func (r *Runner) removeLocked(j *liveJob) {
|
||||
if r.cursor > idx {
|
||||
r.cursor--
|
||||
}
|
||||
if r.cursor > len(r.live) {
|
||||
r.cursor = len(r.live)
|
||||
}
|
||||
}
|
||||
|
||||
// setTerminal writes the job's finished status once: all done → done, all failed → failed, a mix
|
||||
@@ -239,13 +261,6 @@ func (r *Runner) storeTarget(j *liveJob, idx int, res Result, err error) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// forgetCaller drops the in-memory caller for a finished job.
|
||||
func (r *Runner) forgetCaller(id string) {
|
||||
r.callMu.Lock()
|
||||
delete(r.callers, id)
|
||||
r.callMu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Runner) callDepth(live int) {
|
||||
if r.cfg.Hooks.Depth != nil {
|
||||
r.cfg.Hooks.Depth(live)
|
||||
|
||||
Reference in New Issue
Block a user