diff --git a/api/store.mdx b/api/store.mdx index 5422c917..7da818f5 100644 --- a/api/store.mdx +++ b/api/store.mdx @@ -292,16 +292,27 @@ async fn touch_tc_token_sender_timestamp(&self, jid: &str, sender_timestamp: i64 /// `sender_timestamp`. The symmetric counterpart of /// `touch_tc_token_sender_timestamp` — each writer owns its own field, so the /// notification path never drops a sender bucket the issuance path wrote -/// concurrently. Same atomicity requirement as above. +/// concurrently. +/// +/// **Newer-wins**: the stored `(token, token_timestamp)` pair is overwritten +/// only when the existing token is a byte-less placeholder, or the incoming +/// `token_timestamp` is at least as new as the stored one — a stale write must +/// never clobber a fresher real token. `SqliteStore` enforces this atomically +/// inside an `IMMEDIATE` transaction; `InMemoryBackend` under its state lock. +/// This is what lets concurrent history-sync chunks and the +/// privacy-notification path converge on the same row without an external +/// lock. The default read-modify-write below is a best-effort for +/// third-party backends — same atomicity caveat as +/// `touch_tc_token_sender_timestamp`. async fn store_received_tc_token(&self, jid: &str, token: &[u8], token_timestamp: i64) -> Result<()> { - // default: read-modify-write via get_tc_token + put_tc_token + // default: read-modify-write via get_tc_token + put_tc_token, newer-wins } ``` `delete_expired_tc_tokens` gained a second `sender_cutoff` parameter — **this is a breaking change** for any custom backend that overrides it; update the signature to `(&self, token_cutoff: i64, sender_cutoff: i64) -> Result` and prune on both windows independently (see the built-in `SqliteStore`/`InMemoryBackend` implementations for the two-filter pattern). -`touch_tc_token_sender_timestamp` and `store_received_tc_token` are new defaulted methods — existing custom backends compile and work unchanged, but should override both with an atomic upsert if the backend supports one, since the default read-modify-write can race a concurrent writer touching the same row (post-send issuance vs. an incoming `privacy_token` notification). +`touch_tc_token_sender_timestamp` and `store_received_tc_token` are defaulted methods — existing custom backends compile and work unchanged, but should override both with an atomic upsert if the backend supports one, since the default read-modify-write can race a concurrent writer touching the same row (post-send issuance vs. an incoming `privacy_token` notification). For `store_received_tc_token` specifically, a non-atomic override that races two callers can let an older token's write land last and clobber a fresher one — the built-in backends close this by making the newer-wins check part of the same read+write. ### Sent message store diff --git a/api/tctoken.mdx b/api/tctoken.mdx index 2206735e..71feca71 100644 --- a/api/tctoken.mdx +++ b/api/tctoken.mdx @@ -284,12 +284,14 @@ When a contact sends you a privacy token, the library handles it automatically: 1. Parses the `` stanza 2. Resolves the sender to a LID for storage (using `sender_lid` attribute or LID-PN cache) -3. Applies a timestamp monotonicity guard — an older token doesn't overwrite a newer *real* token +3. Applies a timestamp monotonicity pre-filter — if the incoming token is older than a stored *real* token, storage and the presence re-subscribe are both skipped 4. Stores the token in the backend, preserving any `sender_timestamp` already recorded by the post-send issuance path 5. Re-subscribes presence for the sender to pick up the updated token +The same newer-wins rule (older writes rejected, a byte-less placeholder always accepts the first real token) is also enforced **atomically inside the store itself** — see [`store_received_tc_token`](/api/store#tctoken-storage). This is what closes the cross-source race between this notification path and history-sync's tc-token candidates: both call the same store method, so whichever call lands last can never clobber a fresher token, without needing a lock shared across the two paths. + -A byte-less placeholder (written by post-send issuance before any real token has been received) is always replaced by the contact's first real token, even if the placeholder's own timestamp is newer — the monotonicity guard only applies once a real token is on record. +A byte-less placeholder (written by post-send issuance before any real token has been received) is always replaced by the contact's first real token, even if the placeholder's own timestamp is newer — the newer-wins rule only blocks a stale write once a real token is on record. ### Startup pruning @@ -384,7 +386,7 @@ sequenceDiagram Note over Client,Server: Incoming token notification Server->>Client: notification type="privacy_token" Client->>Client: Resolve sender LID - Client->>Backend: store_received_tc_token (monotonicity guard, preserves sender_timestamp) + Client->>Backend: store_received_tc_token (atomic newer-wins, preserves sender_timestamp) Client->>Server: Re-subscribe presence Note over App,Server: Outgoing 1:1 call offer (voip feature, unconditional — no AB prop gate) diff --git a/concepts/storage.mdx b/concepts/storage.mdx index be0a1320..04557834 100644 --- a/concepts/storage.mdx +++ b/concepts/storage.mdx @@ -449,7 +449,9 @@ pub trait ProtocolStore: Send + Sync { // byte-less placeholder if no entry exists yet. Defaulted (read-modify-write). async fn touch_tc_token_sender_timestamp(&self, jid: &str, sender_timestamp: i64) -> Result<()> { /* default */ } // Store a token received from a contact without clobbering an existing - // sender_timestamp. Defaulted (read-modify-write). + // sender_timestamp. Newer-wins: a stale write (older token_timestamp, + // unless the existing token is a byte-less placeholder) is a no-op. + // Defaulted (read-modify-write); SqliteStore/InMemoryBackend enforce it atomically. async fn store_received_tc_token(&self, jid: &str, token: &[u8], token_timestamp: i64) -> Result<()> { /* default */ } // Sent Message Store (retry support, matches WA Web's getMessageTable)