What changed
Background: Previously, every prekey upload generated a full fresh batch of 812 keys regardless of how many were already stored but never uploaded (e.g. after a failed IQ). The oldnext_pre_key_id counter served double duty as both the generation watermark and the upload watermark, and a failed upload left dead rows in the store that the next attempt silently skipped.
WA Web uses two separate meta-keys — NEXT_PK_ID (advances at generation time) and FIRST_UNUPLOAD_PK_ID (advances past the upload window) — so that getOrGenPreKeys(812) is a target-total operation: it re-offers the [FIRST, NEXT) leftover window and only generates 812 - (NEXT - FIRST) new keys.
This release brings the Rust client into full parity with that model.
Behavior changes
Prekey upload window reuse Upload batches now re-offer leftover generated-but-unuploaded keys first, generating only as many new keys as needed to reach the configured count:- FIRST is advanced past the window before the IQ is sent (matching WA Web’s
markKeyAsUploadedordering inPreKeysJob.js). This means that after a mid-flight IQ failure the window is intentionally empty — the server state is unknown and re-offering an id a peer may already have consumed would corrupt the server pool. The next upload attempt mints strictly fresh ids. The abandoned rows stay stored locally and remain decryptable if the upload did land. What the window reuse protects is everything before the send: keys generated but never sent (process death or disconnect between the generation and IQ phases). - A window with more leftovers than the target uploads only
wantedof them and generates nothing, without regressingNEXT_PK_ID. - A corrupt
first > nextpair and windows that would cross the 24-bit id boundary collapse to a fresh window automatically.
getOrGenSinglePreKey parity)
The single prekey allocated for retry receipts now mirrors WA Web’s getOrGenSinglePreKey = getOrGenPreKeys(1):
- When a leftover exists in the window, the window head is returned unchanged. The next batch upload re-offers the same stored key (true WA Web parity).
- A consumed window head (the peer spent the key) heals by skipping the dead slot to the next live key, instead of failing like WA Web does.
- A fully exhausted window generates a fresh key at
NEXT_PK_ID.
UPDATE (not UPSERT). A key deleted between the upload snapshot and the mark (a one-time key consumed by an inbound pkmsg while the IQ was in flight) stays deleted and is never resurrected.
Breaking changes
New SignalStore method: mark_prekeys_uploaded
Custom backend implementations must add this method. It marks already-stored prekeys as uploaded using UPDATE semantics — consuming rows must not be resurrected by the mark:
InMemoryBackend no-ops this (it doesn’t track the uploaded flag). The SqliteStore implementation uses chunked UPDATE to stay under SQLite’s host-parameter limit.
If you implement a custom backend, add:
DeviceCommand::SetPreKeyWatermarks replaces SetNextPreKeyId
DeviceCommand::SetNextPreKeyId(u32) has been removed. Use SetPreKeyWatermarks to update both watermarks atomically:
Device gains first_unupload_pre_key_id
A new u32 field is added to Device:
0 means “unset” (legacy device before this watermark existed). The upload path initialises it on the first upload from the legacy-safe starting point. Existing serialized blobs and SQLite rows load with 0 thanks to #[serde(default)] and DEFAULT 0 on the new column — no migration step is required for existing deployments.
SQLite migration
A new migration adds the column with a safe default:SqliteStore applies this automatically via Diesel migrations on startup.
allocate_next_one_time_prekey_id removed
The crate-private Client::allocate_next_one_time_prekey_id method has been replaced by get_or_gen_single_pre_key. This was an internal surface and is only relevant if you had a fork that called it directly.