Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
19 changes: 18 additions & 1 deletion crates/cgka-session/tests/session_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,24 @@ async fn session_advance_convergence_releases_queued_outbound_work() {
..CanonicalizationPolicy::default()
})
.expect("convergence policy accepted");
let advanced = carol.advance_convergence(&created.group_id).await.unwrap();
// Background convergence is a cooperative quantum, not a drain-to-completion API.
// Under CI load it can adopt epoch 2 and exhaust its 500ms budget before draining
// the queued intent. Follow the scheduled continuation as the runtime worker does.
// Check the deadline between complete calls; never cancel a live MLS/storage step.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut advanced = carol.advance_convergence(&created.group_id).await.unwrap();
while advanced.publish.is_empty() {
assert!(
advanced.pending_convergence.contains(&created.group_id),
"queued outbound work must publish or schedule another convergence quantum"
);
assert!(
std::time::Instant::now() < deadline,
"queued outbound work did not publish within the convergence deadline"
);
tokio::task::yield_now().await;
advanced = carol.advance_convergence(&created.group_id).await.unwrap();
}

assert_eq!(carol.epoch(&created.group_id).unwrap(), EpochId(2));
assert!(
Expand Down
91 changes: 73 additions & 18 deletions crates/storage-sqlite/src/chat_list.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod pages;
use crate::account_projection::chat_mute_is_effective;
use crate::connection::CachedSql;
use crate::storage::disband_requests::{
Expand All @@ -16,6 +17,10 @@ use cgka_traits::app_event::{
MARMOT_APP_EVENT_KIND_CHAT, MARMOT_APP_EVENT_KIND_GROUP_SYSTEM,
};
use cgka_traits::storage::StorageResult;
pub use pages::{
ChatListCursor, ChatListPage, ChatListPageDirection, ChatListPageError, ChatListPageQuery,
ChatListView,
};
use rusqlite::{Connection, OptionalExtension, Params, params};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
Expand Down Expand Up @@ -525,7 +530,7 @@ impl SqliteAccountStorage {
pinned: bool,
) -> Result<ChatPinState, ChatPinError> {
self.connection.with_transaction(|| {
let conn = self.lock()?;
let mut conn = self.lock()?;
let archived = conn
.query_row_cached(
"SELECT archived FROM account_groups WHERE group_id_hex = ?1",
Expand All @@ -547,11 +552,11 @@ impl SqliteAccountStorage {
match (pinned, existing) {
(true, None) => {
ordered_group_ids.insert(0, group_id_hex.to_owned());
rewrite_pinned_chat_order_tx(&conn, &ordered_group_ids)?;
rewrite_pinned_chat_order_tx(&mut conn, &ordered_group_ids)?;
}
(false, Some(position)) => {
ordered_group_ids.remove(position);
rewrite_pinned_chat_order_tx(&conn, &ordered_group_ids)?;
rewrite_pinned_chat_order_tx(&mut conn, &ordered_group_ids)?;
}
_ => {}
}
Expand All @@ -571,7 +576,7 @@ impl SqliteAccountStorage {
ordered_group_ids: &[String],
) -> Result<ChatPinState, ChatPinError> {
self.connection.with_transaction(|| {
let conn = self.lock()?;
let mut conn = self.lock()?;
for group_id_hex in ordered_group_ids {
let exists = conn
.query_row_cached(
Expand Down Expand Up @@ -601,7 +606,7 @@ impl SqliteAccountStorage {
));
}
if current != ordered_group_ids {
rewrite_pinned_chat_order_tx(&conn, ordered_group_ids)?;
rewrite_pinned_chat_order_tx(&mut conn, ordered_group_ids)?;
}
Ok(ChatPinState {
ordered_group_ids: ordered_group_ids.to_vec(),
Expand Down Expand Up @@ -930,23 +935,47 @@ fn pinned_chat_order_tx(tx: &Connection) -> Result<Vec<String>, ChatPinError> {
}

fn rewrite_pinned_chat_order_tx(
tx: &Connection,
conn: &mut Connection,
ordered_group_ids: &[String],
) -> Result<(), ChatPinError> {
// A savepoint also protects callers that catch this command's error inside an outer
// transaction and then commit: the guard, source pins and derived keys roll back together.
let tx = conn.savepoint().storage()?;
tx.execute_cached(
"UPDATE chat_list_navigation_meta SET pin_rewrite_in_progress = 1 WHERE id = 1",
[],
)
.storage()?;
// Reset only previously pinned rows, never the complete chat list. The final order is
// already normalized by this command, so each insert can stamp its rank with one keyed
// update, including the ordinal occupied by any pin whose projected row is absent.
tx.execute_cached(
"UPDATE chat_list_rows INDEXED BY idx_chat_list_pin_ordinal
SET list_pin_ordinal = -1, list_pin_position = NULL WHERE list_pin_ordinal >= 0",
[],
)
.storage()?;
tx.execute_cached("DELETE FROM chat_pin_positions", [])
.storage()?;
for (ordinal, group_id_hex) in ordered_group_ids.iter().enumerate() {
let ordinal = i64::try_from(ordinal)
.map_err(|_| ChatPinError::InvalidOrder("too many pinned chats".to_owned()))?;
tx.execute_cached(
"INSERT INTO chat_pin_positions (group_id_hex, ordinal)
VALUES (?1, ?2)",
params![
group_id_hex,
i64::try_from(ordinal)
.map_err(|_| ChatPinError::InvalidOrder("too many pinned chats".to_owned()))?
],
"INSERT INTO chat_pin_positions (group_id_hex, ordinal) VALUES (?1, ?2)",
params![group_id_hex, ordinal],
)
.storage()?;
tx.execute_cached(
"UPDATE chat_list_rows SET list_pin_ordinal = ?2, list_pin_position = ?2 WHERE group_id_hex = ?1",
params![group_id_hex, ordinal],
).storage()?;
}
tx.execute_cached(
"UPDATE chat_list_navigation_meta SET pin_rewrite_in_progress = 0 WHERE id = 1",
[],
)
.storage()?;
tx.commit().storage()?;
Ok(())
}

Expand Down Expand Up @@ -2440,9 +2469,15 @@ pub(crate) fn chat_list_row_tx(
.transpose()
}

// All chat reads share these columns; only pin-rank computation differs.
const CHAT_LIST_ROW_SELECT_LIST: &str =
"SELECT row.group_id_hex, row.archived, row.pending_confirmation,
// Keep positional decoding shared, with explicit source-field selection for bounded pages.
macro_rules! chat_list_columns {
($archived:literal, $pending:literal, $membership:literal) => {
concat!(
"SELECT row.group_id_hex, ",
$archived,
", ",
$pending,
",
row.title, row.group_name, row.avatar_url,
row.avatar_image_hash_hex, row.avatar_image_key_hex,
row.avatar_image_nonce_hex, row.avatar_image_upload_key_hex,
Expand All @@ -2454,15 +2489,35 @@ const CHAT_LIST_ROW_SELECT_LIST: &str =
row.manually_marked_unread, row.unread_mention_count,
row.first_unread_message_id_hex, row.last_read_message_id_hex,
row.last_read_timeline_at, row.conversation_created_at,
row.activity_sort_at, row.updated_at, row.self_membership,
row.activity_sort_at, row.updated_at, ",
$membership,
",
ag.member_count,
mute.group_id_hex IS NOT NULL,
mute.muted_until_ms,
EXISTS (
SELECT 1 FROM cgka_disband_tombstones AS tomb
WHERE lower(hex(tomb.group_id)) = lower(row.group_id_hex)
),
pin.group_id_hex IS NOT NULL,";
pin.group_id_hex IS NOT NULL,"
)
};
}
const CHAT_LIST_ROW_SELECT_LIST: &str = chat_list_columns!(
"row.archived",
"row.pending_confirmation",
"row.self_membership"
);
// M1's new page contract observes committed account-source lifecycle fields immediately.
// Legacy rows retain their existing projector publication timing. Copying source fields into
// those rows from these new triggers would also change existing readers/subscriptions without
// their refresh notifications. M2 owns runtime command/invalidation integration; migrating
// legacy publication is a separate compatibility change, not a side effect of storage paging.
const CHAT_LIST_PAGE_SELECT_LIST: &str = chat_list_columns!(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two divergent truths for the same three fields, and a macro to express the divergence.

chat_list_page now returns COALESCE(ag.archived, row.archived) while chat_list_row / chat_list_rows still return row.archived, so the two read paths can disagree about archived, pending_confirmation and self_membership for the same conversation. accepting_an_archived_invite_preserves_archive_and_source_fields asserts that divergence directly:

assert!(!active.rows[0].archived);
assert!(store.chat_list_row("01").unwrap().unwrap().archived);

I checked why the overlay is load-bearing: set_group_self_membership (and the archive writers) update account_groups without refreshing the chat_list_rows projection, so the projection really can lag. But that means this PR routes around a stale-projection bug in a new API instead of fixing it, and leaves every existing caller reading the stale value.

Migration 0070 already installs chat_list_keys_account_groups_{INSERT,UPDATE,DELETE} firing on exactly archived, pending_confirmation, self_membership. Extending refresh to also copy those three columns into chat_list_rows would:

  • make the projection self-healing at the same source-write boundary you already chose,
  • fix the legacy read paths rather than forking them,
  • let the page select row.archived / row.pending_confirmation / row.self_membership directly,
  • and delete chat_list_columns!, CHAT_LIST_PAGE_SELECT_LIST and the whole diff to this file.

If there's a reason the projection deliberately must not track the source here, that belongs in a comment — right now the macro documents the mechanism but not the motivation. And if the overlay stays, the pinned flag is still read from chat_pin_positions while ordering uses the projected list_pin_ordinal, which is a third instance of the same source/projection split in one row.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented the compatibility reason beside CHAT_LIST_PAGE_SELECT_LIST in f813315a. The new page contract reads committed account-source state immediately. Legacy readers retain their existing projector publication timing. Copying these fields into legacy rows from new triggers would alter existing reader/subscription behavior without their associated runtime refresh notifications. #1777 explicitly leaves legacy behavior unchanged in M1 and assigns authoritative command/invalidation integration to M2. I am keeping that migration boundary instead of silently expanding this storage PR into a legacy publication change. Pin source and cached ordinal remain maintained in the same source transaction and are checked by the pin-gap/recreation tests.

"COALESCE(ag.archived, row.archived)",
"COALESCE(ag.pending_confirmation, row.pending_confirmation)",
"COALESCE(ag.self_membership, row.self_membership)"
);

const CHAT_PIN_POSITION_SQL: &str = "CASE WHEN pin.ordinal IS NULL THEN NULL ELSE (
SELECT COUNT(*)
Expand Down
Loading
Loading