From dcaa02213f9c94dd26a55ceffe07bfda21c9db6c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 19 May 2026 20:10:10 -0700 Subject: [PATCH] docs(concurrency): document unbounded-channel backpressure invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10d audit of all true prod unbounded `mpsc::unbounded_channel()` call sites. Phase-10a baseline reported 4 (3 daemon + 1 client) based on raw regex hits; hand-audit corrected to **2 true prod sites**: * `crates/uffs-daemon/src/cache/journal_sink.rs:160` (`RegistryPatchSink::apply_tx`) * `crates/uffs-client/src/connect.rs:83` (`UffsClient::notification_tx`) The script's "3 daemon" matched two field-type sigs + the constructor inside the same file plus a `#[cfg(test)] new_for_test` constructor; two additional `uffs-daemon/src/index/search.rs` matches were doc-comment occurrences of the word "unbounded" (predicates / display-row filters). Both prod sites are kept unbounded with documented "by-construction bounded" rationale + producer-rate ceilings + worst-case memory calculations. ## Site 1 — `RegistryPatchSink::apply_tx` Sync callback escape hatch from the journal loop's `accept`/`trigger_save`/`journal_wrapped` (sync `fn`, not `async fn`) into the async `applier_task`. Constraints pinning unbounded: 1. Producer is sync-non-blocking by contract — cannot `.await` on a bounded send. A bounded variant would be `try_send` + drop-on-full, identical to the existing "dead applier silently absorbed" degraded path documented on `apply_tx`. 2. Producer cadence throttled upstream by `SaveTrigger` (50K events ∨ 5-min age). Worst-case steady-state ≈ 5 messages/min across all drives. 3. Payload bounded (`ApplyMsg::Save` carries ≤ 50K-event `Vec`, ~10 MB peak, consumed in ~1 s by serial applier). Added a full `# Backpressure` rustdoc block on `spawn_with_applier`. ## Site 2 — `UffsClient::notification_tx` Per-client receive buffer for incoming `DaemonEvent` notifications forwarded from the IPC read loop. Constraints pinning unbounded: 1. Producer rate is upstream-bounded by the daemon's `broadcast::Sender` capacity (`DEFAULT_BROADCAST_CAPACITY`). A slow client cannot induce unbounded daemon emit. 2. Notification cadence is intrinsically low (≈ 2/min steady-state; ≈ 0.5/sec peak during multi-drive load). 3. `try_send` on bounded would invert responsibility — the producer is the async IPC `read_loop`, and stalling it would block RPC responses on the same socket, strictly worse than the current "drop only at the daemon broadcast layer" design. Added a full `# Backpressure` rustdoc block on the `notification_tx`/`notification_rx` field docs. ## Rule-1 adherence * Zero `#[allow(...)]` introductions. * No suppression hacks, no skipped tests. * Documentation-only — zero behavior change. * `cargo check / clippy -D warnings / fmt` all clean. * `cargo test -p uffs-daemon --lib` — 298 passed. * `cargo test -p uffs-client` — 2 passed + 1 doctest. Per-site verdict in `docs/dev/baseline/2026-05-19/phase_10_backpressure_audit.md` (local). Refs #302. --- crates/uffs-client/src/connect.rs | 1 + crates/uffs-daemon/src/cache/journal_sink.rs | 32 ++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/uffs-client/src/connect.rs b/crates/uffs-client/src/connect.rs index 93e6324c4..39393cdc2 100644 --- a/crates/uffs-client/src/connect.rs +++ b/crates/uffs-client/src/connect.rs @@ -80,6 +80,7 @@ impl UffsClient { reader: BufReader>, writer: Box, ) -> Self { + // Phase 10d: unbounded by-design — see backpressure_audit.md. let (notification_tx, notification_rx) = tokio::sync::mpsc::unbounded_channel(); Self { reader, diff --git a/crates/uffs-daemon/src/cache/journal_sink.rs b/crates/uffs-daemon/src/cache/journal_sink.rs index 37922af19..0d23ff12e 100644 --- a/crates/uffs-daemon/src/cache/journal_sink.rs +++ b/crates/uffs-daemon/src/cache/journal_sink.rs @@ -156,6 +156,38 @@ impl RegistryPatchSink { /// does NOT extend the daemon's lifetime — when all /// `Arc` instances drop the applier exits cleanly /// via the `Weak::upgrade` `None` arm. + /// + /// # Backpressure + /// + /// The `apply_tx` mpsc channel is **unbounded by design**. Three + /// constraints pin this choice (Phase 10d audit): + /// + /// 1. **Producer is sync-non-blocking by contract.** `accept` / + /// `trigger_save` / `journal_wrapped` are `fn`, not `async fn` — invoked + /// synchronously from + /// [`crate::cache::journal_loop::JournalLoop::process_tick`]. They + /// cannot `.await` on a bounded `send`, so a bounded variant would have + /// to use `try_send` + drop-on-full, which is operationally identical to + /// the existing "dead applier silently absorbed" degraded path + /// (documented on `apply_tx`). + /// + /// 2. **Producer cadence is throttled upstream by + /// [`crate::cache::journal_loop::SaveTrigger`].** Save messages fire on + /// either the 50K-event threshold OR the 5-minute age threshold. + /// Worst-case steady-state ≈ 1 `ApplyMsg::Save` per drive per 5 min × 26 + /// drives ≈ 5 messages/min. Wrap messages are rare (NTFS USN journal + /// head reset only). + /// + /// 3. **Payload is bounded.** Each `ApplyMsg::Save` carries the drained + /// per-letter `Vec` (capped at the 50K-event threshold; ~10 + /// MB peak per save tick) and is consumed within ~1 s by the applier's + /// serial loop. If the applier wedges, memory grows by ~10 MB per drive + /// per 5 min — a worst-case that implies the daemon itself is wedged + /// (the applier's blocking step is registry write-lock + body patch, + /// which is a daemon-wide hot path), so process restart resolves both. + /// + /// See `docs/dev/baseline/2026-05-19/phase_10_backpressure_audit.md` + /// (local) for the full per-site verdict. pub(crate) fn spawn_with_applier(idx: &Arc) -> (Arc, JoinHandle<()>) { let (apply_tx, apply_rx) = mpsc::unbounded_channel(); let weak = Arc::downgrade(idx);