Skip to content

perf(signal): coalesce receive flushes and persist outbound state pre-wire - #1022

Merged
jlucaso1 merged 24 commits into
mainfrom
perf/signal-flush-coalesce
Jul 14, 2026
Merged

perf(signal): coalesce receive flushes and persist outbound state pre-wire#1022
jlucaso1 merged 24 commits into
mainfrom
perf/signal-flush-coalesce

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

The live receive path flushed the whole dirty Signal cache to storage once per stanza — a session re-serialize plus a storage transaction per message, dominated by the record the ratchet re-dirties every time. It now routes through a single-flight coalescer: a burst of receives folds into one flush per window.

Only the receive path coalesces. A lost receive-side advance re-derives forward (the receiving chain derives CK_n → CK_n+1), and a consumed prekey stays buffered until its session is durable, so a crash inside the window is recoverable. The send path flushes synchronously, before the stanza reaches the wire, with error propagation: reusing an outbound counter reuses its message key + IV, so that advance must be durable before anyone can act on the ciphertext — and a persistence failure aborts the send instead of transmitting an advance we couldn't save. send_status_message follows the same rule.

Parity with WhatsApp Web

WA Web flushes its Signal store before the send hits the wire (DirectMsgToDeviceList, GroupSkmsgJob, AndSendStatusMsg) — the send path here matches that exactly. On receive, WA Web flushes after decrypt but before the receipt (ProcessingDecryptApi, Handle/Msg); this PR deliberately diverges there, deferring the receive-side flush into a coalescing window. The divergence is safe precisely because a lost receive advance re-derives forward, which the outbound direction cannot do — hence the asymmetry.

Single-flight scheduler, generation-scoped. The state is one atomic (connection_generation << 2) | RUNNING/DIRTY. Only the idle→running transition for the live generation spawns a worker (one worker per generation); requests mid-flush just mark DIRTY and that worker runs another window. A failing flush is retried by the same worker with exponential backoff (25 ms → 5 s cap), so concurrent traffic can't reset it to the base delay and detached tasks can't pile up. The generation is embedded so a reconnect during an in-flight flush is safe with no scheduler reset: a stale worker cannot mutate signal_flush_state a new-generation worker owns, and a stale schedule call is rejected outright. Across a reconnect a stale worker and the new one can briefly coexist; the stale one stands down at its generation check.

Flush-vs-teardown barrier. The generation-scoped atomic orders only signal_flush_state, not the backend writes. A signal_flush_lifecycle mutex closes that gap: the worker holds it only across the flush (never across sleep/backoff) and re-checks the generation under it, while teardown holds it across the entire Signal-cache settle. So a worker either wins the gate and flushes its own generation before any settle, or acquires it after teardown and stands down — it can never interleave a raw backend write between teardown's commit and the next connection's drain (which would persist that drain's rowless ratchet advances out of band). Lock order is always gate → permit/sessions-lock.

Impact

A/B with the same benchmark-client source, rebuilt separately for each revision (the Rust client statically links the library, so the two binaries necessarily differ — this is not a same-binary comparison). The PR side was built with the bench client's path-dep pointed at the ef1cf76d worktree; main at d9e693f5. Mock server = barback on :8080, CHATSTATE_TTL_SECS=3.

MODE=dhat ./run.sh connect <label>                                          # connect baseline
BENCH_TOTAL=2000 MODE=dhat ./run.sh pingpong <label>                        # alloc, x2
BENCH_TOTAL=50000 BENCH_RATE=3000 MODE=normal ./run.sh pingpong <label>     # throughput, x3
BENCH_TOTAL=500 BUILD_PROFILE=profiling MODE=dhat ./run.sh pingpong <label> # symbolic

Provenance caveats: (a) the harness meta.env records the base checkout's SHA (d9e693f5) and harness_git_dirty=yes, not the worktree HEAD, so the artifacts do not self-attest that ef1cf76d was measured — the PR library revision is ef1cf76d only by construction of the path-dep (symbol names in the PR profile do contain the new scheduler). (b) These runs predate the flush-vs-teardown gate and the P3 doc/test commits at the current head; those change teardown ordering and comments, not the steady-state receive allocation, so the deltas below still hold, but the numbers are ef1cf76d, not the final head. Allocation is measured against InMemoryBackend, so these are allocation deltas (session re-serialize + map bookkeeping), not SQLite I/O — the production write cost is separate and not measured here. DHAT is deterministic (run-to-run spread ~0.3%); connect-subtracted, per 2000-cycle ping-pong.

metric main PR delta
DM bytes/cycle 37,754 [37,718–37,790] 37,151 [37,100–37,203] −1.6%
DM blocks/cycle 232.2 [232.2–232.3] 228.4 [228.2–228.6] −1.7%
put_sessions_batch alloc bytes/cycle¹ 165.8 (4.02 blk) 115.3 (3.04 blk) −30.5%

¹ Allocation attributed to the put_sessions_batch frame in the 500-cycle symbolic profile. It is an allocation proxy for receive-path flush volume, not a direct call count, and InMemoryBackend has no transaction — the SQLite write cost is separate.

Throughput: no clear regression, but the sample is too small to call. Three normal-mode runs per side:

mode main PR
normal, target 3000 msg/s 2909 [2902–2910] 2902 [2752–2909]
dhat-instrumented rate 791 [786–797] 778 [776–780]

Medians match, but one PR run was ~5% lower and two PR runs showed elevated ack/pong latency (up to ~153 ms vs ~19 ms on main), so this is inconclusive — it needs more interleaved samples with p50/p95 latency and a separate max-throughput sweep before claiming parity. Context: an earlier revision's much lower dhat-rate came from a different bench-client binary across those runs, not a library change (in steady-state ping-pong the generation guards are no-ops, though the receive path does add loads/CAS, a timer task and the Weak upgrade over main). All runs: 2000/2000 pongs, 0 lost.

Honest framing: the byte gain is modest. Coalescing the send flush too gave a larger number on this benchmark, but that came from relaxing outbound durability — the exact hazard this revision refuses. What remains is the safe receive-side win (largest under bursty inbound traffic: high-rate ping-pong, group fan-in) plus the cut in receive-path flush allocation.

Durability model

  • Receive: coalesced; a lost advance re-derives forward, and live acks already preceded the per-stanza flush before this change, so ack-vs-flush ordering is unchanged — the crash-replay gap just widens to one window.
  • Send: flushed synchronously before the wire, error-propagating; the advance is durable before the stanza is transmitted.
  • Offline drain, retry, identity-change and teardown keep their own synchronous flushes.

Client::flush_pending_signal_state is a public "settle now" for read-after-write durability; its doc carries the permit precondition (self-deadlock if awaited from an event handler / durability hook under the drain permit — pre-existing on main) and notes the lag is a nominal window, not a hard wall-clock bound: it can extend under runtime starvation or slow/failing storage (the retry loop backs off up to 5 s).

Tests

  • test_send_aborts_before_wire_when_persist_fails (e2e): fails session persistence, then asserts the send returns Err, the flush was attempted (the put_sessions_batch counter moved), and — the deterministic core — a pre-registered sent-node waiter is still pending afterward. send_node resolves that waiter before send_raw_bytes, so a pending waiter proves the stanza was never marshaled for the wire. Peer-receives-nothing and post-recovery delivery are kept as end-to-end corroboration.
  • test_outbound_ratchet_is_durable_when_send_returns (e2e): reads the backend immediately after send_message, no delivery wait, no settle — a coalesced/deferred send flush would leave the sender-chain counter stale and fail it.
  • Scheduler unit tests (10). Single-flight: 200 sequential requests ride one armed worker (asserted via the RUNNING bit — no second spawn). Retry: an injected-failure flush is retried by the same worker until it persists (eventual persistence; the test does not measure the exact backoff curve). Generation safety: a worker held inside the flush across a bump cannot clobber the new generation's state; a stale schedule call leaves that state bit-for-bit unchanged and the pending DIRTY still forces a second flush attempt (asserted via the attempt counter reaching 2, so it proves the second window ran). Barrier: the lifecycle gate blocks a worker's flush while held, and a worker reaching the gate after a generation bump stands down without writing.
  • The InMemoryBackend counter/fault hooks these tests use are behind an off-by-default wacore/test-util feature (enabled only by the e2e crate), so normal and benchmark builds carry no extra fields or per-write bookkeeping.
  • The shared e2e connect helper gates on the canonical wait_for_connected and keeps wait_for_startup_sync as a hard requirement (fixes the earlier CI flake without weakening the suite).

Validation

  • cargo test -p whatsapp-rust --lib (988 passed, incl. 10 scheduler tests) and cargo test -p wacore --lib
  • e2e session suites: session_reuse (8), lid_sessions (9), 0 failures
  • cargo clippy -p whatsapp-rust -p wacore -p e2e-tests --tests -- -D warnings
  • RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo check --target wasm32-unknown-unknown --no-default-features

The live receive path and the send epilogue flushed the whole dirty
Signal cache once per stanza: a session re-serialize plus a SQLite
transaction per message, dominated by the record the ratchet re-dirties
every time. Route both through a trailing-edge debounced scheduler
(25 ms): one storage write covers a burst of messages.

Ordering is unchanged — live acks already preceded the per-stanza flush
— so this widens the existing crash-replay window from one stanza to at
most one debounce, bounded and recoverable (receive chains re-derive
forward; consumed prekeys stay buffered until their session is durable,
the flush-internal atomicity is untouched). The offline drain, retry
recovery, identity-change recovery and teardown keep their synchronous
flushes: those gate acks, receipts or follow-up reads on durability.
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Signal cache persistence now uses a generation-scoped, single-flight scheduler and drain-aware flush API. Receive handling schedules coalesced flushes, outbound sending flushes synchronously before transmission, and tests cover retries, durability, reconnects, and connection readiness.

Changes

Signal cache flushing

Layer / File(s) Summary
Drain-safe flush API
src/client/adapters.rs
Adds pending-state settlement and batch-safe flush variants for inbound drain handling.
Debounced flush scheduler
src/signal_flush.rs, src/client.rs, src/client/lifecycle.rs, src/lib.rs
Adds generation-scoped single-flight state, delayed coalescing, retry handling, lifecycle initialization, module wiring, and scheduler tests.
Message and send integration
src/message/receive.rs, src/send/mod.rs
Schedules coalesced receive-path flushes while performing synchronous outbound Signal persistence before sending.
Connection and persistence validation
tests/e2e/tests/lid_sessions.rs, tests/e2e/tests/session_reuse.rs, tests/e2e/src/lib.rs
Settles pending state before durable assertions, validates outbound ratchet durability, adds reconnect ordering, and simplifies readiness handling.

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

Sequence Diagram(s)

sequenceDiagram
  participant MessageReceiver
  participant Client
  participant SignalFlushWorker
  participant SignalCacheBackend
  MessageReceiver->>Client: schedule_signal_flush()
  Client->>SignalFlushWorker: coalesce flush request
  SignalFlushWorker->>SignalCacheBackend: persist batch-safe Signal state
  SignalCacheBackend-->>SignalFlushWorker: success or failure
  SignalFlushWorker->>SignalFlushWorker: retry or await dirty window
Loading

Possibly related PRs

Suggested labels: performance, api-design

Suggested reviewers: copilot, 7ra1, longevityboris, zdanysfa, cubic-dev-ai

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Title check ✅ Passed The title matches the main change: receive flush coalescing plus synchronous pre-wire outbound persistence.
Description check ✅ Passed The description accurately covers receive coalescing, synchronous send flushing, and the generation-scoped scheduler.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/signal-flush-coalesce

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.

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR coalesces receive-side Signal state flushes while keeping outbound state durable before transmission. The main changes are:

  • Adds a generation-scoped, single-flight receive flush worker.
  • Coordinates background flushes with connection teardown.
  • Moves message and status-send persistence before wire submission.
  • Adds retry, reconnect, teardown, and persistence-failure coverage.
  • Adds test-only in-memory backend counters and fault injection.

Confidence Score: 5/5

This looks safe to merge.

  • The teardown gate and generation checks prevent stale workers from writing after cache settlement.
  • Outbound persistence failures stop message transmission and propagate to callers.
  • No distinct unresolved production issue was found in the updated code.

Important Files Changed

Filename Overview
src/signal_flush.rs Adds the generation-scoped coalescing worker, retry logic, teardown barrier, and concurrency tests.
src/client/lifecycle.rs Coordinates generation changes and Signal cache settlement with the new lifecycle gate.
src/message/receive.rs Schedules coalesced flushes for live receives while retaining synchronous offline-drain handling.
src/send/mod.rs Persists outbound Signal state before sending message and status stanzas.
src/client/adapters.rs Adds a public durability-settlement API and documents batch-safe flush behavior.
wacore/src/store/in_memory.rs Adds feature-gated persistence counters and failure injection for end-to-end tests.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant R as Receive path
    participant S as Flush scheduler
    participant W as Flush worker
    participant G as Lifecycle gate
    participant B as Backend
    participant T as Teardown

    R->>S: Schedule flush for generation
    S-->>W: Start one worker or mark dirty
    W->>W: Wait for coalescing window
    W->>G: Acquire gate
    W->>W: Recheck generation
    W->>B: Flush Signal cache
    W-->>S: Clear running or process dirty state

    T->>T: Increment generation
    T->>G: Acquire gate
    T->>B: Settle pending cache state
    T->>T: Clear unresolved cache entries
    T-->>G: Release gate
    Note over W,T: Stale workers exit after observing the new generation
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant R as Receive path
    participant S as Flush scheduler
    participant W as Flush worker
    participant G as Lifecycle gate
    participant B as Backend
    participant T as Teardown

    R->>S: Schedule flush for generation
    S-->>W: Start one worker or mark dirty
    W->>W: Wait for coalescing window
    W->>G: Acquire gate
    W->>W: Recheck generation
    W->>B: Flush Signal cache
    W-->>S: Clear running or process dirty state

    T->>T: Increment generation
    T->>G: Acquire gate
    T->>B: Settle pending cache state
    T->>T: Clear unresolved cache entries
    T-->>G: Release gate
    Note over W,T: Stale workers exit after observing the new generation
Loading

Reviews (14): Last reviewed commit: "docs(e2e): correct the sent-node waiter ..." | Re-trigger Greptile

Comment thread src/message/receive.rs Outdated
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.85 MiB 10.86 MiB +4.53 KiB (+0.04%) 🔺
bin .text 8.85 MiB 8.86 MiB +4.44 KiB (+0.05%) 🔺
bin allocated (text+data+bss) 10.85 MiB 10.86 MiB +4.05 KiB (+0.04%) 🔺
llvm-lines wacore 505,737 505,737 0
llvm-lines wacore copies 17,371 17,371 0
llvm-lines whatsapp-rust lib 771,719 772,606 +887 (+0.11%) 🔺
llvm-lines whatsapp-rust lib copies 25,065 25,079 +14 (+0.06%) 🔺
deps crates (Cargo.lock) 472 472 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.67 MiB 1.68 MiB +2.88 KiB (+0.17%) 🔺
.text wacore 527.79 KiB 527.79 KiB 0
.text wacore_binary 148.45 KiB 148.45 KiB 0
.text wacore_libsignal 179.42 KiB 179.42 KiB 0
.text wacore_appstate 158.25 KiB 158.25 KiB 0
.text wacore_noise 26.05 KiB 26.05 KiB 0
.text waproto 1.60 MiB 1.60 MiB 0
.text whatsapp_rust_sqlite_storage 513.00 KiB 513.00 KiB 0
.text whatsapp_rust_tokio_transport 43.79 KiB 43.79 KiB 0
.text whatsapp_rust_ureq_http_client 10.47 KiB 10.47 KiB 0
.text std 1.01 MiB 1.01 MiB +1.59 KiB (+0.15%) 🔺
.text other deps 2.95 MiB 2.95 MiB 0
Top movers (cargo-bloat attribution)
Crate main PR Δ
whatsapp_rust 1.67 MiB 1.68 MiB +2.88 KiB (+0.17%)
rustix 191 B 1.88 KiB +1.69 KiB (+908.38%)
buffa_descriptor 4.67 KiB 2.98 KiB -1.69 KiB (-36.25%)
std 1.01 MiB 1.01 MiB +1.59 KiB (+0.15%)

Baseline: d9e693f54 (latest main run) · Head: e016b287f · Graphs

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 7 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/signal_flush.rs Outdated
Comment thread src/signal_flush.rs Outdated
jlucaso1 added 3 commits July 10, 2026 23:56
The on-the-fly migration test moved the LID session to PN in the
backend right after a verification message, while that message's
ratchet advance was still in the write-behind signal cache — the next
reconnect's teardown flush then resurrected the LID session the test
had just deleted, and the migration under test never ran. Settle with a
reconnect before the surgery, like the durability test already does:
the scenario this models (a legacy PN-only DB) arises with a quiescent
client anyway.
…nology

A fire whose flush failed cleared the pending flag and exited, leaving
dirty cache state unwritten until unrelated traffic scheduled again —
the documented bounded window became unbounded under a transient
storage failure. Re-arm on error so the window doubles as the retry
backoff. Also rename the docs: this is a fixed coalescing window, not a
trailing-edge debounce (deliberately — extending the deadline per
request would defer the flush indefinitely under continuous traffic).
The re-arm-on-error path called schedule_signal_flush from inside its
own fire, making the async fn recursive (not Send). Loop inside the
fire task instead, holding only the Weak across each window; a
concurrent request that re-armed meanwhile owns the retry.
greptile-apps[bot]
greptile-apps Bot previously approved these changes Jul 11, 2026

@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: 1

🤖 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/signal_flush.rs`:
- Around line 69-74: Update the retry path around flush_signal_cache_batch_safe
and schedule_signal_flush to track consecutive failures, apply exponential retry
backoff with a defined maximum, and reset the backoff after a successful flush.
Rate-limit the log::error reporting so persistent failures do not emit one error
per retry, while preserving coalescing and retry behavior.
🪄 Autofix (Beta)

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: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1d3df37a-3ce8-4e72-ae9d-ff75676ed0a5

📥 Commits

Reviewing files that changed from the base of the PR and between f2d0212 and 430b221.

📒 Files selected for processing (1)
  • src/signal_flush.rs

Comment thread src/signal_flush.rs Outdated

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/signal_flush.rs Outdated
A persistent storage failure turned the re-arm loop into ~40 attempts
and error logs per second. Double the backoff per consecutive failure
up to a 5s ceiling, which rate-limits the log with it; a success (or a
concurrent re-arm) still exits the loop.
@greptile-apps
greptile-apps Bot dismissed their stale review July 11, 2026 13:03

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Jul 11, 2026

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/signal_flush.rs (1)

67-92: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Keep the retry backoff shared across fires. The pending flag is cleared before the flush await, so a slow failed flush can be overlapped by a new request that arms a fresh task with SIGNAL_FLUSH_WINDOW again. Under steady traffic plus an outage, that resets the delay/log ceiling and brings back the retry storm; move the backoff/failure counter onto Client or another shared state.

🤖 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/signal_flush.rs` around lines 67 - 92, Move the retry backoff/failure
state from the local loop in the signal-flush task to shared state on Client (or
equivalent shared state), and update the retry logic around
flush_signal_cache_batch_safe to read, increase, and cap that shared value
across fires. Ensure a newly armed task reuses the accumulated backoff during
ongoing failures and resets the shared state only after a successful flush.
🤖 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.

Outside diff comments:
In `@src/signal_flush.rs`:
- Around line 67-92: Move the retry backoff/failure state from the local loop in
the signal-flush task to shared state on Client (or equivalent shared state),
and update the retry logic around flush_signal_cache_batch_safe to read,
increase, and cap that shared value across fires. Ensure a newly armed task
reuses the accumulated backoff during ongoing failures and resets the shared
state only after a successful flush.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e2f7812f-880d-4c58-8f43-c34fb2d3f523

📥 Commits

Reviewing files that changed from the base of the PR and between 1693785 and a75cfc4.

📒 Files selected for processing (1)
  • src/signal_flush.rs

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 1 file (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/signal_flush.rs Outdated
Inject flush failures via a cfg(test) counter (same pattern as the
commit batcher's fail_flushes): with two injected failures the fire
must consume both error attempts, re-arm through the backoff, and still
persist the pre-existing dirty entry on the third attempt.
@greptile-apps
greptile-apps Bot dismissed their stale review July 11, 2026 13:16

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Jul 11, 2026

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 3 files (changes from recent commits).

Requires human review: Performance optimization introduces coalescing write-behind for signal cache flushes, modifying critical message receive/send paths and durability model. Requires human review to verify correctness and safety of the new timing-based flush mechanism.

Re-trigger cubic

The shared connect helper waited for Event::Connected in a fixed 30s
event-loop, then fell back to offline_sync_completed — an orthogonal
signal. Under concurrent CI load the mock server was slow to serve the
critical app-state IQs, so Connected raced past 30s and the fallback
timed out on a signal that says nothing about readiness, failing 9
unrelated app-state tests at once.

Gate on wait_for_connected instead: it resolves on is_ready (set at the
same point Event::Connected is dispatched, after the critical sync) via
a notifier with a TOCTOU re-check, so it does not depend on event
arrival order, on who drains the channel, or on offline sync. The
startup-sync drain is now best-effort — readiness is already
guaranteed, and a message backlog must not fail the connect. The
determinism comes from waiting on the right signal, not from a larger
timeout.
@greptile-apps
greptile-apps Bot dismissed their stale review July 11, 2026 13:35

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Jul 11, 2026

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 1 file (changes from recent commits).

Requires human review: Modifies core Signal cache flushing logic with new coalescing, affecting all message send/receive paths and data durability. Not safe to auto-approve.

Re-trigger cubic

@greptile-apps
greptile-apps Bot dismissed their stale review July 11, 2026 13:45

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 7 files (changes from recent commits).

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/signal_flush.rs Outdated
jlucaso1 added 2 commits July 13, 2026 17:53
…ression)

The generation-scoped worker CAS protected against a stale WORKER, but
schedule_signal_flush itself read the generation once and its take-over
CAS accepted any state — so a call carrying a torn-down connection's
generation could CAS a newer generation's RUNNING|DIRTY state back down
to its own, dropping the pending DIRTY and orphaning the state (both the
new worker and the demoted one then stand down). Pass the caller's
already-validated lane_generation, no-op if it is not the live
generation, and never move the state to an older generation. Two tests:
a stale schedule leaves gen-1 state bit-for-bit unchanged, and a stale
schedule during a new worker's flush cannot clear the pending DIRTY.
…econnect

signal_cache and flush_signal_cache no longer flush unconditionally per
message (send is synchronous, receive coalesces) — correct their docs.
In the migration test, settle via flush_pending_signal_state instead of
a full reconnect: faster and isolates the scenario.
@greptile-apps
greptile-apps Bot dismissed their stale review July 13, 2026 20:56

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Jul 13, 2026

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 5 files (changes from recent commits).

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Requires human review: Introduces a new concurrency scheduler for Signal cache flush, altering durability guarantees on receive path and adding synchronous flush on send. High-impact, requires human review.

Re-trigger cubic

Adds two InMemoryBackend test hooks: a put_sessions_batch call counter
and a fail switch. The new e2e test fails session persistence, then
asserts the send returns Err, the flush was attempted (counter moved, so
the flush runs pre-wire), the peer receives nothing, and delivery
resumes once persistence recovers. This is the deterministic proof of
the send-path durability ordering the coalescer relies on. TestClient
retains the concrete backend to reach the hooks.
@greptile-apps
greptile-apps Bot dismissed their stale review July 13, 2026 21:13

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@jlucaso1 jlucaso1 changed the title perf(signal): coalesce the receive-path Signal cache flush perf(signal): coalesce live receive-path Signal cache flushes Jul 13, 2026
greptile-apps[bot]
greptile-apps Bot previously approved these changes Jul 13, 2026

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 3 files (changes from recent commits).

You’re at about 95% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Requires human review: This PR modifies core signal cache flushing logic with concurrency, durability, and new synchronization primitives. Such critical changes require human review despite passing automated checks.

Re-trigger cubic

jlucaso1 added 2 commits July 13, 2026 19:16
…-util

The put_sessions_batch call counter and fault switch were unconditional
fields plus a fetch_add/load on every batch write, so every production
build carried them and any in-memory benchmark saw instrumentation the
SQLite path does not. Move both behind a new off-by-default test-util
feature (and cfg(test) for wacore own tests); the e2e crate enables it.
Normal builds regain a zero-cost, two-field struct with no fault-
injection surface.
The stale-schedule starvation test only checked that the pending DIRTY
survived; the first (unblocked) flush would persist the second session
on its own, so it never proved a second window ran. Require the attempt
counter to reach 2 after release. The pre-wire abort e2e now registers a
sent-node waiter and asserts it stays pending after the failed send: no
message node was marshaled, so send_node (and the wire) was never
reached, replacing the sole reliance on a negative timed assertion.
Also correct the flush_signal_cache comment (no longer per-message, and
the backend is generic, not SQLite).
@greptile-apps
greptile-apps Bot dismissed their stale review July 13, 2026 22:17

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Jul 13, 2026

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 6 files (changes from recent commits).

You’re at about 95% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Requires human review: Significant change to Signal cache flush strategy: introduces coalesced receive-path scheduling and synchronous pre-wire send flushes. Affects core durability model and error handling. Requires human review of concurrency, crash recovery, and backoff logic.

Re-trigger cubic

jlucaso1 added 2 commits July 13, 2026 19:57
The generation-scoped atomic ordered only signal_flush_state, not the
backend writes. A worker that passed its pre-flush generation check could
be preempted (e.g. blocked on the sessions lock teardown holds), and
resume its flush after teardown settled the cache and the next
connection's drain dirtied it — persisting that drain's rowless ratchet
advances out of band, the silent-loss class the commit batcher prevents.

Add a signal_flush_lifecycle mutex: the worker holds it only across the
flush (never across sleep/backoff) and re-checks the generation under it;
teardown holds it across the whole cache settle. So a worker either wins
the gate and flushes its own generation before any settle, or acquires it
after teardown and stands down on the generation check. Lock order is
always gate -> permit/sessions-lock, so no inversion. Two deterministic
tests: the gate blocks a worker flush while held, and a worker reaching
the gate after the bump stands down without writing.
send_node resolves the waiter before marshaling the node, not "when
marshaled"; a pending waiter proves a pre-wire abort.
@jlucaso1 jlucaso1 changed the title perf(signal): coalesce live receive-path Signal cache flushes perf(signal): coalesce receive flushes and persist outbound state pre-wire Jul 13, 2026
@greptile-apps
greptile-apps Bot dismissed their stale review July 13, 2026 23:03

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 4 files (changes from recent commits).

You’re at about 96% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/client/lifecycle.rs">

<violation number="1" location="src/client/lifecycle.rs:850">
P1: Disconnect/reconnect can hang indefinitely when a pre-existing coalesced worker is stalled acquiring the processing permit or writing storage. Include lifecycle-gate acquisition in the teardown deadline (or otherwise cancel/bound the worker) so the existing bounded-settle guarantee still covers this wait.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/client/lifecycle.rs
// backend write between our commit and the next connection's drain, or it
// could persist that drain's rowless advances. The worker re-checks the
// generation (bumped above) once it gets the gate, so it stands down.
let flush_gate = self.signal_flush_lifecycle.lock().await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Disconnect/reconnect can hang indefinitely when a pre-existing coalesced worker is stalled acquiring the processing permit or writing storage. Include lifecycle-gate acquisition in the teardown deadline (or otherwise cancel/bound the worker) so the existing bounded-settle guarantee still covers this wait.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/client/lifecycle.rs, line 850:

<comment>Disconnect/reconnect can hang indefinitely when a pre-existing coalesced worker is stalled acquiring the processing permit or writing storage. Include lifecycle-gate acquisition in the teardown deadline (or otherwise cancel/bound the worker) so the existing bounded-settle guarantee still covers this wait.</comment>

<file context>
@@ -840,6 +841,13 @@ impl Client {
+        // backend write between our commit and the next connection's drain, or it
+        // could persist that drain's rowless advances. The worker re-checks the
+        // generation (bumped above) once it gets the gate, so it stands down.
+        let flush_gate = self.signal_flush_lifecycle.lock().await;
         if let Some(client) = self.self_weak.get().and_then(|w| w.upgrade()) {
             client
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant