Skip to content

fix(queue): requeue failed jobs per row so a dedupe collision cannot abort the batch - #139

Merged
M3gA-Mind merged 4 commits into
tinyhumansai:mainfrom
YellowSnnowmann:fix/requeue-dedupe-collision
Aug 7, 2026
Merged

fix(queue): requeue failed jobs per row so a dedupe collision cannot abort the batch#139
M3gA-Mind merged 4 commits into
tinyhumansai:mainfrom
YellowSnnowmann:fix/requeue-dedupe-collision

Conversation

@YellowSnnowmann

@YellowSnnowmann YellowSnnowmann commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Requeueing failed Memory Tree jobs aborted whenever two failed rows shared a dedupe_key. Both the periodic self-heal and the manual "retry failed" path issued one blanket UPDATE ... SET status = 'ready' over every failed row. Two siblings sharing a key then entered the partial unique index idx_mem_tree_jobs_dedupe_active (which covers only status IN ('ready','running')) at the same instant, SQLite aborted the whole statement with UNIQUE constraint failed: mem_tree_jobs.dedupe_key, and nothing was requeued — on every boot and every ~3h self-heal tick, leaving the queue permanently parked.

Requeue is now applied per row inside a single transaction:

  • a dedupe_key already held by a live (ready/running) row is skipped — the live row already covers that work, and requeueing onto it is the collision;
  • among failed rows sharing a key, only the newest is requeued; older siblings settle as cancelled (superseded duplicates of the same unit of work) so they stop being counted as failures the user must act on;
  • rows with dedupe_key IS NULL are outside the index and always requeue.

Selection and mutation run in one transaction under the shared-connection mutex, so a concurrent claim cannot slip an active row in between the read and the write.

API Or Behavior Changes

  • requeue_failed / requeue_transient_failed (and retry_all_failed, which delegates) no longer abort on duplicate dedupe_keys. The return value is unchanged — the count of rows flipped to ready; superseded duplicates are settled as cancelled and are not counted.

Tests

3 new tests in src/memory/queue/store_settle_tests.rs: duplicate-key survival on the manual retry path, the same on the self-heal path, and the active-key-held skip case (one unrelated row must still requeue in the same batch).

  • cargo test — 99/99 queue tests pass (3 new)
  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings
  • cargo build --all-targets

Documentation

Behavior is documented in the requeue_failed_where doc comment (why the requeue is filtered rather than one blanket UPDATE, with the partial-index reasoning). No external docs needed.

Summary by CodeRabbit

  • Bug Fixes

    • Improved failed-job recovery by requeuing eligible jobs individually.
    • Prevented duplicate jobs from being requeued when an active job uses the same key.
    • Ensured only the newest duplicate is requeued and older duplicates are cancelled.
    • Prevented invalid or altered content from being saved during tag updates.
    • Preserved file contents when tag updates fail validation.
  • Tests

    • Added coverage for duplicate jobs, active jobs, malformed metadata, and content changes during tag updates.

senamakel and others added 2 commits July 28, 2026 11:43
…abort the batch

`requeue_failed_where` flipped every matching `failed` row to `ready` in one
UPDATE. `idx_mem_tree_jobs_dedupe_active` is a partial unique index over
`dedupe_key` covering only `ready`/`running`, so failed rows sit outside it and
the same key can legitimately accumulate several failed rows over time. The
moment that UPDATE moved two siblings into the index at once they collided,
SQLite aborted the whole statement, and nothing was requeued.

Observed in production as `UNIQUE constraint failed: mem_tree_jobs.dedupe_key`
from the periodic self-heal on every boot and every 3h tick, with the queue
permanently parked. The manual retry path shares the same helper and aborted
identically, so there was no way out from either side.

Select the candidates first, then apply per row:

- skip a key already held by a live `ready`/`running` row (that work is already
  in flight, and requeueing onto it is the collision);
- among matched failed rows sharing a key, requeue only the newest and settle
  its older siblings as `cancelled` — they are duplicates of the same unit of
  work, and leaving them `failed` would keep them counted as failures the user
  must act on;
- rows with a NULL `dedupe_key` are outside the index and always requeue.

Selection and mutation share one transaction so a concurrent claim cannot
insert an active row between the read and the write. The return value still
counts only rows actually flipped to `ready`.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 55 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 127826ce-c1ff-40f7-8565-9c9db22b07e2

📥 Commits

Reviewing files that changed from the base of the PR and between 5fabcf1 and 4cad022.

📒 Files selected for processing (3)
  • src/memory/queue/store_settle.rs
  • src/memory/queue/store_settle_tests.rs
  • src/memory/queue/types.rs
📝 Walkthrough

Walkthrough

The change makes failed-job requeue transactional and duplicate-aware. It also validates UTF-8, front matter, and body preservation before persisting markdown tag updates. Regression tests cover duplicate jobs, active keys, malformed files, and body changes.

Changes

Failed-job requeue handling

Layer / File(s) Summary
Transactional duplicate-aware requeue
src/memory/queue/store_settle.rs, src/memory/queue/store_settle_tests.rs
The requeue operation processes failed rows individually. It requeues only the newest duplicate, cancels older duplicates, skips active-key conflicts, resets failure state, and preserves unrelated requeues. Tests cover manual requeue and transient-failure paths.

