Skip to content

feat: batch write store - #281

Closed
jlucaso1 wants to merge 2 commits into
mainfrom
perf-batch-write-store
Closed

feat: batch write store#281
jlucaso1 wants to merge 2 commits into
mainfrom
perf-batch-write-store

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Feb 13, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Improved connection lifecycle monitoring for faster detection of closed links.
    • More robust per-device group encryption with automatic prekey fetching and retries.
  • Bug Fixes

    • Faster failover on transport send failures to avoid stuck sends.
    • More reliable identity key handling to reduce spurious trust/identity issues.
  • Performance

    • Encryption/store operations batched and cached to reduce I/O and latency.
  • Changes

    • Receipt handling/logging downgraded — receipts are now ignored or logged at debug level.

@coderabbitai

coderabbitai Bot commented Feb 13, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds client connection liveness and weak self-references, replaces SignalProtocol adapter with a batched/cached variant and blocking crypto tasks, introduces terminal transport-send error handling, refactors group encryption to SenderKeyName flow, downgrades receipt logging, and extends benchmark stores with a backend-serialized mode.

Changes

Cohort / File(s) Summary
Client Connection State
src/client.rs
Added self_weak: OnceLock<Weak<Client>>, connection_alive: Arc<AtomicBool>, and pub(crate) fn shared(&self) -> Option<Arc<Self>>. Connection liveness toggled in connect/disconnect/cleanup and on terminal send errors; added helpers to detect terminal transport/send errors.
Signal Protocol Storage Layer
src/store/signal_adapter.rs, src/store/signal.rs
Introduced BatchedSignalProtocolStoreAdapter with CachedSessionAdapter, CachedIdentityAdapter, CachedSenderKeyAdapter and BatchWriteCache. Added flush() and invalidate_*() APIs and strict identity key handling.
Message Decryption
src/message.rs
Switched to BatchedSignalProtocolStoreAdapter. Added decrypt_session_message_with_blocking() to run decryption on blocking tasks; propagate updated adapter and call invalidate_identity/invalidate_session and flush() where needed.
Message Encryption & Send Paths
src/send.rs, wacore/src/send.rs
Replaced adapter with batched variant and moved encryption/prep into spawn_blocking for peer/group/DM paths. Added explicit flush() calls and error handling. In wacore, removed encrypt_group_message and added per-recipient two-phase group encryption with build_participant_enc_node() and LID/PN normalization, using SenderKeyName/group_encrypt.
Transport Layer
transports/tokio-transport/src/lib.rs, wacore/src/net.rs
Added TransportSendError::ConnectionClosed and changed transport send to fast-fail, null sink, and mark connection closed on send failures.
Event & Receipt Handling
src/main.rs, src/receipt.rs
Removed Event::Receipt handling from main event match; downgraded receipt-type logging from info! to debug!.
Benchmark Infrastructure
wacore/libsignal/benches/libsignal_benchmark.rs
Added BenchmarkStoreMode (InMemory
Misc & Tests
src/client.rs, tests
Added helpers to detect terminal transport/send errors and unit tests verifying terminal vs non-terminal transport-send error detection.

Sequence Diagram(s)

(omitted — changes are across many areas but do not introduce a single new multi-component sequential flow suitable for a concise diagram)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰
With weak refs tucked and atomics bright,
I batch my stores and hop through night,
Blocking calls to keep things tight,
Groups now sing with SenderKey light,
Liveness true — a rabbit's delight. 🥕✨

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.37% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: batch write store' directly and concisely describes the main feature introduced across the changeset: a batch-write caching layer for signal protocol data.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch perf-batch-write-store

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 and usage tips.

@jlucaso1
jlucaso1 force-pushed the perf-batch-write-store branch from aa67874 to a0d4ec2 Compare February 13, 2026 02:04
@github-actions

github-actions Bot commented Feb 13, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchperf-batch-write-store
Testbedubuntu-latest

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
binary_benchmark::unpack_group::bench_unpack_compressedInstructions
instructions x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
555.99 x 1e3
(+7.36%)Baseline: 517.89 x 1e3
543.78 x 1e3
(102.24%)

Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
6,124.00
(-14.95%)Baseline: 7,200.30
7,560.31
(81.00%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
850,819.00
(+0.01%)Baseline: 850,749.48
893,286.96
(95.25%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
22,212.00
(-9.74%)Baseline: 24,609.81
25,840.30
(85.96%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
119,220.00
(-12.21%)Baseline: 135,802.38
142,592.50
(83.61%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
119,248.00
(0.00%)Baseline: 119,248.00
125,210.40
(95.24%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
534,027.00
(0.00%)Baseline: 534,027.00
560,728.35
(95.24%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
17,350.00
(0.00%)Baseline: 17,350.00
18,217.50
(95.24%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
17,136,927.00
(0.00%)Baseline: 17,136,927.00
17,993,773.35
(95.24%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
176,721.00
(0.00%)Baseline: 176,721.00
185,557.05
(95.24%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
535,440.00
(0.00%)Baseline: 535,440.00
562,212.00
(95.24%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
19,404.00
(0.00%)Baseline: 19,404.00
20,374.20
(95.24%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
42,772,453.00
(0.00%)Baseline: 42,772,453.00
44,911,075.65
(95.24%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
534,466.00
(0.00%)Baseline: 534,466.00
561,189.30
(95.24%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
17,323.00
(-10.55%)Baseline: 19,366.33
20,334.65
(85.19%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
17,137,747.00
(0.00%)Baseline: 17,137,747.00
17,994,634.35
(95.24%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
129,121.00
(-5.21%)Baseline: 136,215.70
143,026.49
(90.28%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
119,320.00
(0.00%)Baseline: 119,320.00
125,286.00
(95.24%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
93,707.00
(-11.34%)Baseline: 105,688.11
110,972.52
(84.44%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,249.00
(-13.99%)Baseline: 8,427.69
8,849.07
(81.92%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
93,738.00
(0.00%)Baseline: 93,738.00
98,424.90
(95.24%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,272.00
(0.00%)Baseline: 7,272.00
7,635.60
(95.24%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
109,523.00
(0.00%)Baseline: 109,523.00
114,999.15
(95.24%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,784.00
(0.00%)Baseline: 8,784.00
9,223.20
(95.24%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
44,794.00
(-9.10%)Baseline: 49,277.29
51,741.15
(86.57%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,588.00
(-16.99%)Baseline: 3,117.77
3,273.65
(79.06%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
🚨 view alert (🔔)
555,986.00
(+7.36%)Baseline: 517,886.63
543,780.96
(102.24%)

binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.86%)Baseline: 777.65
816.53
(94.42%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,762,652.00
(+0.01%)Baseline: 27,759,917.16
29,147,913.02
(95.25%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,541,863.00
(-0.39%)Baseline: 5,563,322.54
5,841,488.67
(94.87%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
179,429.00
(+0.81%)Baseline: 177,993.37
186,893.03
(96.01%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
180,385.00
(+0.88%)Baseline: 178,807.46
187,747.83
(96.08%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,421,406.00
(+0.77%)Baseline: 17,288,013.21
18,152,413.87
(95.97%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
296,317.00
(+0.17%)Baseline: 295,810.94
310,601.48
(95.40%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,513,551.00
(-0.61%)Baseline: 12,590,237.49
13,219,749.37
(94.66%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
716,465.00
(+0.08%)Baseline: 715,870.90
751,664.45
(95.32%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
42,961.00
(+2.74%)Baseline: 41,815.02
43,905.77
(97.85%)
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,842.00
(+0.00%)Baseline: 15,561,387.14
16,339,456.50
(95.24%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,517,265.00
(-0.12%)Baseline: 5,523,713.52
5,799,899.20
(95.13%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
960,263.00
(-0.31%)Baseline: 963,224.05
1,011,385.25
(94.95%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,822,769.00
(-0.01%)Baseline: 2,823,179.09
2,964,338.04
(95.22%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,444,364.00
(-3.21%)Baseline: 3,558,772.89
3,736,711.53
(92.18%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
123,491,755.00
(-1.54%)Baseline: 125,424,730.07
131,695,966.57
(93.77%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
11,665.00
(-1.05%)Baseline: 11,789.35
12,378.82
(94.23%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,774.00
(-1.28%)Baseline: 3,823.11
4,014.26
(94.01%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,777.00
(-0.23%)Baseline: 87,976.41
92,375.24
(95.02%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,909.00
(-0.16%)Baseline: 80,033.17
84,034.83
(95.09%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
51,010.00
(-0.04%)Baseline: 51,031.01
53,582.56
(95.20%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,749.00
(+0.25%)Baseline: 5,734.68
6,021.41
(95.48%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,109.00
(-0.30%)Baseline: 2,115.24
2,221.01
(94.96%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(+0.04%)Baseline: 21,911.59
23,007.16
(95.27%)
🐰 View full continuous benchmarking report in Bencher

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

Caution

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

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

349-365: ⚠️ Potential issue | 🟠 Major

Group stanza encryption still runs on the async runtime.
prepare_group_stanza performs Signal encryption; this is CPU-heavy and should be offloaded via tokio::task::spawn_blocking (including the retry path) to avoid stalling the runtime.

As per coding guidelines: All blocking I/O (like ureq calls) and heavy CPU-bound tasks (like media encryption) MUST be wrapped in tokio::task::spawn_blocking to avoid stalling the async runtime.

Also applies to: 419-436

🤖 Fix all issues with AI agents
In `@src/client.rs`:
- Around line 239-245: The current Client::shared() method uses expect on
upgrading a Weak self reference; change shared() to return Option<Arc<Self>> (or
Result<Arc<Self>, SomeError>) instead of panicking so callers can handle missing
self; update all call sites of shared() accordingly. Also replace any places
that call self.self_weak.set(...) and rely on expect/panic (the other occurrence
around the self initialization logic) to handle the Err case gracefully
(log/return an error or propagate a Result) rather than unwrapping—ensure all
references to shared() and self_weak.set(...) are updated to use the new
non-panicking return types and proper error handling.

In `@src/message.rs`:
- Around line 973-992: The code currently awaits
device_guard.backend.delete_session(&address_str).await while holding
device_guard, which can block other tasks; route this through the device command
path instead. Create a DeviceCommand variant (e.g.,
DeleteSession(address_str.clone())) and call
PersistenceManager::process_command(...) or the existing persistence command API
to perform the delete after cloning the needed address/signal_address, drop
device_guard before awaiting the command, and then call
adapter.invalidate_session(&signal_address).await only after the command
completes; ensure you remove the direct backend.delete_session call and use the
DeviceCommand/PersistenceManager::process_command flow to modify device state.

In `@src/store/signal_adapter.rs`:
- Around line 79-94: The flush method currently clears cache.dirty_sessions
up-front which loses pending updates if any SessionStore::store_session call
fails; change flush so dirty_sessions is not removed before writes succeed:
acquire the cache via self.cache.lock().await, clone or collect the list of
dirty addresses (or their (address, record.clone()) pairs) without mutating
cache.dirty_sessions, release the lock, perform SessionStore::store_session(&mut
self.inner, &address, &record).await for each, and only after a successful store
remove that address from cache.dirty_sessions (or, on failure, re-add it) so
failed writes remain marked dirty for retry; reference the flush function,
cache.dirty_sessions, cache.sessions and SessionStore::store_session when making
this change.

In `@src/store/signal.rs`:
- Around line 207-236: The code treats stored empty identity bytes as present;
change handling so empty byte arrays are treated as None: after loading
existing_identity_bytes from self.backend.load_identity(&address_str), normalize
it to e.g. let existing_nonempty = existing_identity_bytes.as_deref().filter(|b|
!b.is_empty()); use existing_nonempty for the equality check (replace the
is_some_and(...) test) and when computing IdentityChange::from_changed pass
existing_nonempty.is_some() so that empty payloads are not considered "existing"
for both equality and change detection before calling put_identity and returning
the IdentityChange.

Comment thread src/client.rs
Comment thread src/message.rs Outdated
Comment on lines +973 to +992
let deleted = if let Err(err) =
device_guard.backend.delete_session(&address_str).await
{
log::warn!(
"Failed to delete stale session for {}: {:?}",
signal_address,
err
);
false
} else {
log::info!(
"Deleted stale session for {} to allow re-establishment",
signal_address
);
}
true
};
drop(device_guard);
if deleted {
adapter.invalidate_session(&signal_address).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.

⚠️ Potential issue | 🟠 Major

Avoid holding the device write-lock across an async delete; route through the command path.
delete_session is awaited while holding device_guard, which can stall other tasks and conflicts with the device-command guideline. Clone the backend (or use PersistenceManager::process_command) and drop the lock before the await.

As per coding guidelines: src/**/*.rs: Use DeviceCommand + PersistenceManager::process_command() for all device state modifications; never modify Device state directly.

Suggested fix (lock-free delete)
-        let device_guard = device_arc.write().await;
         let address_str = signal_address.to_string();
-        let deleted = if let Err(err) =
-            device_guard.backend.delete_session(&address_str).await
-        {
+        let backend = {
+            let device = device_arc.read().await;
+            Arc::clone(&device.backend)
+        };
+        let deleted = if let Err(err) = backend.delete_session(&address_str).await {
             log::warn!(
                 "Failed to delete stale session for {}: {:?}",
                 signal_address,
                 err
             );
             false
         } else {
             log::info!(
                 "Deleted stale session for {} to allow re-establishment",
                 signal_address
             );
             true
         };
-        drop(device_guard);
🤖 Prompt for AI Agents
In `@src/message.rs` around lines 973 - 992, The code currently awaits
device_guard.backend.delete_session(&address_str).await while holding
device_guard, which can block other tasks; route this through the device command
path instead. Create a DeviceCommand variant (e.g.,
DeleteSession(address_str.clone())) and call
PersistenceManager::process_command(...) or the existing persistence command API
to perform the delete after cloning the needed address/signal_address, drop
device_guard before awaiting the command, and then call
adapter.invalidate_session(&signal_address).await only after the command
completes; ensure you remove the direct backend.delete_session call and use the
DeviceCommand/PersistenceManager::process_command flow to modify device state.

Comment thread src/store/signal_adapter.rs
Comment thread src/store/signal.rs
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 13, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

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.

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 13, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

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.

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

🤖 Fix all issues with AI agents
In `@src/client.rs`:
- Around line 1917-1922: The is_connected check currently treats a failed
try_lock on noise_socket as a false (disconnected) which yields false negatives;
update the is_connected implementation to avoid treating lock contention as a
disconnect—either rely solely on the atomic connection_alive flag or consider a
failed try_lock as "still connected" (e.g., treat Err from
noise_socket.try_lock() as true) so the result is monotonic; modify the logic in
is_connected (referencing connection_alive and noise_socket) and ensure
wait_for_socket / wait_for_connected behavior still relies on the updated
monotonic is_connected and notify_waiters semantics.

In `@src/message.rs`:
- Around line 1033-1036: After adapter.flush().await fails the current code logs
a warning but still returns any_success=true which misreports that session state
was persisted; update the error path so that if adapter.flush().await returns
Err(flush_err) you mutate any_success = false (or otherwise clear/overwrite the
success flag returned from this function) before returning, and include the
flush_err in the process log; locate the flush call and the variables
any_success and dispatched_undecryptable in the surrounding function and ensure
the function returns the corrected any_success value (alternatively, implement a
retry/receipt-trigger path in that error branch if your design prefers proactive
retry instead of flipping the flag).
- Around line 764-783: When decrypt_session_message_with_blocking returns Err (a
JoinError), add a warning log that explicitly notes that the spawn_blocking task
failed and any unflushed adapter state from previous successful iterations may
be lost; include identifying context like info.id and info.source.sender and the
JoinError (e) in the log and mention that adapter was moved into the task so its
buffered writes couldn't be flushed, so operators are aware of potential
data-loss risk. Ensure this log is emitted in the Err(e) branch where adapter
was previously moved and before returning the tuple, referencing
decrypt_session_message_with_blocking, adapter, and JoinError/spawn_blocking in
the message.

In `@src/send.rs`:
- Around line 176-203: The peer/DM path currently calls stanza_result? before
flushing the BatchedSignalProtocolStoreAdapter, so any store updates are dropped
on error; move the store_adapter.flush().await so it always runs regardless of
stanza_result (e.g., match on stanza_result, call store_adapter.flush().await in
both Ok and Err branches, then propagate the error or return the stanza),
referencing BatchedSignalProtocolStoreAdapter, prepare_peer_stanza,
store_adapter.flush, and the spawn_blocking block; apply the same change to the
other occurrence mentioned (around the 492-534 region).
- Line 2: prepare_group_stanza is being executed on the async runtime even
though it performs CPU-bound per-recipient encryption via group_encrypt and
message_encrypt; wrap the call to prepare_group_stanza in
tokio::task::spawn_blocking and await the JoinHandle to offload encryption to a
blocking thread, and ensure any errors/results are propagated back to the async
context (update both call sites that currently call prepare_group_stanza
directly so they mirror the existing peer/DM spawn_blocking pattern).

In `@src/store/signal_adapter.rs`:
- Around line 79-101: The flush method snapshots dirty_sessions then unlocks, so
concurrent store_* calls can replace a session after snapshotting and be lost
when flush blindly removes the dirty flag; to fix, when removing the dirty flag
in flush (for SessionStore::store_session / self.cache.dirty_sessions), check
that the cached session still equals the record you just flushed (or use a
stored version/token) and only remove the dirty marker if they match; apply the
same guarded-remove pattern to identities and sender keys (the other flush
blocks referenced) so newer in-memory updates remain dirty and will be persisted
later.
🧹 Nitpick comments (2)
src/message.rs (2)

829-845: delete_identity bypasses the batched adapter and goes directly to the backend.

delete_session (line 976) correctly goes through adapter.delete_session(...), but delete_identity here calls backend.delete_identity(...) directly, then separately invalidates the adapter cache. This is inconsistent — if the batched adapter has a cached identity that differs from what the backend now holds, there's a window for stale reads from other concurrent paths.

Consider routing this through the adapter (if it supports delete_identity) for consistency with how delete_session is handled.

As per coding guidelines: src/**/*.rs: Use DeviceCommand + PersistenceManager::process_command() for all device state modifications; never modify Device state directly.


1075-1078: Group decrypt still acquires a write lock on device_arc per message.

The session path now uses the batched adapter for reduced lock contention, but process_group_enc_batch still holds device_arc.write().await for each group_decrypt call. If the batched adapter also wraps the sender key store (which the relevant snippet in signal_adapter.rs shows CachedSenderKeyAdapter), consider using it here too for consistency and reduced lock pressure.

Comment thread src/client.rs
Comment on lines +1917 to 1922
self.connection_alive.load(Ordering::Acquire)
&& self
.noise_socket
.try_lock()
.is_ok_and(|guard| guard.is_some())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid false negatives from try_lock in is_connected.

try_lock failure signals contention, not a disconnect. Returning false can cause wait_for_socket/wait_for_connected to time out after notify_waiters. Consider treating lock contention as connected (or rely solely on connection_alive) so the check is monotonic.

Suggested fix
-        self.connection_alive.load(Ordering::Acquire)
-            && self
-                .noise_socket
-                .try_lock()
-                .is_ok_and(|guard| guard.is_some())
+        if !self.connection_alive.load(Ordering::Acquire) {
+            return false;
+        }
+        match self.noise_socket.try_lock() {
+            Ok(guard) => guard.is_some(),
+            Err(_) => true, // lock contention shouldn't be treated as disconnected
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.connection_alive.load(Ordering::Acquire)
&& self
.noise_socket
.try_lock()
.is_ok_and(|guard| guard.is_some())
}
if !self.connection_alive.load(Ordering::Acquire) {
return false;
}
match self.noise_socket.try_lock() {
Ok(guard) => guard.is_some(),
Err(_) => true, // lock contention shouldn't be treated as disconnected
}
}
🤖 Prompt for AI Agents
In `@src/client.rs` around lines 1917 - 1922, The is_connected check currently
treats a failed try_lock on noise_socket as a false (disconnected) which yields
false negatives; update the is_connected implementation to avoid treating lock
contention as a disconnect—either rely solely on the atomic connection_alive
flag or consider a failed try_lock as "still connected" (e.g., treat Err from
noise_socket.try_lock() as true) so the result is monotonic; modify the logic in
is_connected (referencing connection_alive and noise_socket) and ensure
wait_for_socket / wait_for_connected behavior still relies on the updated
monotonic is_connected and notify_waiters semantics.

Comment thread src/message.rs
Comment on lines +764 to +783
let (next_adapter, parsed_message, decrypt_res) =
match decrypt_session_message_with_blocking(
parsed_message,
signal_address.clone(),
adapter,
)
.await
{
Ok(result) => result,
Err(e) => {
log::error!(
"spawn_blocking failed while decrypting message {} from {}: {}",
info.id,
info.source.sender,
e
);
return (any_success, any_duplicate, dispatched_undecryptable);
}
};
adapter = next_adapter;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

On JoinError, accumulated adapter writes from prior iterations are silently lost.

If the spawn_blocking task panics or is cancelled, the adapter (moved into the closure) is irrecoverable, so you can't flush. However, any session state mutations from previous successful decryptions in this batch are also lost because they haven't been flushed yet.

This is low-probability (JoinError is exceptional) and self-healing (retry will re-establish state), but worth a log line noting the data-loss risk so operators aren't surprised.

Suggested: warn about lost writes
                 Err(e) => {
                     log::error!(
-                        "spawn_blocking failed while decrypting message {} from {}: {}",
+                        "spawn_blocking failed while decrypting message {} from {}: {}. \
+                         Any batched session writes from prior messages in this batch are lost.",
                         info.id,
                         info.source.sender,
                         e
                     );
                     return (any_success, any_duplicate, dispatched_undecryptable);
                 }
🤖 Prompt for AI Agents
In `@src/message.rs` around lines 764 - 783, When
decrypt_session_message_with_blocking returns Err (a JoinError), add a warning
log that explicitly notes that the spawn_blocking task failed and any unflushed
adapter state from previous successful iterations may be lost; include
identifying context like info.id and info.source.sender and the JoinError (e) in
the log and mention that adapter was moved into the task so its buffered writes
couldn't be flushed, so operators are aware of potential data-loss risk. Ensure
this log is emitted in the Err(e) branch where adapter was previously moved and
before returning the tuple, referencing decrypt_session_message_with_blocking,
adapter, and JoinError/spawn_blocking in the message.

Comment thread src/message.rs
Comment on lines +1033 to 1036
if let Err(flush_err) = adapter.flush().await {
log::warn!("Failed to flush cached Signal stores after decrypt batch: {flush_err}");
}
(any_success, any_duplicate, dispatched_undecryptable)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Flush failure silently loses session state updates while still reporting success.

If flush() fails, the function returns any_success = true (messages were dispatched to the event bus), but session ratchet state was never persisted. The next message from this sender will fail decryption with a ratchet mismatch or duplicate-message error.

Consider whether a flush failure should flip any_success to false or at least trigger a retry receipt proactively, since the session state is now inconsistent.

🤖 Prompt for AI Agents
In `@src/message.rs` around lines 1033 - 1036, After adapter.flush().await fails
the current code logs a warning but still returns any_success=true which
misreports that session state was persisted; update the error path so that if
adapter.flush().await returns Err(flush_err) you mutate any_success = false (or
otherwise clear/overwrite the success flag returned from this function) before
returning, and include the flush_err in the process log; locate the flush call
and the variables any_success and dispatched_undecryptable in the surrounding
function and ensure the function returns the corrected any_success value
(alternatively, implement a retry/receipt-trigger path in that error branch if
your design prefers proactive retry instead of flipping the flag).

Comment thread src/send.rs
@@ -1,5 +1,5 @@
use crate::client::Client;
use crate::store::signal_adapter::SignalProtocolStoreAdapter;
use crate::store::signal_adapter::BatchedSignalProtocolStoreAdapter;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -type f -name "send.rs" 2>/dev/null | head -5

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

fd -a "send.rs"

Repository: jlucaso1/whatsapp-rust

Length of output: 134


🏁 Script executed:

rg -l "prepare_group_stanza"

Repository: jlucaso1/whatsapp-rust

Length of output: 149


🏁 Script executed:

rg -n "prepare_group_stanza|spawn_blocking" src/send.rs | head -30

Repository: jlucaso1/whatsapp-rust

Length of output: 685


🏁 Script executed:

sed -n '175,215p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 2114


🏁 Script executed:

sed -n '340,365p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 911


🏁 Script executed:

sed -n '495,535p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 2112


🏁 Script executed:

rg -A 20 "pub fn prepare_group_stanza" wacore/src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

rg -B 5 -A 30 "fn prepare_group_stanza" wacore/src/

Repository: jlucaso1/whatsapp-rust

Length of output: 2155


🏁 Script executed:

rg -l "prepare_group_stanza" wacore/src/

Repository: jlucaso1/whatsapp-rust

Length of output: 112


🏁 Script executed:

cat wacore/src/send.rs | head -100

Repository: jlucaso1/whatsapp-rust

Length of output: 3922


🏁 Script executed:

wc -l wacore/src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 89


🏁 Script executed:

rg -n "group_encrypt|message_encrypt|sender_key" wacore/src/send.rs | head -20

Repository: jlucaso1/whatsapp-rust

Length of output: 1352


🏁 Script executed:

sed -n '206,380p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 7562


🏁 Script executed:

sed -n '350,365p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 550


🏁 Script executed:

sed -n '415,435p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 1128


🏁 Script executed:

sed -n '365,430p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 3243


🏁 Script executed:

sed -n '396,412p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 909


🏁 Script executed:

sed -n '195,205p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 543


🏁 Script executed:

sed -n '257,270p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 835


🏁 Script executed:

sed -n '440,450p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 505


🏁 Script executed:

sed -n '436,450p' src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 658


🏁 Script executed:

sed -n '395,450p' src/send.rs | grep -A 10 "Err(e)"

Repository: jlucaso1/whatsapp-rust

Length of output: 1143


🏁 Script executed:

rg -B 2 "pub async fn prepare_group_stanza" wacore/src/send.rs | head -10

Repository: jlucaso1/whatsapp-rust

Length of output: 138


🏁 Script executed:

sed -n '754,760p' wacore/src/send.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 320


Wrap group encryption in spawn_blocking to prevent async runtime stalling.

The peer and DM paths both use tokio::task::spawn_blocking to offload encryption work (lines 182 and 504), but the group path calls prepare_group_stanza directly on the async runtime at lines 351 and 421. Since prepare_group_stanza performs per-recipient encryption with group_encrypt and message_encrypt calls, these CPU-bound operations should be wrapped in spawn_blocking to avoid blocking reactor threads under load.

Per coding guidelines: "All blocking I/O (like ureq calls) and heavy CPU-bound tasks (like media encryption) MUST be wrapped in tokio::task::spawn_blocking to avoid stalling the async runtime."

🤖 Prompt for AI Agents
In `@src/send.rs` at line 2, prepare_group_stanza is being executed on the async
runtime even though it performs CPU-bound per-recipient encryption via
group_encrypt and message_encrypt; wrap the call to prepare_group_stanza in
tokio::task::spawn_blocking and await the JoinHandle to offload encryption to a
blocking thread, and ensure any errors/results are propagated back to the async
context (update both call sites that currently call prepare_group_stanza
directly so they mirror the existing peer/DM spawn_blocking pattern).

Comment thread src/send.rs
Comment on lines 176 to 203
let device_store_arc = self.persistence_manager.get_device_arc().await;
let mut store_adapter = SignalProtocolStoreAdapter::new(device_store_arc);

wacore::send::prepare_peer_stanza(
&mut store_adapter.session_store,
&mut store_adapter.identity_store,
to,
encryption_jid,
message,
request_id,
)
.await?
let message_for_encrypt = message.clone();
let to_for_encrypt = to.clone();
let encryption_jid_for_encrypt = encryption_jid.clone();
// Peer encryption is a single-session operation. With the batched
// cache, store ops resolve from memory so the work is CPU-bound.
let (mut store_adapter, stanza_result) = tokio::task::spawn_blocking(move || {
let runtime = tokio::runtime::Handle::current();
let mut store_adapter = BatchedSignalProtocolStoreAdapter::new(device_store_arc);
let stanza_result = runtime.block_on(async {
wacore::send::prepare_peer_stanza(
&mut store_adapter.session_store,
&mut store_adapter.identity_store,
to_for_encrypt,
encryption_jid_for_encrypt,
&message_for_encrypt,
request_id,
)
.await
});
(store_adapter, stanza_result)
})
.await
.map_err(|e| anyhow!("spawn_blocking failed during peer encryption: {e}"))?;
let stanza = stanza_result?;
store_adapter.flush().await?;
stanza
} else if to.is_group() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Flush cached stores even when prepare_* returns error.

With the batched cache, any state changes made before an error are dropped if we exit without flushing. The group path already flushes on error; peer/DM should do the same (or add a guard) to keep session/identity updates durable.

Example pattern (apply similarly to DM)
-            let stanza = stanza_result?;
-            store_adapter.flush().await?;
-            stanza
+            let stanza = match stanza_result {
+                Ok(s) => s,
+                Err(e) => {
+                    if let Err(flush_err) = store_adapter.flush().await {
+                        log::warn!("Failed to flush cached Signal stores after error: {flush_err}");
+                    }
+                    return Err(e.into());
+                }
+            };
+            store_adapter.flush().await?;
+            stanza

Also applies to: 492-534

🤖 Prompt for AI Agents
In `@src/send.rs` around lines 176 - 203, The peer/DM path currently calls
stanza_result? before flushing the BatchedSignalProtocolStoreAdapter, so any
store updates are dropped on error; move the store_adapter.flush().await so it
always runs regardless of stanza_result (e.g., match on stanza_result, call
store_adapter.flush().await in both Ok and Err branches, then propagate the
error or return the stanza), referencing BatchedSignalProtocolStoreAdapter,
prepare_peer_stanza, store_adapter.flush, and the spawn_blocking block; apply
the same change to the other occurrence mentioned (around the 492-534 region).

Comment on lines +79 to +101
async fn flush(&mut self) -> Result<(), SignalProtocolError> {
let pending_writes: Vec<_> = {
let cache = self.cache.lock().await;
cache
.dirty_sessions
.iter()
.filter_map(|address| {
cache
.sessions
.get(address)
.and_then(|opt| opt.as_ref())
.map(|record| (address.clone(), record.clone()))
})
.collect()
};

for (address, record) in pending_writes {
SessionStore::store_session(&mut self.inner, &address, &record).await?;
self.cache.lock().await.dirty_sessions.remove(&address);
}

Ok(())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Flush can drop newer updates during concurrent writes.

Each flush snapshots dirty_* then unlocks. If a new store_* happens for the same key during the flush, the subsequent dirty_* removal can clear the flag for the newer value, so it never gets persisted. Please guard dirty-flag removal with a version/token check (or compare the cached value to the flushed value) so newer writes remain dirty.

One way to guard dirty-flag removal (apply similarly to identities/sender keys)
-        for (address, record) in pending_writes {
-            SessionStore::store_session(&mut self.inner, &address, &record).await?;
-            self.cache.lock().await.dirty_sessions.remove(&address);
-        }
+        for (address, record) in pending_writes {
+            SessionStore::store_session(&mut self.inner, &address, &record).await?;
+            let mut cache = self.cache.lock().await;
+            let still_same = cache
+                .sessions
+                .get(&address)
+                .and_then(|opt| opt.as_ref())
+                .is_some_and(|current| current == &record);
+            if still_same {
+                cache.dirty_sessions.remove(&address);
+            }
+        }

Also applies to: 137-159, 179-205

🤖 Prompt for AI Agents
In `@src/store/signal_adapter.rs` around lines 79 - 101, The flush method
snapshots dirty_sessions then unlocks, so concurrent store_* calls can replace a
session after snapshotting and be lost when flush blindly removes the dirty
flag; to fix, when removing the dirty flag in flush (for
SessionStore::store_session / self.cache.dirty_sessions), check that the cached
session still equals the record you just flushed (or use a stored version/token)
and only remove the dirty marker if they match; apply the same guarded-remove
pattern to identities and sender keys (the other flush blocks referenced) so
newer in-memory updates remain dirty and will be persisted later.

@jlucaso1 jlucaso1 closed this Apr 7, 2026
@jlucaso1
jlucaso1 deleted the perf-batch-write-store branch April 7, 2026 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant