Skip to content

Commit 16f6430

Browse files
authored
Bound chat-list and media retention query work (#1749)
* Bound media reference retention work Index cached secrets by group and epoch so retaining a media message does not scan unrelated exporter history. Mark shared epoch rows once per message and avoid rewriting secrets already managed by retention. Cover reference replay, history scaling, and populated migration upgrades while preserving epoch retirement behavior. * Bound chat readiness and pinned-list query work Index each group's receipt timestamps so checking a complete chat projection does not scan retained message history. Compute pin ranks once for full-list reads instead of counting earlier pins per row. Keep keyed lookups and ranks across gaps or missing projections intact. Cover query scaling, stale receipt detection, archive filtering, and populated index upgrades. * Benchmark file-backed chat-list readiness Measure release-mode readiness and row fetches on encrypted files with 10,000 and 100,000 retained messages. Alternate the receipt index off and on, exclude setup, and verify identical chat rows.
1 parent 008c128 commit 16f6430

7 files changed

Lines changed: 428 additions & 57 deletions

File tree

crates/storage-sqlite/src/chat_list.rs

Lines changed: 28 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2316,20 +2316,25 @@ pub(crate) fn chat_list_rows_tx(
23162316
tx: &Connection,
23172317
query: ChatListQuery,
23182318
) -> StorageResult<Vec<ChatListRow>> {
2319-
let sql = if query.include_archived {
2320-
format!(
2321-
"{CHAT_LIST_ROW_SELECT_AND_JOINS}
2322-
ORDER BY pin.ordinal IS NULL, pin.ordinal ASC,
2323-
row.activity_sort_at DESC, row.group_id_hex"
2324-
)
2319+
// Rank the pin table once, including pins whose projection is absent.
2320+
// Keyed reads retain their single ordinal-count lookup.
2321+
let archived_filter = if query.include_archived {
2322+
""
23252323
} else {
2326-
format!(
2327-
"{CHAT_LIST_ROW_SELECT_AND_JOINS}
2328-
WHERE row.archived = 0
2329-
ORDER BY pin.ordinal IS NULL, pin.ordinal ASC,
2330-
row.activity_sort_at DESC, row.group_id_hex"
2331-
)
2324+
"WHERE row.archived = 0"
23322325
};
2326+
let sql = format!(
2327+
"{CHAT_LIST_ROW_SELECT_LIST} pin.position
2328+
{CHAT_LIST_ROW_JOINS}
2329+
LEFT JOIN (
2330+
SELECT group_id_hex, ordinal,
2331+
ROW_NUMBER() OVER (ORDER BY ordinal) - 1 AS position
2332+
FROM chat_pin_positions
2333+
) AS pin ON pin.group_id_hex = row.group_id_hex
2334+
{archived_filter}
2335+
ORDER BY pin.ordinal IS NULL, pin.ordinal ASC,
2336+
row.activity_sort_at DESC, row.group_id_hex"
2337+
);
23332338
let now_ms = unix_now_ms();
23342339
let mut stmt = tx.prepare_cached(&sql).storage()?;
23352340
let mut rows = stmt
@@ -2358,7 +2363,7 @@ fn direct_conversation_candidate_sql() -> String {
23582363
// Drive from the peer index, then join the matching chat-list row.
23592364
// Durable activity order, not pin-first chat-list order.
23602365
format!(
2361-
"{CHAT_LIST_ROW_SELECT_LIST}
2366+
"{CHAT_LIST_ROW_SELECT_LIST} {CHAT_PIN_POSITION_SQL}
23622367
FROM direct_conversation_members AS dcm
23632368
JOIN chat_list_rows AS row ON row.group_id_hex = dcm.group_id_hex
23642369
LEFT JOIN account_groups AS ag ON ag.group_id_hex = row.group_id_hex
@@ -2412,7 +2417,8 @@ pub(crate) fn chat_list_row_tx(
24122417
) -> StorageResult<Option<ChatListRow>> {
24132418
let now_ms = unix_now_ms();
24142419
let sql = format!(
2415-
"{CHAT_LIST_ROW_SELECT_AND_JOINS}
2420+
"{CHAT_LIST_ROW_SELECT_LIST} {CHAT_PIN_POSITION_SQL}
2421+
{CHAT_LIST_ROW_JOINS} {CHAT_PIN_JOIN}
24162422
WHERE row.group_id_hex = ?1"
24172423
);
24182424
tx.query_row_cached(&sql, params![group_id_hex], |row| {
@@ -2434,11 +2440,7 @@ pub(crate) fn chat_list_row_tx(
24342440
.transpose()
24352441
}
24362442

2437-
// Keep this projection in one place: `chat_list_row_from_row` decodes it by
2438-
// index, so list and single-row queries must never drift in column order.
2439-
// `CHAT_LIST_ROW_SELECT_LIST` must stay column-identical to
2440-
// `CHAT_LIST_ROW_SELECT_AND_JOINS` so the peer-driven candidate query
2441-
// decodes the same way.
2443+
// All chat reads share these columns; only pin-rank computation differs.
24422444
const CHAT_LIST_ROW_SELECT_LIST: &str =
24432445
"SELECT row.group_id_hex, row.archived, row.pending_confirmation,
24442446
row.title, row.group_name, row.avatar_url,
@@ -2460,45 +2462,20 @@ const CHAT_LIST_ROW_SELECT_LIST: &str =
24602462
SELECT 1 FROM cgka_disband_tombstones AS tomb
24612463
WHERE lower(hex(tomb.group_id)) = lower(row.group_id_hex)
24622464
),
2463-
pin.group_id_hex IS NOT NULL,
2464-
CASE WHEN pin.ordinal IS NULL THEN NULL ELSE (
2465+
pin.group_id_hex IS NOT NULL,";
2466+
2467+
const CHAT_PIN_POSITION_SQL: &str = "CASE WHEN pin.ordinal IS NULL THEN NULL ELSE (
24652468
SELECT COUNT(*)
24662469
FROM chat_pin_positions AS earlier_pin
24672470
WHERE earlier_pin.ordinal < pin.ordinal
24682471
) END";
24692472

2470-
const CHAT_LIST_ROW_SELECT_AND_JOINS: &str =
2471-
"SELECT row.group_id_hex, row.archived, row.pending_confirmation,
2472-
row.title, row.group_name, row.avatar_url,
2473-
row.avatar_image_hash_hex, row.avatar_image_key_hex,
2474-
row.avatar_image_nonce_hex, row.avatar_image_upload_key_hex,
2475-
row.avatar_media_type, row.last_message_id_hex,
2476-
row.last_message_sender, row.last_message_preview,
2477-
row.last_message_kind, row.last_message_timeline_at,
2478-
row.last_message_deleted, row.last_message_media_json,
2479-
row.last_message_delivery_state, row.unread_count,
2480-
row.manually_marked_unread, row.unread_mention_count,
2481-
row.first_unread_message_id_hex, row.last_read_message_id_hex,
2482-
row.last_read_timeline_at, row.conversation_created_at,
2483-
row.activity_sort_at, row.updated_at, row.self_membership,
2484-
ag.member_count,
2485-
mute.group_id_hex IS NOT NULL,
2486-
mute.muted_until_ms,
2487-
EXISTS (
2488-
SELECT 1 FROM cgka_disband_tombstones AS tomb
2489-
WHERE lower(hex(tomb.group_id)) = lower(row.group_id_hex)
2490-
),
2491-
pin.group_id_hex IS NOT NULL,
2492-
CASE WHEN pin.ordinal IS NULL THEN NULL ELSE (
2493-
SELECT COUNT(*)
2494-
FROM chat_pin_positions AS earlier_pin
2495-
WHERE earlier_pin.ordinal < pin.ordinal
2496-
) END
2497-
FROM chat_list_rows AS row
2473+
const CHAT_LIST_ROW_JOINS: &str = "FROM chat_list_rows AS row
24982474
LEFT JOIN account_groups AS ag ON ag.group_id_hex = row.group_id_hex
24992475
LEFT JOIN chat_notification_settings AS mute
2500-
ON mute.group_id_hex = row.group_id_hex
2501-
LEFT JOIN chat_pin_positions AS pin
2476+
ON mute.group_id_hex = row.group_id_hex";
2477+
2478+
const CHAT_PIN_JOIN: &str = "LEFT JOIN chat_pin_positions AS pin
25022479
ON pin.group_id_hex = row.group_id_hex";
25032480

25042481
fn chat_list_row_from_row(row: &rusqlite::Row<'_>, now_ms: i64) -> rusqlite::Result<ChatListRow> {

crates/storage-sqlite/src/chat_list/tests.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,84 @@ fn setup_store() -> SqliteAccountStorage {
158158
setup_store_with_group(group())
159159
}
160160

161+
// Compare the existing readiness API with migration 0066’s index on synthetic history.
162+
// Setup, index construction, and the connection/KDF are outside the timed region.
163+
// Run: cargo test -p storage-sqlite --release --lib chat_startup_benchmark -- --ignored --nocapture
164+
#[test]
165+
#[ignore = "file-backed chat startup performance investigation"]
166+
fn chat_startup_benchmark() {
167+
let dir = tempfile::tempdir().unwrap();
168+
let key = SqlCipherKey::new("synthetic chat benchmark").unwrap();
169+
let store =
170+
SqliteAccountStorage::open_encrypted(dir.path().join("account.sqlite3"), &key).unwrap();
171+
store
172+
.save_account_projection_state(
173+
&StoredAccountState {
174+
label: "benchmark".to_owned(),
175+
groups: vec![group()],
176+
..StoredAccountState::default()
177+
},
178+
256,
179+
MAX_FUTURE_SKEW_SECS,
180+
)
181+
.unwrap();
182+
store
183+
.lock()
184+
.unwrap()
185+
.execute_batch("DROP INDEX idx_message_timeline_group_received")
186+
.unwrap();
187+
let mut seeded = 0;
188+
let body = "x".repeat(512);
189+
for count in [10_000, 100_000] {
190+
cgka_traits::StorageProvider::with_transaction(&store, |store| {
191+
for index in seeded..count {
192+
let mut event = chat(&format!("{index:064x}"), REMOTE, index, &body);
193+
event.source_epoch = Some(1);
194+
store.record_app_event(&event)?;
195+
}
196+
Ok::<_, cgka_traits::StorageError>(())
197+
})
198+
.unwrap();
199+
seeded = count;
200+
let id = format!("{:064x}", count - 1);
201+
store
202+
.mark_timeline_message_read(LOCAL, GROUP, &id, &no_mentions)
203+
.unwrap();
204+
store.ensure_chat_list_rows(LOCAL, &no_mentions).unwrap();
205+
let expected = store.chat_list_rows(ChatListQuery::default()).unwrap();
206+
assert_eq!(expected.len(), 1);
207+
assert_eq!(
208+
expected[0].last_message.as_ref().unwrap().message_id_hex,
209+
id
210+
);
211+
assert_eq!(expected[0].unread_count, 0);
212+
// Repeat A/B after dropping the index to expose cache/order effects.
213+
for mode in ["baseline", "indexed", "baseline", "indexed"] {
214+
if mode == "indexed" {
215+
store.lock().unwrap().execute_batch("CREATE INDEX idx_message_timeline_group_received ON message_timeline(group_id_hex, received_at)").unwrap();
216+
}
217+
store.ensure_chat_list_rows(LOCAL, &no_mentions).unwrap();
218+
let mut samples = Vec::new();
219+
for _ in 0..7 {
220+
let start = std::time::Instant::now();
221+
store.ensure_chat_list_rows(LOCAL, &no_mentions).unwrap();
222+
let rows = store.chat_list_rows(ChatListQuery::default()).unwrap();
223+
samples.push(start.elapsed().as_micros());
224+
assert_eq!(rows, expected);
225+
}
226+
samples.sort_unstable();
227+
eprintln!("count={count} mode={mode} median_us={}", samples[3]);
228+
if mode == "indexed" {
229+
store
230+
.lock()
231+
.unwrap()
232+
.execute_batch("DROP INDEX idx_message_timeline_group_received")
233+
.unwrap();
234+
}
235+
}
236+
}
237+
}
238+
161239
/// Preview work stays bounded for accepted history and displaced pending sends.
162240
#[test]
163241
fn preview_query_work() {

crates/storage-sqlite/src/encrypted_media_secrets.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,24 +245,29 @@ pub(crate) fn replace_encrypted_media_secret_references_for_parts_tx(
245245
&& let Some(source_epoch) = source_epoch
246246
{
247247
let source_epoch = u64_to_i64(source_epoch)?;
248-
for component_id in encrypted_media_component_ids(tags) {
248+
let component_ids = encrypted_media_component_ids(tags);
249+
for component_id in &component_ids {
249250
tx.execute_cached(
250251
"INSERT INTO encrypted_media_epoch_secret_references (
251252
group_id_hex, message_id_hex, component_id, source_epoch
252253
) VALUES (?1, ?2, ?3, ?4)",
253254
params![
254255
group_id_hex,
255256
message_id_hex,
256-
i64::from(component_id),
257+
i64::from(*component_id),
257258
source_epoch,
258259
],
259260
)
260261
.storage()?;
262+
}
263+
// All media formats share the exporter; mark its cached rows once.
264+
if !component_ids.is_empty() {
261265
tx.execute_cached(
262266
"UPDATE encrypted_media_epoch_secrets
263267
SET retention_managed = 1
264268
WHERE group_id_hex = ?1
265-
AND source_epoch = ?2",
269+
AND source_epoch = ?2
270+
AND retention_managed = 0",
266271
params![group_id_hex, source_epoch],
267272
)
268273
.storage()?;

crates/storage-sqlite/src/migrations.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ mod migration_0065_chat_presentation;
132132
mod migration_0066_chat_presentation_maintenance;
133133
#[path = "migrations/0067_invitation_recovery.rs"]
134134
mod migration_0067_invitation_recovery;
135+
#[path = "migrations/0068_media_epoch_index.rs"]
136+
mod migration_0068_media_epoch_index;
137+
#[path = "migrations/0069_chat_readiness_index.rs"]
138+
mod migration_0069_chat_readiness_index;
135139
#[cfg(test)]
136140
#[path = "migrations/query_work_tests.rs"]
137141
mod query_work_tests;
@@ -485,6 +489,16 @@ const MIGRATIONS: &[Migration] = &[
485489
name: "0067_invitation_recovery",
486490
apply: migration_0067_invitation_recovery::apply,
487491
},
492+
Migration {
493+
version: 68,
494+
name: "0068_media_epoch_index",
495+
apply: migration_0068_media_epoch_index::apply,
496+
},
497+
Migration {
498+
version: 69,
499+
name: "0069_chat_readiness_index",
500+
apply: migration_0069_chat_readiness_index::apply,
501+
},
488502
];
489503

490504
pub(crate) fn run_all(connection: &mut Connection) -> StorageResult<usize> {
@@ -1255,7 +1269,7 @@ mod tests {
12551269
assert!(matches!(
12561270
error,
12571271
StorageError::UnsupportedSchemaVersion {
1258-
found: 67,
1272+
found: 69,
12591273
latest_supported: 46,
12601274
}
12611275
));
@@ -1311,7 +1325,7 @@ mod tests {
13111325
assert!(matches!(
13121326
error,
13131327
StorageError::UnsupportedSchemaVersion {
1314-
found: 67,
1328+
found: 69,
13151329
latest_supported: 46,
13161330
}
13171331
));
@@ -1615,7 +1629,7 @@ mod tests {
16151629
assert!(matches!(
16161630
error,
16171631
StorageError::UnsupportedSchemaVersion {
1618-
found: 67,
1632+
found: 69,
16191633
latest_supported: 46,
16201634
}
16211635
));
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
use crate::SqliteResultExt;
2+
use cgka_traits::storage::StorageResult;
3+
use rusqlite::Transaction;
4+
5+
pub(super) fn apply(tx: &Transaction<'_>) -> StorageResult<()> {
6+
// Both first-reference retention and retirement address an entire epoch.
7+
tx.execute_batch(
8+
"DROP INDEX IF EXISTS idx_media_secrets_retirement;
9+
CREATE INDEX IF NOT EXISTS idx_media_secrets_epoch
10+
ON encrypted_media_epoch_secrets(group_id_hex, source_epoch);",
11+
)
12+
.storage()
13+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
use crate::SqliteResultExt;
2+
use cgka_traits::storage::StorageResult;
3+
use rusqlite::Transaction;
4+
5+
pub(super) fn apply(tx: &Transaction<'_>) -> StorageResult<()> {
6+
// Readiness probes need the latest receipt, regardless of preview eligibility.
7+
tx.execute_batch(
8+
"CREATE INDEX IF NOT EXISTS idx_message_timeline_group_received
9+
ON message_timeline(group_id_hex, received_at);",
10+
)
11+
.storage()
12+
}

0 commit comments

Comments
 (0)