Skip to content

feat(recv)!: batch the inbound commit pipeline during the offline drain - #961

Merged
jlucaso1 merged 35 commits into
mainfrom
claude/whatsapp-rust-allocator-api-prw9c4
Jul 3, 2026
Merged

feat(recv)!: batch the inbound commit pipeline during the offline drain#961
jlucaso1 merged 35 commits into
mainfrom
claude/whatsapp-rust-allocator-api-prw9c4

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Production tracing showed the offline drain paying every durability cost per message while globally serialized (1-permit semaphore): one durability-hook commit (~2.8 ms on a Postgres-backed hook), two pending-buffer transactions, one Signal-cache flush and one spawned event per message — a 421-message backlog spent ~1.2 s in per-message round-trips.

WA Web batches all of this, verified in the captured JS:

  • WAWebMessageProcessorCache accumulates decrypted messages with three flush triggers: size (web_message_processing_cache_size), timeout (web_offline_message_processor_timeout_seconds, ShiftTimer) and forced flush at end of drain.
  • createSnapshot commits per batch, in strict order: bulk message-table write → bulk signal-store commit under lock (bulkPutSession/bulkPutSenderKey/…) → only then sendAggregateOfflineReceipts(batch).
  • A failed snapshot never marks its checkpoint, so the server redelivers the batch.

Our per-message hook was the parity divergence — not the batch. This PR closes it end-to-end: everything per-message in the drain pipeline now amortizes over the batch, with the same ordering guarantee (nothing acked or observable before it is durable).

Design

New InboundCommitBatcher (src/message/commit_batch.rs), active during the drain:

  • Triggers (WA Web parity): 200 messages (the server's offline batch size), 4 MiB encoded (stricter than WA Web, which caps by count only), 3 s timeout (its pre-ack batcher's delayMs: 3000), forced flush at end of drain and on disconnect/reconnect.
  • Commit sequence (createSnapshot ordering): pending-inbound buffer (one SQLite txn, rows encoded into a single reused arena) → Signal-cache flush → hook.on_messages(batch) → buffer clear (one txn) → acks → Event::Messages. Any failure leaves the whole batch unacked for redelivery.
  • Raceless drain→live transition: the batcher owns an active state that only the end-of-drain flush flips, while holding the single processing permit — no stanza can straddle the transition, and stanzas queued behind the permit commit strictly after the tail batch, preserving arrival order across the boundary. The end-of-drain finisher runs off the read loop (a slow hook must not starve pongs/IQs), guarded by the connection generation.
  • Live traffic commits immediately as a batch of one (also WA Web behavior: same pipeline, immediate flush). Latency and per-stanza Signal flush are unchanged on the live path; live commits use a local encode buffer so they never serialize behind a slow hook.

Failure handling (hardened through review)

The core invariant, enforced end-to-end: a Signal ratchet advance is never persisted without a durable buffered row for its message — otherwise a crash turns the server's redelivery into an acked duplicate (silent loss for hook consumers). The machinery, added across an adversarial self-review plus several bot-review rounds:

  • ReinsertGuard restores taken entries when a commit is cancelled (bounded teardown) or fails before its durable point; re-storing rows is idempotent, so a transient Signal-flush failure after the row write keeps the batch retryable in-session.
  • A failed tail commit defers the drain→live transition (pending_live): the batcher stays in drain mode at one permit, a generation-scoped retry task reflushes every 3 s, and the first durable flush completes the transition (deactivate + widen + buffered-receipt flush together). Completion is still published immediately, so startup waiters never hang.
  • Out-of-band Signal flushes (sends, retry receipts, identity changes, session APIs) go through flush_signal_cache_batch_safe, which commits the pending batch under the permit first; session/chain locks are released before it to avoid lock inversion with the pipeline.
  • Teardown quiesces the pipeline: cleanup_connection_state bumps the connection generation first (lane workers stop draining; a post-permit re-check in process_classified_message catches stragglers before any ratchet mutation), then commits + settles the Signal cache in one permit-held section. The cache drop is coupled to the batcher resets that drop entries — rowless advances never outlive their entries, while flush-failure-retained committed state survives for the next successful flush.
  • Offline delivery receipts keep buffering while the batcher is active (including the deferred window) and only flush after a durable commit; on failure they are dropped so everything unacked redelivers.

Newsletters bypass the batcher/hook entirely (their plaintext stanza is acked at enqueue and never redelivered — gating them on the hook could permanently drop them) and dispatch as event-only Event::Messages, which the hook docs state.

Breaking changes (pre-1.0)

Before After
InboundDurabilityHook::on_message(client, info, message) on_messages(client, &[InboundMessage]) — all-or-nothing, slice order; batch of one on live traffic
Event::Message(Arc, Arc) Event::Messages(MessageBatch { messages: Arc<[InboundMessage]>, origin: Live | OfflineDrain }) — Baileys' messages.upsert shape
EventKind::Message EventKind::Messages
Event::as_message() Event::as_messages() (whole batch) / Event::messages() (message iterator)
MessageContext::from_event MessageContext::from_inbound

InboundMessage, MessageBatch and BatchOrigin are exported from the prelude. MessageBatch is a first-class collection: it derefs to nothing but exposes iter()/len()/is_empty()/first() and IntoIterator for &MessageBatch, so for msg in &batch and batch.len() work directly; messages: Arc<[InboundMessage]> stays public for zero-copy sharing. The event slice is the same allocation handed to the hook: what the hook committed is exactly what event consumers observe, in the same order — a consumer never sees a message whose commit failed (a hook that only succeeds on redelivery replay dispatches its event there). Bot::on_message keeps its per-message closure signature by fanning out the batch in order, so simple bots migrate with zero changes.

ProtocolStore gains store_pending_inbound_batch / delete_pending_inbound_batch with defaults that iterate the single-row methods (third-party backends keep working); SqliteStore overrides both with one transaction per batch. The single-row methods stay: the redelivery replay and the BotBuilder capability probe are inherently single-row.

What this buys (421-message drain, from production numbers)

Cost Before After
Hook commits (Postgres round-trips) 421 ~3
Pending-buffer SQLite txns 842 ~6
Signal-cache flushes 421 ~3
Event spawns per handler 421 ~3
Encode Vec allocations 421 ~3 reused arenas
Loss-zero guarantee (synchronous_commit=on) ✅ unchanged

Live-path cost is unchanged vs main (single commit, immediate; the batch slice is built in one allocation — measured 2.0→1.0 allocs, −18ns for that step). Delivery receipts and message secrets were already batched (offline receipt aggregation, MsgSecretWriteBuffer) — this closes the remaining four. Bare <receipt> stanzas stay 1:1, matching WA Web (its pre-ack batcher batches persistence, not socket sends).

Validation

  • Workspace test suite green (2600+ tests excluding e2e), including the batcher suite (accumulate-then-commit order, live batch-of-one, size trigger, no-hook event batching, teardown reset, raceless drain→live transition, deferred-transition retry, flush-failure-after-rows retry) via test-only failure-injection points
  • E2E suite green in CI on every recent head (offline drain, reconnect, receipts, LID sessions)
  • cargo clippy -D warnings clean on all crates and feature combinations, including --all-features and the wasm32 release build
  • Binary size: .text +~45 KiB over the 32 KiB per-PR budget — accepted feature cost (size-increase-ok label); llvm-lines show it is new code, not monomorphization (+1.2% copies)
  • CodSpeed: no significant changes — benches don't exercise the drain path with a hook
  • New span: wa.recv.commit_batch (field: count) replaces per-message hook time in dashboards

Production traces showed the offline drain paying every durability cost
per message while globally serialized (1-permit semaphore): one hook
commit, two pending-buffer transactions, one Signal-cache flush and one
spawned event per message — ~2.8ms each on a Postgres-backed hook, so a
421-message backlog spent ~1.2s in per-message round-trips.

WA Web batches all of this. Its MessageProcessorCache accumulates
decrypted messages (size cap + timeout + end-of-drain triggers) and
createSnapshot commits per batch, in order: bulk message-table write,
bulk signal-store commit under lock, THEN the aggregated receipts for
the batch. Our per-message hook was the parity divergence, not the
batch.

This introduces an InboundCommitBatcher with the same triggers (200
msgs / 4 MiB / 3s / end-of-drain) and the same commit ordering: pending
buffer (one txn, single reused encode arena) -> Signal flush -> hook ->
buffer clear (one txn) -> event -> acks. Nothing is acked or observable
before it is durable; a failed batch stays unacked whole and the server
redelivers it. Live traffic commits immediately as a batch of one (also
WA Web behavior). Every flush acquires the global processing permit so
the Signal flush can never interleave with a half-processed stanza,
which would otherwise persist a ratchet advance for an uncommitted
message and turn its redelivery into an unrecoverable duplicate. The
end-of-drain flush runs before the semaphore widens, so the batcher is
provably empty on the live path.

Breaking (pre-1.0):
- InboundDurabilityHook::on_message(info, message) is replaced by
  on_messages(&[InboundMessage]) — all-or-nothing, slice order, batch
  of one on live traffic.
- Event::Message(Arc<Message>, Arc<MessageInfo>) is replaced by
  Event::Messages(MessageBatch { messages: Arc<[InboundMessage]>,
  origin: Live | OfflineDrain }) — Baileys' messages.upsert shape. The
  slice is the same allocation handed to the hook: what was committed
  is exactly what event consumers observe, in order.
- EventKind::Message -> EventKind::Messages; Event::as_message() ->
  message_batch()/messages(); MessageContext::from_event ->
  from_inbound. Bot::on_message keeps its per-message signature by
  fanning out the batch in order.
- ProtocolStore gains store/delete_pending_inbound_batch (defaults
  iterate the single-row methods, so third-party backends keep
  working); SqliteStore overrides both with one transaction per batch.

Per-message costs for a 421-message drain: 421 hook commits -> ~3, 842
pending-buffer txns -> ~6, 421 Signal flushes -> ~3, 421 event spawns
per handler -> ~3, 421 encode Vecs -> ~3 reused arenas. Delivery
receipts and message secrets were already batched (offline receipt
aggregation, MsgSecretWriteBuffer); bare <ack> stanzas stay 1:1, which
matches WA Web (its pre-ack batcher batches persistence, not sends).
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a05c2a00-8661-4101-b783-03d2228b5b76

📥 Commits

Reviewing files that changed from the base of the PR and between d82ccea and 4b672d6.

📒 Files selected for processing (2)
  • src/client/adapters.rs
  • src/message/commit_batch.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Message delivery now surfaces as batched events (multiple messages per event), with updated event access patterns and prelude types for batched-message handling.
    • Inbound durability hooks are now batch-oriented (all-or-nothing per batch).
  • Bug Fixes

    • Improved offline sync completion and disconnect/reconnect flushing to reduce missed/duplicated deliveries.
    • More reliable Signal/cache flushing during drain and message processing.
  • Documentation

    • Updated E2E testing docs and examples to use the new batched-message waiting predicates.
    • Refreshed MESSAGE_EDIT integration instructions for batched-event handling.
  • Tests

    • Updated E2E and unit tests to assert against batched message events and embedded message lists.

Walkthrough

This PR replaces single-message inbound delivery with batched Messages events, adds offline drain commit batching and batch durability hooks, and updates client lifecycle, signal flushing, handlers, docs, examples, and tests to use the new batch-oriented APIs.

Changes

Batched inbound messages and durability flow

Layer / File(s) Summary
Event and hook contracts
wacore/src/types/events.rs, src/types/durability_hook.rs, wacore/src/store/traits.rs, src/message.rs, src/lib.rs
Adds EventKind::Messages, Event::Messages(MessageBatch), batch accessors, InboundMessage, BatchOrigin, batch hook contracts, pending-inbound batch helpers, and public re-exports.
Inbound commit batching and persistence
src/message/commit_batch.rs, storages/sqlite-storage/src/sqlite_store.rs, src/message/durability.rs, src/message/dispatch.rs, src/pdo.rs
Buffers inbound messages, flushes them on thresholds or drain boundaries, persists and deletes pending inbound rows in batches, invokes the batch hook, and dispatches batched events after durability work completes.
Client lifecycle and receive coordination
src/client/*.rs, src/message/receive.rs, src/handlers/ib.rs, src/handlers/message.rs, src/features/signal.rs, src/retry.rs, src/send/mod.rs, src/voip/facade.rs, src/handlers/notification/device.rs, src/client/tests.rs, src/receipt.rs, src/test_utils.rs
Initializes, flushes, clears, and awaits inbound batch state across connection and offline-sync transitions, updates permit acquisition, and switches Signal cache flushing to batch-safe paths.
Consumers, docs, and tests
src/bot.rs, examples/benchmark.rs, examples/durability_hook.rs, src/reexports_test.rs, tests/e2e/**, src/message/tests.rs, agent_docs/e2e_testing.md, src/features/message_edit.rs, src/msg_secret_buffer.rs
Updates message context construction, example handlers, hook implementations, docs, and E2E/message tests to consume batched message events and batch hook callbacks.

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

Possibly related PRs

Suggested labels: api-design, breaking-change
Suggested reviewers: cubic-dev-ai, greptile-apps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: batching the inbound commit pipeline during offline drain.
Description check ✅ Passed The description is strongly aligned with the changeset and explains the batched inbound commit design and behavior.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/whatsapp-rust-allocator-api-prw9c4

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.

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.62 MiB 10.68 MiB +54.25 KiB (+0.50%) 🔺
bin .text 8.66 MiB 8.70 MiB +45.06 KiB (+0.51%) 🚨
bin allocated (text+data+bss) 10.62 MiB 10.68 MiB +53.32 KiB (+0.49%) 🔺
llvm-lines wacore 500,712 500,835 +123 (+0.02%) 🔺
llvm-lines wacore copies 17,154 17,162 +8 (+0.05%) 🔺
llvm-lines whatsapp-rust lib 720,149 728,604 +8,455 (+1.17%) ⚠️
llvm-lines whatsapp-rust lib copies 23,315 23,639 +324 (+1.39%) ⚠️
deps crates (Cargo.lock) 466 466 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.54 MiB 1.57 MiB +29.83 KiB (+1.89%) ⚠️
.text wacore 534.18 KiB 535.89 KiB +1.71 KiB (+0.32%) 🔺
.text wacore_binary 157.67 KiB 157.49 KiB -191 B (-0.12%) 🔽
.text wacore_libsignal 174.81 KiB 174.81 KiB 0
.text wacore_appstate 156.10 KiB 156.10 KiB 0
.text wacore_noise 26.05 KiB 26.05 KiB 0
.text waproto 1.60 MiB 1.60 MiB +179 B (+0.01%) 🔺
.text whatsapp_rust_sqlite_storage 481.73 KiB 488.88 KiB +7.15 KiB (+1.48%) ⚠️
.text whatsapp_rust_tokio_transport 43.46 KiB 43.46 KiB 0
.text whatsapp_rust_ureq_http_client 9.05 KiB 9.05 KiB 0
.text std 1007.78 KiB 1013.77 KiB +6.00 KiB (+0.60%) 🔺
.text other deps 2.94 MiB 2.94 MiB +30 B (+0.00%) 🔺
Top movers (cargo-bloat attribution)
Crate main PR Δ
whatsapp_rust 1.54 MiB 1.57 MiB +29.83 KiB (+1.89%)
whatsapp_rust_sqlite_storage 481.73 KiB 488.88 KiB +7.15 KiB (+1.48%)
std 1007.78 KiB 1013.77 KiB +6.00 KiB (+0.60%)
wacore 534.18 KiB 535.89 KiB +1.71 KiB (+0.32%)

🚨 Per-PR size budget exceeded (Δ stripped ≤ 64.00 KiB, Δ .text ≤ 32.00 KiB):

  • bin .text: +45.06 KiB (+0.51%) exceeds the 32.00 KiB per-PR budget

The size-increase-ok label is set, so the gate is not enforced for this PR.

Baseline: c57b87bbb (latest main run) · Head: 6aadc747f · Graphs

MessageContext is not Send on wasm32 (the Client's trait objects carry
no Send/Sync bounds there), so the batch fan-out must not hold contexts
across an await. Build every handler future before the async block —
only the futures, which the Fut: Send bound covers, cross the awaits.
Same shape the pre-batch code used for its single future.

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

4 issues found and verified against the latest diff

Confidence score: 2/5

  • In src/message/receive.rs, drain messages can still take the live flush path during offline→live transition, reopening a window where Signal ratchet state is persisted before the batch is durably committed; merging as-is risks crashes that leave protocol state ahead of stored messages. Gate drain handling to the offline-safe commit path (or equivalent durability ordering) before merging.
  • In examples/durability_hook.rs, duplicate keys within the same batch are not deduplicated before archive write, so one fsync can append the same key twice and create duplicate durable records. Check both previously seen keys and keys already queued in the current batch before writing.
  • In src/test_utils.rs, helpers mark tests as live while still using a 1-permit semaphore, so true concurrent live-path behavior is not exercised and race/ordering regressions can slip through. Update the helper to use realistic live-path concurrency before relying on these tests as merge confidence.
  • In src/message/commit_batch.rs, stale-permit re-acquire logic is duplicated in multiple paths, increasing the chance of future semaphore-generation fixes being applied inconsistently and reintroducing subtle concurrency bugs. Consolidate permit acquisition into a shared helper to de-risk follow-up changes.
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="examples/durability_hook.rs">

<violation number="1" location="examples/durability_hook.rs:123">
P2: Duplicate messages inside a single batch are not deduplicated before archive write, so the same key can be appended twice in one fsync. Checking both already-seen keys and keys queued earlier in this batch would keep archive output idempotent.</violation>
</file>

<file name="src/test_utils.rs">

<violation number="1" location="src/test_utils.rs:117">
P2: Tests created via this helper are marked as "live" but still run with a 1-permit message semaphore, so concurrent live-path behavior is not exercised and race/ordering bugs can be masked. Consider updating the helper to mirror offline completion state by widening permits when setting `offline_sync_completed=true`.</violation>
</file>

<file name="src/message/receive.rs">

<violation number="1" location="src/message/receive.rs:536">
P1: A drain message can take the live flush path during the offline->live transition, which reintroduces a crash window where Signal ratchet state is persisted before that message batch is durably committed. This comes from gating on `offline_sync_completed` even though `complete_offline_sync()` sets that flag before flushing the tail batch; consider keeping batch-owned flush behavior until the pending inbound batch is fully drained.</violation>
</file>

<file name="src/message/commit_batch.rs">

<violation number="1" location="src/message/commit_batch.rs:145">
P3: Stale-permit re-acquire logic now exists in two places, which makes future fixes to semaphore-generation handling easy to apply in one path and miss in the other. A shared helper for acquiring the current message-processing permit would reduce drift risk in this safety-critical flow.</violation>
</file>

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

Re-trigger cubic

Comment thread src/message/receive.rs Outdated
Comment thread examples/durability_hook.rs Outdated
Comment thread src/test_utils.rs Outdated
Comment thread src/message/commit_batch.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0dc4a4700

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/message/commit_batch.rs Outdated
Comment thread src/message/commit_batch.rs Outdated
@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the per-message inbound commit pipeline with a batching layer (InboundCommitBatcher) that mirrors WA Web's MessageProcessorCache semantics — bulk durable write → Signal-cache flush → hook → acks → event — reducing a 421-message drain from ~421 Postgres round-trips to ~3. Live traffic continues to commit as a batch of one.

  • InboundCommitBatcher (src/message/commit_batch.rs, 1279 lines) is the core addition: flush triggers (size, byte-cap, timeout, end-of-drain), a ReinsertGuard that atomically restores taken entries on cancellation or pre-durable failure, a deferred drain→live transition with a retry loop for failed tail commits, and a generation-scoped finisher task that avoids blocking pongs/IQs on the read loop.
  • Breaking API changes (pre-1.0): InboundDurabilityHook::on_messageon_messages(&[InboundMessage]), Event::MessageEvent::Messages(MessageBatch), MessageContext::from_eventfrom_inbound; BotBuilder::on_message fans the batch out in arrival order so simple bots migrate without changes.
  • Safety machinery: every flush_signal_cache call that may race the drain is replaced by flush_signal_cache_batch_safe, which commits accumulated drain entries (rows first) under the processing permit before the raw cache flush, closing the rowless-ratchet-advance path that would turn redeliveries into ackable duplicates.

Confidence Score: 5/5

The PR is safe to merge. The core correctness invariant — no Signal ratchet advance persisted without a durable buffered row — is enforced end-to-end by the ReinsertGuard, permit-gated flushes, and generation-scoped teardown. All previous review feedback has been addressed.

The atomics that were previously on Relaxed are now Acquire; the shared arena no longer serializes live-path hook calls; the SQLite batch methods wrap per-row statements in a single transaction. The two remaining observations are minor: the bot fan-out behavior change is documented, and the retry loop's lack of backoff is a quality-of-life concern rather than a correctness defect.

src/message/commit_batch.rs is the densest new code and is worth a focused read, particularly the deferred-transition paths and the ReinsertGuard drop implementation.

Important Files Changed

Filename Overview
src/message/commit_batch.rs New 1279-line core of the PR: InboundCommitBatcher with ReinsertGuard, deferred transition retry, drain/live commit paths, and a comprehensive test suite covering all failure modes.
src/client/sessions.rs complete_offline_sync made async; drain finisher spawned off the read loop to avoid deadlocking slow hooks against pong/IQ responses; publish_offline_sync_live_state handles durable/deferred/upgrade-failure cases.
src/client/lifecycle.rs connection_generation bumped first in cleanup_connection_state; teardown_inbound_commits_bounded replaces raw flush+clear; disconnect/reconnect paths gate offline receipts on durable batch commit.
src/client/adapters.rs flush_signal_cache_batch_safe added: during drain routes through flush_inbound_commits_under_permit to commit rows before persisting ratchet advances; outside drain, plain flush.
src/message/receive.rs Lane-generation guard added at classification and post-permit; live path calls flush_signal_cache_logged, drain path calls maybe_flush_inbound_commits; identity-change path commits drain batch before raw cache flush.
wacore/src/types/events.rs Event::Message replaced by Event::Messages(MessageBatch); InboundMessage, MessageBatch, BatchOrigin added as first-class types with IntoIterator, iter, len, first.
storages/sqlite-storage/src/sqlite_store.rs store_pending_inbound_batch and delete_pending_inbound_batch overrides added, each wrapping per-row calls in a single transaction; shared helpers prevent single/batch path divergence.
src/bot.rs on_message fans out Event::Messages batch in arrival order; all handler closures called up-front then awaited sequentially to avoid holding MessageContext across an await on wasm32.
wacore/src/store/traits.rs store_pending_inbound_batch / delete_pending_inbound_batch added to ProtocolStore with defaults iterating single-row methods; PendingInboundRow / PendingInboundKey zero-copy borrowed types added.
src/receipt.rs try_buffer_offline_receipt extended with inbound_commit_batch.is_active() guard so receipts continue buffering during the deferred drain-to-live window.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant RL as ReadLoop
    participant PCM as process_classified_message
    participant ICB as InboundCommitBatcher
    participant BE as Backend
    participant SC as SignalCache
    participant Hook as DurabilityHook
    participant Bus as EventBus

    Note over RL,Bus: Offline drain (single permit, batching active)
    RL->>PCM: acquire permit, decrypt
    PCM->>ICB: commit_or_batch_inbound(entry)
    ICB->>ICB: enqueue + arm 3s timer
    PCM->>ICB: maybe_flush_inbound_commits if over limit

    Note over RL,Bus: End-of-drain finisher spawned off read loop
    RL->>ICB: finish_inbound_commit_drain(generation)
    ICB->>ICB: take batch, bump epoch
    ICB->>BE: store_pending_inbound_batch one txn
    alt store succeeds
        ICB->>SC: flush_signal_cache bulk
        ICB->>ICB: disarm ReinsertGuard
        ICB->>Hook: on_messages(batch)
        ICB->>BE: delete_pending_inbound_batch
        ICB->>PCM: ack each message
        ICB->>Bus: Event::Messages(OfflineDrain)
        ICB->>ICB: deactivate + swap semaphore to 64
        ICB->>ICB: flush_offline_receipts
    else store fails
        ICB->>ICB: ReinsertGuard restores entries
        ICB->>ICB: defer_live_transition + arm retry loop
    end

    Note over RL,Bus: Live path (64 permits, immediate batch-of-one)
    PCM->>ICB: "commit_or_batch_inbound is_active=false"
    ICB->>BE: store 1 row
    ICB->>Hook: on_messages batch-of-one
    ICB->>BE: delete 1 row
    ICB->>PCM: ack
    ICB->>Bus: Event::Messages(Live)
    PCM->>SC: flush_signal_cache_logged per-stanza
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 RL as ReadLoop
    participant PCM as process_classified_message
    participant ICB as InboundCommitBatcher
    participant BE as Backend
    participant SC as SignalCache
    participant Hook as DurabilityHook
    participant Bus as EventBus

    Note over RL,Bus: Offline drain (single permit, batching active)
    RL->>PCM: acquire permit, decrypt
    PCM->>ICB: commit_or_batch_inbound(entry)
    ICB->>ICB: enqueue + arm 3s timer
    PCM->>ICB: maybe_flush_inbound_commits if over limit

    Note over RL,Bus: End-of-drain finisher spawned off read loop
    RL->>ICB: finish_inbound_commit_drain(generation)
    ICB->>ICB: take batch, bump epoch
    ICB->>BE: store_pending_inbound_batch one txn
    alt store succeeds
        ICB->>SC: flush_signal_cache bulk
        ICB->>ICB: disarm ReinsertGuard
        ICB->>Hook: on_messages(batch)
        ICB->>BE: delete_pending_inbound_batch
        ICB->>PCM: ack each message
        ICB->>Bus: Event::Messages(OfflineDrain)
        ICB->>ICB: deactivate + swap semaphore to 64
        ICB->>ICB: flush_offline_receipts
    else store fails
        ICB->>ICB: ReinsertGuard restores entries
        ICB->>ICB: defer_live_transition + arm retry loop
    end

    Note over RL,Bus: Live path (64 permits, immediate batch-of-one)
    PCM->>ICB: "commit_or_batch_inbound is_active=false"
    ICB->>BE: store 1 row
    ICB->>Hook: on_messages batch-of-one
    ICB->>BE: delete 1 row
    ICB->>PCM: ack
    ICB->>Bus: Event::Messages(Live)
    PCM->>SC: flush_signal_cache_logged per-stanza
Loading

Reviews (14): Last reviewed commit: "fix(recv): fail closed on active drain w..." | Re-trigger Greptile

Comment thread src/message/commit_batch.rs Outdated
Comment thread src/message/receive.rs Outdated
Comment thread src/message/commit_batch.rs Outdated
Comment thread storages/sqlite-storage/src/sqlite_store.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.

1 issue found across 1 file (changes from recent commits).

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/bot.rs">

<violation number="1" location="src/bot.rs:693">
P2: Batch fan-out now calls `handler(...)` for the entire batch before awaiting, so any synchronous side effect or panic during future construction changes per-message sequencing and can drop already-built earlier futures without running them. Preserving call+await per message keeps the documented in-order semantics and avoids this eager-construction edge case.</violation>
</file>

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

Re-trigger cubic

Comment thread src/bot.rs

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

Caution

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

⚠️ Outside diff range comments (2)
tests/e2e/tests/session_reuse.rs (1)

9-33: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Flatten the nested if let into a let-chain.

record is only used to reach state; this collapses into one condition per our lint policy on nested if let.

♻️ Proposed let-chain refactor
-        if let Some(data) = backend.get_session(&addr).await? {
-            let record = SessionRecord::deserialize(&data)?;
-            if let Some(state) = record.session_state() {
-                let has_pending = state
-                    .unacknowledged_pre_key_message_items()
-                    .map_err(|e| anyhow::anyhow!("invalid session state: {e}"))?
-                    .is_some();
-                results.push((addr, has_pending));
-            }
-        }
+        if let Some(data) = backend.get_session(&addr).await?
+            && let Some(state) = SessionRecord::deserialize(&data)?.session_state()
+        {
+            let has_pending = state
+                .unacknowledged_pre_key_message_items()
+                .map_err(|e| anyhow::anyhow!("invalid session state: {e}"))?
+                .is_some();
+            results.push((addr, has_pending));
+        }
🤖 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 `@tests/e2e/tests/session_reuse.rs` around lines 9 - 33, The nested conditional
in scan_sessions should be collapsed into a single let-chain to satisfy the lint
policy. Refactor the backend.get_session, SessionRecord::deserialize, and
record.session_state checks into one combined condition, then keep the
has_pending calculation and results.push inside that branch.

Source: Coding guidelines

src/client/lifecycle.rs (1)

580-591: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Bound the inbound drain wait
flush_inbound_commits_acquiring_permit() has no internal timeout: it waits on Semaphore::acquire_arc().await and then commit_inbound_batch().await. If either stalls, disconnect(), reconnect(), and reconnect_immediately() can block forever. Apply the same timeout pattern used for outbound_flush.flush() here too.

🤖 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/client/lifecycle.rs` around lines 580 - 591, The inbound drain path in
`disconnect()` can block forever because
`flush_inbound_commits_acquiring_permit()` waits indefinitely on permit
acquisition and commit completion. Add the same bounded-wait timeout pattern
used by `outbound_flush.flush()` around this call in `lifecycle.rs`, and make
sure `disconnect()`, `reconnect()`, and `reconnect_immediately()` continue after
the timeout instead of hanging.
🤖 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/client/sessions.rs`:
- Around line 39-48: The `self_weak.get().and_then(|w| w.upgrade())` check in
the session commit path can silently skip
`flush_inbound_commits_acquiring_permit()` when the weak reference is gone,
which breaks the invariant described in this block. Update this branch in
`src/client/sessions.rs` to log a clear warning or error when `upgrade()`
returns `None`, using the surrounding session/commit context so the failure is
visible. Keep the existing `swap_message_semaphore(64)` and
`flush_offline_receipts()` flow intact, but ensure the `None` path is explicitly
reported rather than failing silently.

In `@src/message/commit_batch.rs`:
- Around line 144-156: The generation-check semaphore acquire loop is duplicated
between flush_inbound_commits_acquiring_permit and process_classified_message,
so extract it into a shared helper on Client such as acquire_message_permit.
Move the read_message_semaphore / acquire_arc / generation-compare retry logic
into that method, then update both call sites to await the helper and keep any
call-site-specific logging separate. This keeps the offline→online semaphore
swap guard consistent in one place and prevents the two code paths from
drifting.
- Around line 194-236: The `arena` mutex guard in `commit_batch` is held longer
than necessary, blocking unrelated inbound commits on the shared `Client` hot
path. After `store_pending_inbound_batch` succeeds and `rows` is dropped,
explicitly release the `arena` lock before calling `flush_signal_cache_logged`,
`hook.on_messages`, and any later cleanup so the encode arena is not held during
slow durability/hook work.

In `@src/message/receive.rs`:
- Around line 531-544: The SKDM-only fallback currently bypasses the
offline-drain flush order by calling ack logic before the Signal cache is
durably written. Update should_ack_skdm_only_session_fallback so it is handled
through commit_or_batch_inbound, or otherwise gate ack_received_message on the
same flush path used in receive.rs to ensure ratchet advancement only happens
after the cache flush completes.

In `@tests/e2e/tests/app_state.rs`:
- Around line 213-228: The message lookup in the app state test is scanning the
same event batch twice, once in the `wait_for_event` predicate and again with
`event.messages().find(...)` to extract the id. Update the logic around
`client_b.wait_for_event` and the subsequent `msg_id` assignment to derive the
matching message and its `info.id` in a single pass, ideally using `find_map`
over `event.messages()` so the code only traverses the batch once.

In `@tests/e2e/tests/media.rs`:
- Around line 413-421: The media E2E tests repeat the same two-step pattern of
waiting for an event and then searching the messages again with the same
predicate. Add a reusable helper such as wait_for_message on TestClient (or a
free function) that wraps wait_for_event plus the follow-up find, returns the
matched InboundMessage directly, and update the repeated call sites in media.rs
to use it so the matching logic lives in one place.

In `@wacore/src/types/events.rs`:
- Around line 846-849: The message_text() helper on Event::Messages is only
returning the first conversation value from messages(), which can silently drop
additional texts in batched offline-drain events. Update the Events API so
callers use an iterator-style accessor over all messages, or make message_text()
explicitly only succeed for single-message batches and avoid partial processing.
Use the existing messages() method and the Event::Messages path to locate the
change.

---

Outside diff comments:
In `@src/client/lifecycle.rs`:
- Around line 580-591: The inbound drain path in `disconnect()` can block
forever because `flush_inbound_commits_acquiring_permit()` waits indefinitely on
permit acquisition and commit completion. Add the same bounded-wait timeout
pattern used by `outbound_flush.flush()` around this call in `lifecycle.rs`, and
make sure `disconnect()`, `reconnect()`, and `reconnect_immediately()` continue
after the timeout instead of hanging.

In `@tests/e2e/tests/session_reuse.rs`:
- Around line 9-33: The nested conditional in scan_sessions should be collapsed
into a single let-chain to satisfy the lint policy. Refactor the
backend.get_session, SessionRecord::deserialize, and record.session_state checks
into one combined condition, then keep the has_pending calculation and
results.push inside that branch.
🪄 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: 7d810c67-69bb-4f58-b2a4-f062133aefa4

📥 Commits

Reviewing files that changed from the base of the PR and between ec3a12a and e0dc4a4.

📒 Files selected for processing (35)
  • agent_docs/e2e_testing.md
  • examples/benchmark.rs
  • examples/durability_hook.rs
  • src/bot.rs
  • src/client.rs
  • src/client/lifecycle.rs
  • src/client/sessions.rs
  • src/handlers/ib.rs
  • src/message.rs
  • src/message/commit_batch.rs
  • src/message/dispatch.rs
  • src/message/durability.rs
  • src/message/receive.rs
  • src/message/tests.rs
  • src/pdo.rs
  • src/reexports_test.rs
  • src/send/mod.rs
  • src/test_utils.rs
  • src/types/durability_hook.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/app_state.rs
  • tests/e2e/tests/concurrent_disconnect.rs
  • tests/e2e/tests/groups.rs
  • tests/e2e/tests/media.rs
  • tests/e2e/tests/memory_soak.rs
  • tests/e2e/tests/messaging.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/offline_messages.rs
  • tests/e2e/tests/prekey_sessions.rs
  • tests/e2e/tests/privacy_tokens.rs
  • tests/e2e/tests/receipts.rs
  • tests/e2e/tests/session_reuse.rs
  • wacore/src/store/traits.rs
  • wacore/src/types/events.rs

Comment thread src/client/sessions.rs Outdated
Comment thread src/message/commit_batch.rs Outdated
Comment thread src/message/commit_batch.rs Outdated
Comment thread src/message/receive.rs
Comment thread tests/e2e/tests/app_state.rs
Comment thread tests/e2e/tests/media.rs
Comment thread wacore/src/types/events.rs Outdated
…/codex/greptile/coderabbit

The drain/live gate moved off offline_sync_completed onto a dedicated
batcher-owned `active` state that only the end-of-drain flush flips,
while holding the single processing permit. Gating on the flag was racy
in two confirmed ways: an in-flight stanza could enqueue pre-flip and
then take the live per-stanza Signal flush at stanza end, persisting
ratchet state for an uncommitted batch entry (crash => unrecoverable
duplicate, cubic P1); and stanzas queued behind the permit could read
the flipped flag at dispatch and commit as Live ahead of the still
accumulated batch, inverting arrival order at the drain tail (codex
P1). finish_inbound_commit_drain commits the tail and deactivates under
the permit, so no stanza straddles the transition and queued stanzas
provably commit after the tail. Regression test locks the sequence.

Also from review:
- Acks are sent before the event dispatch (all durability done by
  then): handle_event runs synchronously, so a panicking or blocking
  handler must not suppress acks for messages the consumer already
  owns — matches the old at-most-once ordering (codex).
- The encode buffer is scoped to the durable write only and the live
  path uses a local buffer instead of the shared arena, so concurrent
  live commits never serialize behind a slow hook (greptile,
  coderabbit).
- store_pending_inbound_batch uses multi-row VALUES inserts (chunked at
  100 rows); deletes stay per-row inside the one transaction because
  Diesel's DSL cannot express a composite-key tuple IN (greptile).
- The stale-permit re-acquire loop is one shared helper,
  acquire_message_processing_permit (cubic, coderabbit).
- Teardown flushes are bounded (5s disconnect / 2s reconnect, like the
  outbound flush): a hung hook cannot wedge disconnect; entries stay
  unacked for redelivery (coderabbit).
- complete_offline_sync logs loudly if the self_weak upgrade ever
  fails instead of silently skipping the tail commit (coderabbit).
- Event::message_texts() iterator; message_text() documented as
  first-text convenience (coderabbit).
- Test clients mirror full live state (flag, 64 permits, batcher live);
  batch example dedups within the batch; on_message documents the
  eager future construction wasm32 requires (cubic).

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66ac89ae54

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client/lifecycle.rs Outdated
Comment thread src/message/durability.rs Outdated
@coderabbitai coderabbitai Bot removed the performance label Jul 3, 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.

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

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/bot.rs">

<violation number="1" location="src/bot.rs:693">
P2: Batch fan-out now calls `handler(...)` for the entire batch before awaiting, so any synchronous side effect or panic during future construction changes per-message sequencing and can drop already-built earlier futures without running them. Preserving call+await per message keeps the documented in-order semantics and avoids this eager-construction edge case.</violation>
</file>

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

Re-trigger cubic

Comment thread src/client/lifecycle.rs Outdated
claude added 2 commits July 3, 2026 04:31
…atch replayed messages

Two Codex findings on the review round, both real:

- cleanup_connection_state flushed the Signal cache and then reset the
  batcher, so an unexpected read-loop exit mid-drain persisted ratchet
  advances for entries it was about to drop — their redelivery would
  decrypt as a duplicate with no buffered copy and get acked without
  delivery. The cleanup now commits the batch (bounded, 5s) before that
  flush; acks/events are best-effort with the socket gone, but the
  durable hook commit is what prevents the loss.

- A message whose hook only succeeds on redelivery replay never emitted
  Event::Messages (its original batch aborted before dispatch). The
  replay success path now dispatches the single-message batch after the
  ack, so consumers see it exactly once; test extended to lock this.

Also: live batches build their Arc<[InboundMessage]> slice directly
instead of round-tripping through a Vec — measured 2.0 -> 1.0
allocations and ~18ns (-24%) for that step per live message;
commit_inbound_batch now takes the Arc slice, which the event reuses
without conversion.
66ac89a's message claimed this reorder but the patch hunk had silently
failed to apply; the code still dispatched Event::Messages before
sending acks (and with a redundant Arc conversion, caught by clippy's
useless_conversion). Apply it for real: everything is durable by the
time either happens, and acking first means a panicking or blocking
synchronous handler cannot suppress acks for messages the consumer
already owns — the ordering the pre-batch at-most-once path had.
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

@jlucaso1 Got it. This batched commit architecture needs to actually work correctly — durability ordering, permit handling during the drain-to-live transition, all of it. Kicking off the review now to make sure nothing's out of order.

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

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

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

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

Re-trigger cubic

Comment thread src/message/commit_batch.rs
Comment thread src/message/durability.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c9bee768c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/message/receive.rs
Comment thread src/client/sessions.rs Outdated
…p .text in budget

- An SKDM-only drain stanza mutates Signal state without enqueueing a
  message, and its buffered receipt flushes at drain end / teardown —
  right after the batcher flush call sites. The empty-batch early
  return I added with the Arc refactor silently dropped the "flush
  Signal even when there is nothing to commit" guarantee, so a crash
  after the receipt flush could lose an acked sender key. Empty drain
  flushes now persist the Signal cache before returning (codex).

- commit_inbound_batch's doc said event -> acks while the code acks
  first; the doc now states the ack -> event contract and why (a crash
  between them trades exactly like the old at-most-once path: the
  consumer's durable copy is the hook commit, not the event) (cubic).

- A replayed message now always dispatches with BatchOrigin::Live, and
  BatchOrigin is documented as the delivery shape (immediate vs
  accumulated drain batch) — a stanza's provenance is info.is_offline.
  Inferring origin from the batcher's current state could mislabel
  replays (cubic).

- complete_offline_sync documents why flag-gated waiters may unblock
  during the tail commit (same window in-flight stanzas always had;
  OfflineSyncCompleted still dispatches only after the commit).

- Revert the multi-row VALUES insert to per-row statements inside the
  single batch transaction: the WAL commit is the amortized cost either
  way, and the multi-row form added ~4 KiB of monomorphized .text
  against a 32 KiB per-PR budget currently at 31.9 KiB.

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

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

Re-trigger cubic

Comment thread src/message/commit_batch.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dcdc148c39

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/message/durability.rs Outdated
During a drain, a buffered redelivery used to commit and dispatch
immediately while earlier freshly-decrypted stanzas were still
accumulating in the batcher, so consumers could observe the replayed
message ahead of its predecessors (codex). The replay's Ok branch now
just calls commit_or_batch_inbound: in drain mode it joins the
accumulating batch (arrival order preserved, its pending row rewritten
and cleared by the batch commit), live it commits as a batch of one —
which is store -> hook -> delete -> ack -> event, exactly what the
hand-rolled branch did, so 30 lines fold away.

This also supersedes dcdc148's origin change for replays: that hunk
had silently failed to apply (same failure mode as the one confessed
in 9c9bee7 — patch scripts now assert), and the batcher routing makes
it moot: a drain replay is genuinely part of an OfflineDrain batch and
a live replay genuinely a Live one.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1d0019190

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/message/commit_batch.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.

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

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

@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/client/lifecycle.rs`:
- Around line 755-759: The self_weak.upgrade() failure in the inbound commit
flush path is currently silent, unlike the matching handling in
complete_offline_sync. Update the lifecycle logic around the
self_weak.get().and_then(|w| w.upgrade()) check in this flush section to emit a
log::error! when the upgrade returns None, and keep the same descriptive context
used for the other failure path so both code paths report the same class of bug
consistently.
🪄 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: 1ba51884-9f0e-4ddf-88ca-d8585123a9a0

📥 Commits

Reviewing files that changed from the base of the PR and between 66ac89a and d1d0019.

📒 Files selected for processing (6)
  • src/client/lifecycle.rs
  • src/client/sessions.rs
  • src/message/commit_batch.rs
  • src/message/durability.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/types/events.rs

Comment thread src/client/lifecycle.rs Outdated
… identity path single-permit

Two stale-state windows around awaited drain commits:

- The finisher's generation check ran only before the take. A teardown
  that times out around the awaited commit (slow hook) resets the
  batcher, and the resuming finisher then deactivated the NEW
  connection's drain. The generation is now re-checked after the await,
  before any mode mutation; the commit itself stays sound (entries taken
  pre-reset, rows durable).
- commit_inbound_batch_holding_permit no longer completes a pending
  deferred transition: the caller (UntrustedIdentity recovery) holds a
  permit from the old single-permit semaphore and follows up with a raw
  whole-cache flush, which is only safe while that permit excludes every
  other stanza — widening to 64 first would let new workers be
  mid-decrypt under the flush. The deferred-retry loop completes the
  transition moments later, outside any raw-flush window.

@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: This is a high-impact refactor of the message processing pipeline that changes core business logic, including breaking API changes to events, hooks, and data structures. Even with no linting issues, such changes need human review to evaluate architectural correctness and potential regressions.

Re-trigger cubic

The comment still described an inline generation-reacquire loop, but that
logic moved into acquire_message_processing_permit; trim it to reference
the helper while keeping the SKDM-loss rationale for why the 1→N
re-acquire matters.

@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: This PR makes a substantial architectural change to inbound message batching during offline drain, affecting multiple core components, public API, and concurrency logic. The high risk of breakage and complexity necessitates human review.

Re-trigger cubic

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 68358c4fc6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/message/commit_batch.rs
The timeout branch deferred the cache decision to the batcher reset, but
reset only clears when it drops entries — so a worker hung mid-decrypt
(holding the permit the settle timed out waiting for) that advanced a
ratchet WITHOUT enqueueing an entry (SKDM-only) left rowless state the
reset could not see, and a later flush would persist it into an
acked-duplicate loss.

Clear unconditionally on timeout instead: it does not sample
has_entries() (which races that permit holder — the concern from the
earlier round), and it is the only rowless-safe action when a stuck
worker may hold unenqueued advances. The common hook-timeout case is
already safe (the ReinsertGuard restored the entries), so this only
changes the rare hung-worker corner. The sole thing it can drop is
committed state a prior failed flush retained across a total storage
outage — a degraded corner where redeliver-on-reauth beats a silent
rowless duplicate.

@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: Major refactor of inbound message processing pipeline with breaking API changes, affecting core business logic, durability hooks, and event handling. High risk of regressions despite automated reviews.

Re-trigger cubic

No production consumers yet, so shape the new batch API properly instead
of carrying the accreted form:

- Rename Event::message_batch() → Event::as_messages(): the idiomatic
  as_ prefix for a &self -> Option<&T> accessor, and it now tiers
  clearly against messages() (the flattening iterator) instead of two
  near-synonym names.
- Make MessageBatch a first-class collection: iter/len/is_empty/first
  and IntoIterator for &MessageBatch, so `for m in &batch`, batch.iter(),
  batch.len() work without reaching through the .messages field. origin
  stays alongside as the delivery-shape metadata.
- messages() is reimplemented on top of as_messages(); it stays because
  `event.messages().any(...)` / `for m in event.messages()` is the
  dominant consumer verb and reads better than the derived form.

Callers migrated; the batcher tests and benchmark example dogfood the
collection methods.

@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 4 files (changes from recent commits).

Requires human review: Major refactor of inbound message pipeline: batches commits, changes event types, modifies durability hook API, and updates concurrency model. High risk of regressions in message processing, Signal state, and durability.

Re-trigger cubic

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 19bef0e35b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client/sessions.rs

@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/client/lifecycle.rs`:
- Around line 815-825: The teardown path in lifecycle handling silently drops
uncommitted inbound commit entries when `inbound_commit_batch.reset()` returns
true, unlike the `connect_graph` path that already logs a warning for the same
condition. Update this branch to emit a warning or error with clear context
before calling `signal_cache.clear().await`, using the same symbols
(`inbound_commit_batch.reset`, `signal_cache.clear`, and the surrounding
teardown logic) so unexpected redelivery-triggering drops are visible in logs.
🪄 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: 019ae790-28f5-4205-8249-7eb43ec5426c

📥 Commits

Reviewing files that changed from the base of the PR and between d1d0019 and 19bef0e.

📒 Files selected for processing (30)
  • examples/benchmark.rs
  • src/client.rs
  • src/client/adapters.rs
  • src/client/device_registry.rs
  • src/client/lifecycle.rs
  • src/client/sender_keys.rs
  • src/client/sessions.rs
  • src/client/tests.rs
  • src/features/message_edit.rs
  • src/features/signal.rs
  • src/handlers/message.rs
  • src/handlers/notification/device.rs
  • src/lib.rs
  • src/message/commit_batch.rs
  • src/message/dispatch.rs
  • src/message/durability.rs
  • src/message/receive.rs
  • src/message/tests.rs
  • src/msg_secret_buffer.rs
  • src/receipt.rs
  • src/retry.rs
  • src/send/mod.rs
  • src/test_utils.rs
  • src/types/durability_hook.rs
  • src/voip/facade.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/receipts.rs
  • wacore/src/types/events.rs

Comment thread src/client/lifecycle.rs
flush_signal_cache_batch_safe entered on an is_active() check that the
drain finisher can invalidate before the wrapper acquired the permit:
the under-permit commit then took an empty batch in live mode and
reported durable WITHOUT flushing the caller's out-of-band Signal
advance, leaving it dirty until the next flush. Re-check is_active()
after the commit and fall through to the raw flush when the batcher
deactivated mid-wait — the same path a caller that found it already
inactive takes. Covered by a deferred-transition test driven through
the wrapper.

Also log the teardown branch that drops uncommitted drain entries and
clears the Signal cache, matching the connect-side twin and the
upgrade-failure path so redelivery-triggering drops are visible.
@coderabbitai coderabbitai Bot removed the api-design label Jul 3, 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: 3

🤖 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/client/adapters.rs`:
- Around line 124-135: The active-empty path in
`flush_inbound_commits_under_permit`/`commit` can return durable without
actually flushing Signal state, so `is_active()` alone is not a safe success
check. Update `src/client/adapters.rs` so the logic around
`inbound_commit_batch.is_active()` also reflects whether Signal was really
flushed: either have `flush_inbound_commits_under_permit(false, None, None)`
return a flag indicating an actual Signal flush, or explicitly perform the
under-permit Signal flush before returning `Ok(())` when the batcher is still
active.
- Around line 114-136: The nested `if` plus `if let` in `src/client/adapters.rs`
should be collapsed into a single let-chain to satisfy the repo’s Clippy rule.
Refactor the `inbound_commit_batch.is_active()` branch in the adapter method so
the `self.self_weak.get().and_then(|w| w.upgrade())` check is combined with the
outer condition using a let-chain, while preserving the existing
`flush_inbound_commits_under_permit` flow and the `has_entries()` fallback path.

In `@src/message/commit_batch.rs`:
- Around line 1204-1210: The test around flush_signal_cache_batch_safe currently
only verifies the deferred transition completes and can still pass if the final
raw flush is skipped. Update the test to make the raw flush observable by using
an injected raw-flush failure or a flush counter on client/flush_signal_cache so
that flush_signal_cache_batch_safe must actually reach flush_signal_cache().
Keep the assertion tied to the existing batch-safe path in commit_batch.rs so
the regression is caught if the raw flush is omitted.
🪄 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: ae399dd5-f0e3-4aa7-a3eb-746989db384d

📥 Commits

Reviewing files that changed from the base of the PR and between 19bef0e and 99fe8c7.

📒 Files selected for processing (3)
  • src/client/adapters.rs
  • src/client/lifecycle.rs
  • src/message/commit_batch.rs

Comment thread src/client/adapters.rs Outdated
Comment thread src/client/adapters.rs Outdated
Comment thread src/message/commit_batch.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.

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

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/message/commit_batch.rs">

<violation number="1" location="src/message/commit_batch.rs:1179">
P3: This test validates deferred-transition completion, but it does not verify that the final raw `flush_signal_cache()` path executed. A regression that commits `B1` and returns before that raw flush would still pass these assertions, so adding an observable flush assertion would close the gap.</violation>
</file>

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

Re-trigger cubic

Comment thread src/message/commit_batch.rs
… recheck

The batch-safe flush proved durability with a post-commit is_active()
recheck, which is not a reliable proxy for whether the Signal cache was
actually flushed. Instead make flush_inbound_commits_under_permit's empty
path flush the cache unconditionally (it already did while draining; now
also when the batcher went live between the caller's is_active() check
and the permit). That makes "cache flushed under the permit" an
unconditional post-condition, so the wrapper drops the racy recheck and
collapses to a let-chain.

Add a deterministic test (fail_flushes injection) proving the empty
commit still flushes — the pre-fix empty+live path returned durable
without flushing, so it now guards that regression observably.

@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/client/adapters.rs`:
- Around line 119-134: Fail closed whenever drain is active and the weak self
reference cannot upgrade, instead of falling through to the plain Signal flush
path. Update the logic in the adapter’s drain-handling branch around
`drain_active`, `self_weak.get()`, and `flush_inbound_commits_under_permit` so
that any active drain without a live client returns an error immediately,
regardless of `inbound_commit_batch.has_entries()`, preventing
`flush_signal_cache()` from running in that case.
🪄 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: 53970f28-2154-4a8f-bab8-5506a5d26203

📥 Commits

Reviewing files that changed from the base of the PR and between 99fe8c7 and d82ccea.

📒 Files selected for processing (2)
  • src/client/adapters.rs
  • src/message/commit_batch.rs

Comment thread src/client/adapters.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.

1 issue found 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/message/commit_batch.rs
…le doc

The batch-safe flush's upgrade-failure fallback gated on has_entries()
before falling through to a raw flush. But an empty drain can still hold
dirty SKDM-only Signal advances with no rows, so that raw flush could
persist them rowless — the loss this path exists to prevent. Fail closed
for any active drain when the weak self ref can't upgrade (practically
unreachable; the run loop holds a strong Arc) so the cache stays
unflushed and the server redelivers.

Also refresh the flush_inbound_commits_under_permit doc: an empty batch
is no longer a no-op — it still flushes the Signal cache under the permit
(idempotent when clean).

@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 2 files (changes from recent commits).

Requires human review: Major refactor of inbound commit pipeline with new batching module, breaking changes to event types and the durability hook trait, and complex concurrency logic across the drain lifecycle. Requires human review of correctness and safety invariants.

Re-trigger cubic

@jlucaso1
jlucaso1 merged commit eb91ac1 into main Jul 3, 2026
18 checks passed
@jlucaso1
jlucaso1 deleted the claude/whatsapp-rust-allocator-api-prw9c4 branch July 3, 2026 16:43

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b672d6904

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


client
.flush_signal_cache_logged("identity change", None)
.flush_signal_cache_batch_safe_logged("identity change", None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep read-loop notifications out of drain batch commits

When an identity-change notification arrives while the offline drain is active, this call can route into flush_inbound_commits_under_permit and commit the accumulated inbound batch, including awaiting the user durability hook, while the stanza/router read loop is still inside this handler. If that hook waits on any client request that needs a server response (or is just slow), the read loop is parked and cannot process the response/pongs; this is the same deadlock class the offline finisher avoids by running off-loop. Defer this batch-safe flush to a spawned task or avoid committing drain batches from read-loop handlers.

Useful? React with 👍 / 👎.

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

Labels

api-design breaking-change size-increase-ok Accepted binary-size increase: downgrades the per-PR size gate to a warning

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants