fix: charge the drain unattributed halt and cover its unanchorable-root refusal - #1842
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. WalkthroughThe 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. ChangesDrain bookkeeping and halt handling
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
Merge Risk: ⚪ Minimal · up to 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)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
## 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 -->
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
Drainharness built over the engine test kit seam fakes: one struct owns every value a
pass borrows and hands out a real pass plus the
DrainScopeit anchors on. Itseeds 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_rootrefuses a scope root it cannot anchor a ratchet on. Allthree 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::Unclassifiedwas uncharged. An op that kept taking it held the strict-FIFOhead 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:
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.
create_replays_a_publishthat thisdevice 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
load_scope_rootintoopen_root_record, and the body did not name the empty-cache arm. No follow-upfiled; the test set covers all three arms.
unknown-field-collisionanddepth-exceededas the two producing arms. Neither string reaches the drain anymore, and the class now has about fifty producing sites. The
v2-buildlabel wasmissing and was added. The decision, and the rejected structural-unreachability
option, are recorded on the issue rather than in a comment in the code.
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
Note
Charge
Unclassifieddrain halts against a separate 120-pass budget and dead-letter at exhaustionChargesrecord tosync.Attemptsthat tracks publish-attempt and unattributed-halt counters independently, replacing the old single-integer attempt map.Unclassifiedhalts now consume the unattributed budget; while under 120 passes the op stays queued, and at the limit it is dead-lettered asAttemptsExhaustedwithout spending publish-attempt budget.charged_tonow returns only the publish-attempt component so replay decisions no longer count unattributed halts as publish attempts.drain_queueprunes charge records in the same pass when an op leaves the queue (published, dropped, dead-lettered, or restore residue), andstore_attemptsremoves the staging key when no entries remain.Attempts::decoderejects the prior V1 format — any in-flight V1 records restart with zero charges. Review drain.rs around theV2tag and fixed-width entry sizing to confirm no production V1 data needs migration.Macroscope summarized bab6fe8.