spc: dedup enrich backfill enqueue against active tasks - #63
Conversation
The retry sweeper re-enqueued a due doc every lease expiry (600s) with no regard for the backfill task it had already enqueued. Once queue latency exceeds the lease — observed on-prem under a flapping LLM provider with a large ingest backlog — the same doc accumulates unbounded duplicate kTaskEnrichBackfill rows: 35,729 queued duplicates built up over 18 hours in the field. The 5s Enqueue debounce window cannot catch a re-submission period measured in minutes. Per F36-LR addendum §3.7.6 (design updated first): the sweeper now skips a due doc — without consuming its lease — while its most recent backfill task is still queued/processing/cancelling, via a new TaskScheduler::HasActiveTaskFor guard. At most one active backfill task per doc becomes a queue invariant. Terminal tasks do not block recovery: a due doc whose previous task completed or failed is enqueued again. Test disables the watcher debounce to isolate the sweeper-side guard; with debounce on, the recovery enqueue merges into the just-completed task (the intended dedup of duplicate submissions) and would hide the row-count signal. Signed-off-by: Miles <miles@cortrix.ai>
ScottSiu1983
left a comment
There was a problem hiding this comment.
Blocking: HasActiveTaskFor cannot detect every state it promises. It delegates to FindRecentTaskByDocId, whose SQL excludes cancelling and applies a seven-day created_at cutoff. A cancelling or older queued/processing backfill is therefore reported inactive, allowing the sweeper to enqueue another duplicate and violating the stated at-most-one-active invariant. Please query active statuses directly without an age cutoff and add coverage for cancelling and old active tasks.
Review of PR #63: HasActiveTaskFor delegated to FindRecentTaskByDocId, whose SQL excludes 'cancelling' and windows on created_at (7 days here). A cancelling backfill, or a queued/processing one older than the window, was therefore reported inactive and the sweeper enqueued a duplicate — violating the at-most-one-active invariant the guard exists to keep. Add TaskManager::HasActiveTask: a direct probe for status IN (queued, processing, cancelling) on (namespace_id, doc_id, task_type) with no age cutoff, and route the scheduler guard through it. The debounce lookup keeps its own shape (that shape is right for debounce). New test EnrichSweeper_GuardSeesCancellingAndOldActiveTasks: a task moved processing -> cancelling, and a queued task backdated to 2000 (asserted invisible to the recency lookup as a precondition), both keep the sweeper from enqueuing again. Defect injection: restoring the old delegating guard flips it red; the fix flips it green. Scheduler/manager/ sweeper neighborhood: 73 tests pass. Signed-off-by: Miles <miles@cortrix.ai>
|
Thanks — the review is correct on both counts (excluded
Field note for context: the earlier head has been running on the on-prem enriched-arm box since 08-14 with the backfill queue bounded at ≤181 rows (vs 35,729 before) — the dedup path itself is doing its job; this closes the status/age gaps in it. |
ScottSiu1983
left a comment
There was a problem hiding this comment.
Blocking: the new dedup query error path fails open. In RunSweepNow, any HasActiveTaskFor error is treated like inactive because only a successful true result skips. A transient task-store read failure can therefore consume the lease and enqueue a duplicate if the following write succeeds, violating the at-most-one-active invariant. Please fail closed by skipping and logging or propagating the lookup error, and cover that path.
Round 2 review: RunSweepNow treated any HasActiveTaskFor error as "inactive", because only a successful true result took the skip. A transient task-store read failure therefore consumed the doc's lease and enqueued another backfill task if the following write succeeded - the exact duplicate the guard exists to prevent. TaskManager::HasActiveTask returns kStorageFailed on prepare failure and on any step rc that is neither SQLITE_ROW nor SQLITE_DONE, so SQLITE_BUSY under load reaches this path in production, not just in tests. The lookup now fails closed: an unanswerable check is treated as "an active task may exist" and the doc is skipped with its lease intact, so it stays due and the next tick re-checks. The skip sits above LeaseDocRetries, which is what makes it free - nothing is consumed and nothing is lost. Chose skip-and-log over propagating: propagation would abort the whole namespace batch for one doc's transient read error, and skip-and-log matches the existing ListDueDocs handler in the same function. New test EnrichSweeper_DedupLookupFailureFailsClosed. It renames the tasks table out from under the lookup so the prepare fails, then asserts on the LEASE rather than on the return value: once the table is gone Enqueue fails too, so RunSweepNow returns 0 in both the fixed and the broken build and the return value discriminates nothing. The lease lives in the namespace store, untouched by the rename - failing open pushes next_retry_at to now+600, failing closed leaves the doc due. The table is renamed rather than dropped so the second half can restore it and prove the outage was transient-safe: no duplicate left behind, and the doc still enqueues normally once the original task goes terminal. Verification: defect injection confirmed the test discriminates - reverting only the sweeper arm to the fail-open form turns it red at the lease assertion with "guard failed open", and restoring turns it green. Adjacent regression: 94 tests across the SPC pipeline, TaskScheduler and TaskManager suites pass. Signed-off-by: Miles <miles@cortrix.ai>
|
Fixed in The lookup now fails closed — an unanswerable check is treated as "an active task may exist", and the doc is skipped with its lease intact, so it stays due and the next tick re-checks. The I took skip-and-log rather than propagating. Propagating would abort the whole namespace batch over one doc's transient read error, and skip-and-log matches the existing On the test, one thing worth flagging — the obvious assertion does not work. Verification, red/green both observed rather than assumed:
Note this closes only the queue-flood half of #62. The attempts-cap bypass stays open there — I will follow the counting model you outlined in that issue (count when a backfill task actually begins a provider call, persist the claim before the call, idempotent per task/attempt token, so transport failures and timeouts count while pre-call queue/scheduler/storage failures do not). Re-requesting review. |
ScottSiu1983
left a comment
There was a problem hiding this comment.
Blocking: the new check still does not make the at-most-one-active invariant atomic. RunSweepNow is callable by both the background thread and the backfill route, while HasActiveTaskFor releases its locks before LeaseDocRetries and Enqueue. Two concurrent sweeps can both observe no active task; with f42.watcher_debounce_seconds=0, both can then create rows. Please serialize or atomically claim a sweep, or enforce uniqueness at task creation, and add a concurrent-sweep test with debounce disabled.
…ueue
Round 3 review: the per-doc dedup guard spans HasActiveTaskFor -> LeaseDocRetries
-> Enqueue while holding no lock across the three, so two overlapping sweeps
could each observe "no active task" for the same doc and each enqueue one.
RunSweepNow has two production callers - the timer thread and the backfill ops
route - and httplib serves the route from a thread pool, so route-vs-route is a
third interleaving beyond the two named in the review.
Serialized with a dedicated std::mutex held for the whole sweep body. This is
the pattern this repo already uses for every other sweeper reachable from both a
background thread and an ops route (GcManager::RunOnce/Purge take mu_;
CleanupScheduler has run_mu_ documented as "advisory lock - one sweep at a
time"); EnrichRetrySweeper was the only one missing it. Since RunSweepNow has
exactly three call sites and all are in-process, this closes the invariant for
the single-instance deployment this product supports.
Blocking rather than try_lock, matching GcManager: a missed try_lock could only
report zero enqueued, which the route cannot distinguish from "nothing was due"
- and that route exists precisely to force a backfill. The mutex is distinct
from cv_mu_, which RunLoop releases before calling RunSweepNow, so Stop() cannot
deadlock against an in-flight sweep. Worst-case shutdown latency is one sweep,
bounded by kMaxDocsPerSweep per namespace with no network calls in the loop.
New test EnrichSweeper_ConcurrentSweepsCreateOneTask: two threads released from
a spin barrier into RunSweepNow("test-ns") - the single-namespace form the route
uses - with f42.watcher_debounce_seconds=0 as the review asked, repeated 120
rounds on a fresh doc each round. It asserts the row count in the TASKS table is
1, not the return value: the debounce merge branch also counts toward the
returned enqueued total, so a returned 1 can still hide a second row.
Verification: defect injection, 5 runs of the unlocked build, 5 failed - every
one tripping in round 0 or 1 with "concurrent sweeps created 2 backfill tasks".
Restored: 5 of 5 pass. All 5 EnrichSweeper tests pass; adjacent regression is 95
tests across the SPC pipeline, TaskScheduler and TaskManager suites.
Note on scope: the invariant is still enforced by the sweeper rather than by the
queue. Its long-term home is inside TaskScheduler::Enqueue's existing critical
section - task_scheduler.cpp:91 is the only CreateTask call site in src/ and it
is already under the lock - which would make it hold for every producer. Filed
as a follow-up rather than changing shared F42 queue semantics in a PR scoped to
the enrich sweeper.
Signed-off-by: Miles <miles@cortrix.ai>
3f2d534 to
924a09b
Compare
|
Fixed in
Why a lock rather than uniqueness at task creation. This repo already answers exactly this problem the same way everywhere else it occurs: I did evaluate the partial unique index and rejected it on measurement, which I'd rather show than assert. Against a table already holding two duplicate rows: That DDL would live in Blocking rather than Test — It asserts the row count in the tasks table, not the return value. That distinction matters here: the debounce merge branch also counts toward the returned total, so a returned 1 can still hide a second row — a test written against the return value would pass on the defect. Verification, red and green both observed:
All 5 One thing I want to flag rather than quietly rely on. The duplicate row is not reachable in a shipped binary: Follow-up filed rather than done here: the invariant is still enforced by the sweeper, not the queue. Its long-term home is inside Re-requesting review. |
ScottSiu1983
left a comment
There was a problem hiding this comment.
Reviewed on 924a09b. The dedicated sweep mutex closes the concurrent RunSweepNow race. The active-task query now covers queued, processing, and cancelling states without an age cutoff, and lookup failures preserve the lease. The concurrent regression test addresses the earlier blocker, and all seven CI checks pass.
|
Merged as The follow-up I mentioned is filed: #78 — moving the invariant into Issue #62's remaining half — the attempts-cap bypass — stays open there, and I'll follow the counting model you outlined in that issue. |
Fixes the first half of #62 (queue flood). The attempts-cap bypass stays open in #62 pending a design call.
Problem
The enrich retry sweeper re-enqueues every due doc after its 600s lease expires, with no awareness of the backfill task it already enqueued. Once queue latency exceeds the lease, duplicates accumulate without bound — field-observed on the on-prem benchmark: 35,729 queued
kTaskEnrichBackfillduplicates over ~18h under a flapping LLM provider. The 5s Enqueue debounce window cannot catch a re-submission period measured in minutes.Fix (design first: F36-LR addendum §3.7.6, hub commit pushed)
TaskScheduler::HasActiveTaskFor(ns, doc, task_type): reports whether the most recent task for the triple is still queued/processing/cancelling.EnrichRetrySweeper::RunSweepNow: a due doc with an active backfill task is skipped without consuming its lease — it stays due and is re-checked next tick. Queue invariant: at most one active backfill task per doc.Verification
SPCPipelineR7Test.EnrichSweeper_SkipsDocsWithActiveBackfillTask: lease-expiry while queued → no duplicate (task rows stay 1); after the task reaches a terminal state → re-enqueue works (rows 2). The test disables the watcher debounce to isolate the sweeper-side guard (with debounce on, the recovery enqueue merges into the just-completed row — the intended duplicate-submission dedup — and would mask the signal).enrich_retry_sweeper.cppto main flips the new test red; restoring flips it green.🤖 Generated with Claude Code
https://claude.ai/code/session_01SRNhu7QnvaQ3RjHQv7ViEC