Skip to content

fix: charge the drain unattributed halt and cover its unanchorable-root refusal - #1842

Merged
FSM1 merged 2 commits into
mainfrom
fix/1723-1497-drain-harness-and-unclassified-halt
Sep 14, 2026
Merged

FSM1 merged 2 commits into
mainfrom
fix/1723-1497-drain-harness-and-unclassified-halt

Conversation

@FSM1

@FSM1 FSM1 commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

What this changes

Two issues on crates/engine/src/sync/drain.rs, in one chain.

A drain harness, and the unanchorable scope root

The drain's unit module covered pure functions only. It now holds a Drain
harness built over the engine test kit seam fakes: one struct owns every value a
pass borrows and hands out a real pass plus the DrainScope it anchors on. It
seeds the snapshot cache, the gateway block store and the floors from one
owner-root fixture, so a test can plant chosen bytes at the scope-root name.

Drain::load_scope_root refuses a scope root it cannot anchor a ratchet on. All
three reject arms are now covered: a grant section that does not decode, a root
that carries none, and a name the cache holds nothing at. Each runs against a
control that loads the intact fixture and comes back with the ratchet, so a
refusal proves the grant-section read and not an earlier stage of the load.

The unattributed halt is charged

Halt::Unclassified was uncharged. An op that kept taking it held the strict-FIFO
head for ever, with no budget and no dead-letter exit, so every op behind it
waited on a stall the member could not act on.

It is now charged, but on its own count and its own ceiling rather than on the
publish attempt budget. Two reasons:

  • The class carries real availability failures — an unreachable provider, a
    record plane that will not resolve, a seam error. The attempt budget is five
    passes, which is about two and a half minutes at the production poll cadence.
    The new ceiling is an hour of consecutive halted passes.
  • A spent publish attempt is what tells create_replays_a_publish that this
    device already reached the record plane. An outage that spent one would suppress
    the already-published verdict a restored data directory depends on.

The op-attempts record therefore stores a triple per op under a new format tag.
Bytes an older build wrote read as no charges, which retries rather than abandons.
A spent unattributed budget dead-letters with the staged version preserved and
hands back no name, because the class spans both sides of the PUT and cutting a
name a live record carries would leave a reference outliving its referent.

Two smaller repairs fell out of that. The record is pruned against the pass's own
retirements rather than only the queue the pass opened on, and a record the pass
emptied is dropped instead of rewritten as a bare tag — otherwise the far more
frequent writes would park a staging row nothing reclaims.

Closes #1723.
Closes #1497.

Body checks / follow-ups filed

  • engine: test the drain's refusal of a scope root it cannot anchor a ratchet on #1723 — body check added. The refusal moved out of load_scope_root into
    open_root_record, and the body did not name the empty-cache arm. No follow-up
    filed; the test set covers all three arms.
  • engine: Halt::Unclassified is an uncharged infinite-retry surface #1497 — body check added. The body named unknown-field-collision and
    depth-exceeded as the two producing arms. Neither string reaches the drain any
    more, and the class now has about fifty producing sites. The v2-build label was
    missing and was added. The decision, and the rejected structural-unreachability
    option, are recorded on the issue rather than in a comment in the code.
  • No new issue filed. Both small findings were implemented here.

Verification

cargo fmt --all --check, cargo clippy --workspace --all-targets -D warnings,
cargo test -p cipherbox-engine (all suites green), pnpm lint:tracker-refs.
No seam trait, wasm-bound type or TypeScript surface changes.

Summary by CodeRabbit

  • Bug Fixes
    • Improved synchronization recovery when operations halt unexpectedly.
    • More accurately tracks removed, restored, and successfully published operations during queue draining.
    • Unclassified outages now receive additional recovery attempts before operations are moved to dead-letter handling.
    • Dead-lettered operations can preserve staged content and potentially active names, reducing the risk of unintended data loss.
    • Improved cleanup of completed and empty operation records for more reliable subsequent synchronization.

Note

Charge Unclassified drain halts against a separate 120-pass budget and dead-letter at exhaustion

  • Adds a per-op Charges record to sync.Attempts that tracks publish-attempt and unattributed-halt counters independently, replacing the old single-integer attempt map.
  • Unclassified halts now consume the unattributed budget; while under 120 passes the op stays queued, and at the limit it is dead-lettered as AttemptsExhausted without spending publish-attempt budget.
  • Serialization moves to a V2 two-counter layout; V1 or malformed records decode as empty. charged_to now returns only the publish-attempt component so replay decisions no longer count unattributed halts as publish attempts.
  • drain_queue prunes charge records in the same pass when an op leaves the queue (published, dropped, dead-lettered, or restore residue), and store_attempts removes the staging key when no entries remain.
  • Risk: Attempts::decode rejects the prior V1 format — any in-flight V1 records restart with zero charges. Review drain.rs around the V2 tag and fixed-width entry sizing to confirm no production V1 data needs migration.

Macroscope summarized bab6fe8.

Build a `Drain` harness in the drain's own unit module over the test kit
seam fakes, so a pass's reject arms are drivable from where they live. The
harness owns every value a pass borrows and seeds the snapshot cache, the
gateway block store and the floors from one owner-root fixture.

Cover `load_scope_root`: a root whose grant section does not decode, a root
that carries none, and a name the cache holds nothing at, each against a
control that loads the intact fixture and comes back with the ratchet.
`Halt::Unclassified` was uncharged, so an op that kept taking it held the
strict-FIFO head for ever with no budget and no dead-letter exit. Charge it,
but on a second count and a second ceiling: the class carries real
availability failures, and the publish attempt budget is also what tells the
already-published guard that this device reached the record plane.

The op-attempts record now stores a triple per op, under a new format tag, so
bytes an older build wrote read as no charges and the op is retried. A spent
unattributed budget dead-letters with the staged version preserved and hands
back no name, because the class spans both sides of the PUT.

The record is now pruned against the pass's own retirements and dropped when
it is empty, so the charges an emptied pass leaves do not park a staging row.
@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: df875442-afd0-4928-81f2-8d2ae70fc692

📥 Commits

Reviewing files that changed from the base of the PR and between 624e0dd and bab6fe8.

📒 Files selected for processing (1)
  • crates/engine/src/sync/drain.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The drain now uses version-2 bookkeeping with separate publish-attempt and unattributed-halt budgets. It tracks all operations leaving the queue and preserves staged content for selected dead letters. New harness tests cover scope-root failures, retention, budgets, and dead-letter behavior.

Changes

Drain bookkeeping and halt handling

Layer / File(s) Summary
Version-2 charge model
crates/engine/src/sync/drain.rs
Attempt records store separate publish-attempt and unattributed-halt counters. Unclassified halts use a separate budget, and empty records are removed. Round-trip, malformed-record, and retention tests cover the format.
Queue exit and dead-letter handling
crates/engine/src/sync/drain.rs
Drain reports include all operations removed from the queue. Cleanup and drained marks use that set. Selected dead letters preserve staged content and potentially live names.
Drain harness and budget validation
crates/engine/src/sync/drain.rs
Integration-style tests cover scope-root rejection, queue retention, independent halt budgets, and dead-letter behavior.

Priority: ➖ Normal

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

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Drain
  participant Queue
  participant DeadLetter
  Drain->>Queue: process queued operation
  Drain->>DeadLetter: preserve staged operation after budget exhaustion
  Drain->>Queue: remove operation and report queue exit
  Drain->>Drain: prune charge records and compute drained marks
Loading

Merge Risk: ⚪ Minimal · up to bab6f

The changed retry bookkeeping and dead-letter behavior satisfy the stated compatibility and preservation contracts, with no actionable merge risk identified.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 79.41% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 34 functions across 1 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR meets the coding requirements in #1723 and #1497. It adds a Drain harness and tests for undecodable grant-section bytes, a missing grant section, and a missing cache entry. The tests verify `Ha…
Out of Scope Changes check ✅ Passed The changed code stays within the linked issue scope. The harness, controls, bookkeeping updates, separate budgets, dead-letter behavior, retirement-based pruning, and tests directly support #1723 and…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies both main changes: charging unattributed drain halts and covering unanchorable-root refusal tests. It is specific and concise.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1723-1497-drain-harness-and-unclassified-halt

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@FSM1

FSM1 commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@FSM1
FSM1 marked this pull request as ready for review September 14, 2026 23:42
@FSM1
FSM1 merged commit 588d646 into main Sep 14, 2026
32 checks passed
@FSM1
FSM1 deleted the fix/1723-1497-drain-harness-and-unclassified-halt branch September 14, 2026 23:42
FSM1 added a commit that referenced this pull request Sep 15, 2026
## What

The drain held the queue head in three parallel cells — one for the account quota, one for a settings refusal, one for a bin index the pass could not read. The three carried the same head and were mutually exclusive, but nothing made the exclusion structural: each halt arm cleared the other arms by hand.

One `QueueHold { op_id, node, reason }` now replaces them. The reason is `Quota { needed_bytes }`, `Settings(SettingsRefusal)` or `BinIndex(DefaultsReason)`.

- One pre-pass gate, `hold_admits_the_head`, dispatches on the reason and is the only place a hold lets go. The quota probe becomes `quota_admits(needed_bytes)`, which returns a verdict rather than clearing a cell.
- Every per-arm clear of another arm's state is gone. The three halt arms only set the one cell.
- `SnapshotView::queue_hold` and `SessionStatus::queue_hold` replace the three public fields.
- One wasm-bound `QueueHold` class replaces three. It carries `reason` as a stable name, `neededBytes` on a quota hold, and `check` on the two the host renders by name.
- `packages/client` carries one `QueueHoldDescriptor` discriminated union in place of three nullable fields. The codec still fails closed: it refuses a reason this build cannot name, a check outside that reason's own vocabulary, and a quota hold with no byte count.
- `apps/web` reads the one field: the queue hold notice dispatches on the reason, and `heldBytes` reads the quota reason alone.

The host reads the same information it read before, through one field.

## Drive-by build fix

The drain test harness regains the `writer_pseudonym` and `pointer_read_key` arguments of its owner-root fixture. #1836 added those required fields and #1842 added this call site; the two merged without a textual conflict and left `cargo check -p cipherbox-engine --all-targets` failing on `main`. The fix is two lines at one call site.

## Verification

- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`
- `cargo test -p cipherbox-engine`, `cargo test -p cipherbox-fuse`, `cargo test -p cipherbox-wasm`
- `cargo check -p cipherbox-wasm --target wasm32-unknown-unknown`
- `pnpm -r typecheck`, `pnpm lint`, `pnpm lint:tracker-refs`
- `packages/client` vitest: 718 tests. `apps/web` vitest: 694 tests.

## Body checks / follow-ups filed

- #1665 body: a dated "Body check 2026-09-15" section records the counts re-read on `main` after #1842 — 14 clear calls in `drain.rs`, four of them inside the halt arms — and names the TypeScript surface the original body did not list.
- No follow-up issue filed. The one defect found, the broken `main` build, is fixed in this pull request.

Closes #1665.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Queue holds now use a single status with a clear reason: quota, settings, or bin-index.
  - Hold details include relevant information such as required storage, affected item, and applicable checks.
  - Upload and file-browser views provide more consistent queue-hold messaging.

- **Bug Fixes**
  - Quota holds are handled in the upload panel without duplicate file-browser notices.
  - Invalid or incomplete hold information is rejected with clearer validation errors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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.

engine: test the drain's refusal of a scope root it cannot anchor a ratchet on engine: Halt::Unclassified is an uncharged infinite-retry surface

1 participant