Markdown tag rewrite validation

Layer / File(s) Summary
Validated tag rewrites
src/memory/store/content/tags.rs, src/memory/store/content/tags_tests.rs
Tag updates now reject invalid parsing and body changes before file replacement. Tests cover malformed front matter, unchanged bodies, and changed bodies.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant requeue_failed_where
  participant QueueDatabase
  participant JobState
  requeue_failed_where->>QueueDatabase: Select failed jobs newest-first
  QueueDatabase-->>requeue_failed_where: Return failed candidates
  requeue_failed_where->>JobState: Check active duplicate keys
  requeue_failed_where->>QueueDatabase: Cancel superseded duplicates
  requeue_failed_where->>QueueDatabase: Reset and requeue eligible jobs
Loading

Poem

A rabbit checks each failed queue,
Keeps the newest job in view.
Old duplicates sleep, keys stay bright,
Tags guard bodies through the night.
UTF-8 hops in, safe files stay true.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main queue change: requeueing failed jobs per row to prevent deduplication collisions from aborting the batch.

Comment @coderabbitai help to get the list of available commands.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/memory/queue/store_settle.rs (3)

346-374: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: prepare the two UPDATE statements once outside the loops.

Each iteration compiles the same SQL again. Use tx.prepare before the loop and call stmt.execute per row.

Also note that a cancelled row keeps its old failure_reason and failure_class. The requeue branch clears both. If any status panel reads those columns, a cancelled duplicate still reports a failure classification. Clear them on cancel if that is not intended.

♻️ Prepared statement reuse
-        for id in &to_cancel {
-            tx.execute(
-                "UPDATE mem_tree_jobs
+        if !to_cancel.is_empty() {
+            let mut stmt = tx.prepare(
+                "UPDATE mem_tree_jobs
                     SET status = 'cancelled',
                         locked_until_ms = NULL,
                         completed_at_ms = ?2,
                         last_error = 'superseded by a newer job with the same dedupe_key'
-                  WHERE id = ?1",
-                params![id, now_ms],
-            )?;
+                  WHERE id = ?1",
+            )?;
+            for id in &to_cancel {
+                stmt.execute(params![id, now_ms])?;
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/queue/store_settle.rs` around lines 346 - 374, Prepare each
repeated UPDATE statement once before its corresponding loop and reuse the
prepared statements for every ID. In the cancellation UPDATE, also clear
failure_reason and failure_class so cancelled superseded jobs do not retain
failure metadata; leave the existing requeue cleanup behavior unchanged.

258-289: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Doc block is accurate; align the "skipped" wording with the cancel behavior.

Lines 278-279 state that a key already held by an active row is skipped entirely. The code matches that: skipped_active only counts the row and leaves it in failed. The test doc in src/memory/queue/store_settle_tests.rs (Lines 332-335) claims the same row "settles as a superseded duplicate", which the implementation does not do. Keep one description of this behavior in both files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/queue/store_settle.rs` around lines 258 - 289, Align the test
documentation for the active-row case with the implementation: update the
relevant description in the requeue tests around the active dedupe key to state
that the failed row is skipped and remains failed, not cancelled as a superseded
duplicate. Preserve the existing cancellation wording only for matched failed
siblings when no active row already exists, keeping the documentation in
requeue_failed/requeue_transient_failed consistent.

290-293: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Replace unchecked_transaction() with Transaction::new_unchecked() and TransactionBehavior::Immediate to eliminate the read-write race condition.

The current code uses unchecked_transaction(), which opens a deferred transaction. The first SELECT acquires only a read lock. If another connection commits a claim before the later UPDATE executes, the UPDATE fails with SQLITE_BUSY_SNAPSHOT (WAL mode) or SQLITE_BUSY, causing the entire requeue operation to return an error. The doc comment at line 285 promises that selection and mutation run atomically in a single transaction; a deferred transaction does not guarantee this.

Use Transaction::new_unchecked(&conn, TransactionBehavior::Immediate) instead. An immediate transaction acquires the write lock up front, eliminating this window.

Add imports:

use rusqlite::{Transaction, TransactionBehavior};

Change line 293 from:

let tx = conn.unchecked_transaction()?;

to:

let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/queue/store_settle.rs` around lines 290 - 293, Update
requeue_failed_where to import and use rusqlite::Transaction and
TransactionBehavior, replacing conn.unchecked_transaction() with
Transaction::new_unchecked(conn, TransactionBehavior::Immediate) so the
transaction acquires the write lock before selection and mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/memory/queue/store_settle_tests.rs`:
- Around line 332-369: Update the doc comment for
requeue_failed_skips_keys_already_held_by_an_active_row to state that the
occupied-key failed row remains failed and is counted as skipped_active, rather
than settling as a superseded duplicate. Replace the broad assert_ne! on
failed_id with an exact JobStatus::Failed assertion while preserving the
existing assertions for the requeued and live rows.

In `@src/memory/queue/store_settle.rs`:
- Around line 295-307: The candidate ordering in the settlement query must use a
monotonic enqueue-order value instead of UUID `id DESC` when completion
timestamps tie. Update the relevant job schema, enqueue logic in `enqueue_conn`,
and the query around `select` to persist and order by that sequence after
`COALESCE(completed_at_ms, created_at_ms) DESC`; add coverage for completions
occurring in the same millisecond and verify the newest enqueued row is
retained.

---

Nitpick comments:
In `@src/memory/queue/store_settle.rs`:
- Around line 346-374: Prepare each repeated UPDATE statement once before its
corresponding loop and reuse the prepared statements for every ID. In the
cancellation UPDATE, also clear failure_reason and failure_class so cancelled
superseded jobs do not retain failure metadata; leave the existing requeue
cleanup behavior unchanged.
- Around line 258-289: Align the test documentation for the active-row case with
the implementation: update the relevant description in the requeue tests around
the active dedupe key to state that the failed row is skipped and remains
failed, not cancelled as a superseded duplicate. Preserve the existing
cancellation wording only for matched failed siblings when no active row already
exists, keeping the documentation in requeue_failed/requeue_transient_failed
consistent.
- Around line 290-293: Update requeue_failed_where to import and use
rusqlite::Transaction and TransactionBehavior, replacing
conn.unchecked_transaction() with Transaction::new_unchecked(conn,
TransactionBehavior::Immediate) so the transaction acquires the write lock
before selection and mutation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3ece258e-37e3-4065-85ac-1f84d71cc8ec

📥 Commits

Reviewing files that changed from the base of the PR and between b73d161 and 5fabcf1.

📒 Files selected for processing (4)
  • src/memory/queue/store_settle.rs
  • src/memory/queue/store_settle_tests.rs
  • src/memory/store/content/tags.rs
  • src/memory/store/content/tags_tests.rs

Comment thread src/memory/queue/store_settle_tests.rs
Comment thread src/memory/queue/store_settle.rs
…s (PR tinyhumansai#139 review)

- ORDER BY breaks exact completed_at_ms ties by created_at_ms then id, so the
  "keep newest per dedupe_key" pick is reproducible instead of hinging on a
  random UUID. Rows sharing a dedupe_key are the same unit of work, so the pick
  is immaterial to correctness -- no monotonic-sequence migration needed.
- Correct the skip-active-key test doc (the occupied-key row stays failed,
  skipped, not settled) and assert JobStatus::Failed exactly.
- Add a test for equal-millisecond completions asserting the one-Ready /
  one-Cancelled invariant holds regardless of tiebreak.
- Refresh the JobStatus::Cancelled doc: requeue supersession is its first real
  producer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@YellowSnnowmann

Copy link
Copy Markdown
Contributor Author

Addressed the review in 03e6de7. Actionable items fixed (see inline replies); the three nitpicks:

  • Prepare the two UPDATEs once outside the loops — left inline. This is the parked-failure recovery path: it runs on manual retry and the periodic self-heal, over the failed rows of a single workspace (tiny N), so re-preparing per row is immaterial, and two inline tx.execute calls read more clearly than hoisted prepared statements. Happy to hoist if you feel strongly.

  • Doc at lines 278-279 ("skipped entirely") — no change; as you noted it already matches the code (an active-key row is skipped and left failed). The inaccurate wording was the test doc at line ~334, corrected in 03e6de7.

  • unchecked_transaction()TransactionBehavior::Immediate for the read-write race — kept unchecked_transaction() (deferred), because there is no race in this architecture to eliminate. with_connection serialises all queue access behind a single process-global parking_lot::Mutex over one shared rusqlite::Connection (the chunk DB is single-connection, journal_mode=TRUNCATE, not WAL). The whole select → settle → commit runs while that mutex is held, so no other connection can commit a claim between the read and the write — Immediate only matters with concurrent writers, which don't exist here. (Separately, Connection::unchecked_transaction() always opens DEFERRED; forcing IMMEDIATE from a &Connection would require a hand-rolled BEGIN IMMEDIATE.)

Local: cargo fmt --check clean · cargo test memory::queue::store_settle 22/22 (incl. the new equal-ms test).

@YellowSnnowmann

Copy link
Copy Markdown
Contributor Author

Heads-up on CI: the one red check — Features (sync) — failed at the setup step with a GitHub Actions ISE (Internal Server Error occurred while resolving "Swatinem/rust-cache@v2" / "actions/checkout@v7" / "dtolnay/rust-toolchain@stable"), before any code compiled. It's a transient GitHub action-resolution error, not a code failure — every other feature job passed and cargo test memory::queue::store_settle is 22/22 locally. I don't have re-run rights on this repo; a maintainer clicking Re-run failed jobs (or the next push) will clear it.

The 03e6de7 CI run failed only on GitHub's action-resolution outage
("Service Unavailable" resolving actions/checkout, rust-toolchain, rust-cache);
no job compiled. GitHub Actions is Operational again but dropped events can't
replay, so this empty commit re-triggers the pipeline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@M3gA-Mind
M3gA-Mind merged commit 94aa2c0 into tinyhumansai:main Aug 7, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants