Skip to content

Commit b81db7e

Browse files
authored
engine: keep documents 'parsing' while the queue retries; log + cap ingest retries (HAL-321) (#47)
Under heavy concurrent bulk ingestion, individual attempts hit transient failures (parse-timeout, not-yet-visible source) that River recovers on retry — but the pipeline marked the document 'failed' on each miss, so polling clients (and the benchmark) gave up prematurely. Now: - queue.Job carries Attempt/MaxAttempts (populated by the River worker); - Pipeline.fail keeps the doc 'parsing' while retries remain and only marks 'failed' on the final attempt — and now always LOGS the failure (was silent); - ingest jobs cap retries at 5 so a genuinely-bad doc fails in reasonable time.
1 parent 0f11b8f commit b81db7e

4 files changed

Lines changed: 79 additions & 8 deletions

File tree

internal/api/server.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,9 +320,16 @@ func (d Deps) handleIngestDocument(w http.ResponseWriter, r *http.Request) {
320320
SourceRef: key,
321321
})
322322
if err := d.Queue.Enqueue(ctx, queue.Job{
323-
Kind: queue.KindIngestDocument,
324-
Payload: payload,
325-
DedupeKey: string(docID),
323+
Kind: queue.KindIngestDocument,
324+
Payload: payload,
325+
// Cap retries so a transient failure (e.g. a parse-timeout or a
326+
// not-yet-visible source under heavy concurrent ingestion) gets a
327+
// few chances to recover, without the queue's default 25-attempt
328+
// exponential backoff dragging a genuinely-bad document out for
329+
// hours. The document stays "parsing" across these attempts and
330+
// only flips to "failed" on the last one (see Pipeline.fail).
331+
MaxRetries: 5,
332+
DedupeKey: string(docID),
326333
}); err != nil {
327334
d.Logger.Error("ingest: enqueue failed", "err", err)
328335
writeErr(w, http.StatusInternalServerError, "enqueue failed")

pkg/ingest/ingest.go

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,14 +317,38 @@ func (p *Pipeline) acquireGlobalLLM(ctx context.Context) (release func(), ok boo
317317
}
318318
}
319319

320+
// lastAttemptCtxKey carries whether the current ingest job is on its final
321+
// queue attempt. When it isn't, a transient failure must NOT be surfaced as a
322+
// terminal "failed" status — the queue will retry it.
323+
type lastAttemptCtxKeyT struct{}
324+
325+
func withLastAttempt(ctx context.Context, last bool) context.Context {
326+
return context.WithValue(ctx, lastAttemptCtxKeyT{}, last)
327+
}
328+
329+
// isLastAttempt reports whether this is the final attempt. Defaults to true
330+
// when unset (callers that don't track attempts, e.g. tests, keep the
331+
// fail-immediately behaviour).
332+
func isLastAttempt(ctx context.Context) bool {
333+
v, ok := ctx.Value(lastAttemptCtxKeyT{}).(bool)
334+
if !ok {
335+
return true
336+
}
337+
return v
338+
}
339+
320340
// Handler returns a queue.Handler suitable for queue.KindIngestDocument.
321341
func (p *Pipeline) Handler() queue.Handler {
322342
return func(ctx context.Context, j queue.Job) error {
323343
var payload Payload
324344
if err := json.Unmarshal(j.Payload, &payload); err != nil {
325345
return fmt.Errorf("decode payload: %w", err)
326346
}
327-
return p.Run(ctx, payload)
347+
// A job with no attempt tracking (MaxAttempts<=0) is treated as its
348+
// own last attempt; otherwise it's the last attempt only when the
349+
// queue has used up its retries.
350+
last := j.MaxAttempts <= 0 || j.Attempt >= j.MaxAttempts
351+
return p.Run(withLastAttempt(ctx, last), payload)
328352
}
329353
}
330354

