Skip to content
Merged
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions concepts/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -533,14 +533,18 @@ For bots that don't need chat history, `skip_history_sync()` sends a receipt so

### Per-Chat Lanes

Prevents race conditions where a later message is processed before the PreKey message. Each chat gets a lane combining an enqueue lock and a bounded channel (capacity **500** messages) into a single cached entry, providing backpressure to prevent memory amplification when many chats are active simultaneously:
Prevents race conditions where a later message is processed before the PreKey message. Each chat gets a lane combining an enqueue lock and an **unbounded** channel into a single cached entry. Backpressure comes from capping the number of cached lanes rather than messages within a lane: the `chat_lanes` cache itself has a capacity (`chat_lanes_capacity`, default **5,000**) and evicts idle lanes once full — see the note below for what counts as idle:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

```rust
pub(crate) chat_lanes: Cache<Jid, ChatLane>,
// ChatLane { enqueue_lock, queue_tx }
// Each queue: async_channel::bounded::<Arc<OwnedNodeRef>>(500)
// Each queue: async_channel::unbounded::<QueuedChatMessage>()
```

<Note>
**Active lanes survive capacity eviction (v0.6).** Every queued item is a `QueuedChatMessage { node, lane_liveness }`, where `lane_liveness` is a clone of the lane's `enqueue_lock`. The cache's `evict_guard` refuses to evict a lane while any in-flight message still holds that clone (`Arc::strong_count(&lane.enqueue_lock) > 1`); the worker drops its copy only after it finishes processing that message. Previously, a lane could be capacity-evicted right after its worker dequeued a message, and a later stanza for the same chat would then miss the cache and spawn a second worker — letting two workers process the same chat concurrently and out of order. Idle lanes (no in-flight message) remain evictable exactly as before.
</Note>

### Per-device session locks

Prevents concurrent Signal protocol operations on the same session. Each device JID gets its own lock, keyed by protocol address strings generated by `to_protocol_address_string()` (format: `user[:device]@server.0`):
Expand Down Expand Up @@ -575,6 +579,10 @@ let decrypt_result = group_decrypt(ciphertext, &mut adapter.sender_key_store, &s

Without this lock, two decrypt workers for the same `(group, sender)` — reachable when a [chat lane](#per-chat-lanes) is capacity-evicted while its worker is still draining, and a later stanza for that chat misses the cache and spawns a second worker at the same connection generation — could both load the sender-key chain, advance it, and store their result, with the last store silently winning and dropping a chain step. This mirrors the per-device session lock the 1:1 send/receive paths already hold around their Signal ratchet mutations.

<Note>
**Chat-lane eviction trigger closed (v0.6).** The specific double-worker path described above — a chat lane evicted while its worker was still draining — is now closed by the [active-lane eviction guard](#per-chat-lanes): a lane with an in-flight message can no longer be capacity-evicted, so a later stanza for that chat can no longer spawn a second worker. This chain lock remains in place as defense-in-depth against any other path that could produce two concurrent decrypt workers for the same `(group, sender)`.
</Note>

### Background Saver

Periodic persistence with dirty flag optimization:
Expand Down