Skip to main content

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 old next_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 markKeyAsUploaded ordering in PreKeysJob.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 wanted of them and generates nothing, without regressing NEXT_PK_ID.
  • A corrupt first > next pair and windows that would cross the 24-bit id boundary collapse to a fresh window automatically.
Retry-receipt single prekey (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.
Upload marking is UPDATE-only After a successful IQ, keys are marked uploaded via an SQL 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:
async fn mark_prekeys_uploaded(&self, ids: &[u32]) -> Result<()>;
The 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:
async fn mark_prekeys_uploaded(&self, ids: &[u32]) -> Result<()> {
    if ids.is_empty() {
        return Ok(());
    }
    // UPDATE prekeys SET uploaded = true WHERE id IN (...) AND device_id = ?
    // Use UPDATE, not UPSERT — consumed rows must stay deleted.
    todo!()
}

DeviceCommand::SetPreKeyWatermarks replaces SetNextPreKeyId

DeviceCommand::SetNextPreKeyId(u32) has been removed. Use SetPreKeyWatermarks to update both watermarks atomically:
// Before
DeviceCommand::SetNextPreKeyId(next_id)

// After
DeviceCommand::SetPreKeyWatermarks {
    next_pre_key_id: next_id,
    first_unupload_pre_key_id: first_id,
}
Both watermarks must always move together — the split-update model was exactly how the pre-watermark code lost track of generated keys.

Device gains first_unupload_pre_key_id

A new u32 field is added to Device:
pub first_unupload_pre_key_id: u32, // serde(default) = 0 (unset/legacy)
The value 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:
-- up
ALTER TABLE device ADD COLUMN first_unupload_pre_key_id INTEGER NOT NULL DEFAULT 0;

-- down
ALTER TABLE device DROP COLUMN first_unupload_pre_key_id;
The 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.