@@ -1005,11 +1029,30 @@ func fallbackSummary(title, body string) string {
10051029

10061030
func (p *Pipeline) fail(ctx context.Context, store docPersister, id tree.DocumentID, stage string, cause error) {
10071031
msg := fmt.Sprintf("%s: %s", stage, cause.Error())
1008-
// Use a FRESH context for the failure write — the inbound one is
1009-
// almost certainly the reason we're failing (timeout/cancel) and
1010-
// reusing it would leave the doc stuck on "parsing" forever.
1032+
// Use a FRESH context for the status write — the inbound one is almost
1033+
// certainly the reason we're failing (timeout/cancel) and reusing it
1034+
// would leave the doc stuck mid-flight.
10111035
failCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
10121036
defer cancel()
1037+
1038+
// If the queue will retry this job, the failure is (so far) transient —
1039+
// under heavy concurrent ingestion, parse can hit a parse-timeout or a
1040+
// not-yet-visible source and recover on a later attempt. Surfacing
1041+
// "failed" now would tell a polling client the document is dead when it
1042+
// isn't, so keep it in "parsing" and let the retry run. Only the final
1043+
// attempt produces a terminal "failed".
1044+
if !isLastAttempt(ctx) {
1045+
p.Logger.Warn("ingest: transient failure, will retry",
1046+
"document_id", string(id), "stage", stage, "cause", cause.Error())
1047+
if err := store.SetDocumentStatus(failCtx, id, db.StatusParsing, ""); err != nil {
1048+
p.Logger.Error("ingest: failed to reset document to parsing", "err", err)
1049+
}
1050+
return
1051+
}
1052+
1053+
// Terminal failure: log it (previously silent — failures only showed up
1054+
// in the DB error_message) and mark the document failed.
1055+
p.Logger.Error("ingest: failed", "document_id", string(id), "stage", stage, "cause", cause.Error())
10131056
if err := store.SetDocumentStatus(failCtx, id, db.StatusFailed, msg); err != nil {
10141057
p.Logger.Error("ingest: failed to mark document failed", "err", err, "cause", cause)
10151058
}

pkg/queue/queue.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ type Job struct {
4444

4545
// Optional: max retries before dead-lettering.
4646
MaxRetries int `json:"max_retries,omitempty"`
47+
48+
// Attempt is the 1-based current attempt number, set by the queue when
49+
// it dispatches a job to its handler (0 if the queue doesn't track
50+
// attempts). MaxAttempts is the total before dead-lettering. Handlers
51+
// use these to tell a transient, will-be-retried failure apart from a
52+
// terminal one — e.g. so a document isn't marked "failed" while the
53+
// queue will still retry it.
54+
Attempt int `json:"-"`
55+
MaxAttempts int `json:"-"`
4756
}
4857

4958
// Handler processes a single job.

pkg/queue/river.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,19 @@ func (w *envelopeWorker) Work(ctx context.Context, job *river.Job[envelopeArgs])
7373
if !ok {
7474
return fmt.Errorf("%w: %q", ErrUnknownKind, job.Args.DomainKind)
7575
}
76-
return h(ctx, Job{Kind: job.Args.DomainKind, Payload: job.Args.Payload})
76+
// Attempt/MaxAttempts live on River's embedded *JobRow, which is always
77+
// populated in production but may be nil in unit tests that construct a
78+
// bare river.Job. Guard the deref.
79+
attempt, maxAttempts := 0, 0
80+
if job.JobRow != nil {
81+
attempt, maxAttempts = job.Attempt, job.MaxAttempts
82+
}
83+
return h(ctx, Job{
84+
Kind: job.Args.DomainKind,
85+
Payload: job.Args.Payload,
86+
Attempt: attempt,
87+
MaxAttempts: maxAttempts,
88+
})
7789
}
7890

7991
// NewRiver constructs a new River-backed Queue. It opens its own pgxpool

0 commit comments

Comments
 (0)