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:
2026-09-26 23:27:47 -07:00
co-authored by Claude Opus 5.5
parent be48ecb550
commit a452aa16a1
11 changed files with 406 additions and 63 deletions
+39 -11
View File
@@ -134,8 +134,14 @@ func (d *DB) PutBatchTargets(jobID string, targets []BatchTarget) error {
if d == nil {
return fmt.Errorf("dcache: nil db")
}
// One transaction: a job never has only some of its targets.
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, t := range targets {
if _, err := d.sql.Exec(`
if _, err := tx.Exec(`
INSERT INTO batch_target(job_id, idx, url, options, status, result, error, hint)
VALUES(?,?,?,?,?,?,?,?)
`, t.JobID, t.Idx, t.URL, t.Options, t.Status,
@@ -143,7 +149,7 @@ func (d *DB) PutBatchTargets(jobID string, targets []BatchTarget) error {
return err
}
}
return nil
return tx.Commit()
}
func (d *DB) BatchTargets(jobID string) ([]BatchTarget, bool) {
@@ -249,16 +255,25 @@ func (d *DB) SweepBatch(now int64, ttl time.Duration) int {
return 0
}
ttlSec := int64(ttl.Seconds())
if _, err := d.sql.Exec(`
DELETE FROM batch_target WHERE job_id IN
(SELECT id FROM batch_job WHERE lease + ? <= ?)
`, ttlSec, now); err != nil {
// A queued or running job is never swept: it is still in a worker's rotation. The restart
// reaper marks such jobs failed, and they are swept after that. Both deletes are one
// transaction, so a job never loses its targets but keeps its row.
tx, err := d.sql.Begin()
if err != nil {
return 0
}
res, err := d.sql.Exec(`DELETE FROM batch_job WHERE lease + ? <= ?`, ttlSec, now)
defer func() { _ = tx.Rollback() }()
const expired = `SELECT id FROM batch_job WHERE lease + ? <= ? AND status NOT IN ('queued','running')`
if _, err := tx.Exec(`DELETE FROM batch_target WHERE job_id IN (`+expired+`)`, ttlSec, now); err != nil {
return 0
}
res, err := tx.Exec(`DELETE FROM batch_job WHERE id IN (`+expired+`)`, ttlSec, now)
if err != nil {
return 0
}
if err := tx.Commit(); err != nil {
return 0
}
n, _ := res.RowsAffected()
return int(n)
}
@@ -279,10 +294,7 @@ func (d *DB) EvictBatchOldest(maxBytes int64) int {
if err != nil {
break
}
if _, err := d.sql.Exec(`DELETE FROM batch_target WHERE job_id = ?`, id); err != nil {
break
}
if _, err := d.sql.Exec(`DELETE FROM batch_job WHERE id = ?`, id); err != nil {
if err := d.deleteBatchJob(id); err != nil {
break
}
evicted++
@@ -312,3 +324,19 @@ func (d *DB) BatchNbytes() int64 {
_ = d.sql.QueryRow(`SELECT IFNULL(SUM(nbytes),0) FROM batch_job`).Scan(&n)
return n.Int64
}
// deleteBatchJob removes a job and its targets in one transaction.
func (d *DB) deleteBatchJob(id string) error {
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`DELETE FROM batch_target WHERE job_id = ?`, id); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM batch_job WHERE id = ?`, id); err != nil {
return err
}
return tx.Commit()
}
+25
View File
@@ -0,0 +1,25 @@
package dcache
// P6 owner review: the sweep never removes a live job.
import (
"testing"
"time"
)
func TestSweepSparesLiveJobs(t *testing.T) {
db := newBatchDB(t)
for _, st := range []string{"queued", "running", "done"} {
if err := db.CreateBatchJob(BatchJob{ID: st, Caller: "a", CallerClass: ClassFleet, IDemKey: st, Status: st, TargetsTotal: 1, CreatedAt: 1, Lease: 1}); err != nil {
t.Fatal(err)
}
}
if n := db.SweepBatch(1_000_000, time.Hour); n != 1 {
t.Fatalf("swept %d, want only the done job", n)
}
for _, id := range []string{"queued", "running"} {
if _, ok := db.BatchJob(id); !ok {
t.Fatalf("live job %s was swept", id)
}
}
}