refactor(call-to-play): merge histories atomically

Replace per-event insertion with a transactional batch merge. The store now
validates and deduplicates an entire history before committing it, rejects
conflicting event IDs without partial mutation, evaluates retention after all
batch events are present, and reports applied, duplicate, obsolete, and
missing-root outcomes explicitly.

Derive event identity from retained history instead of preserving an unbounded
ID set. Expired histories can therefore be restored by a complete Create plus
AddTime batch, while orphan actions request history and terminal tombstones
continue to reject stale resurrection. Capacity applies only to unresolved
history, allowing Start and Cancel to settle a full call.

Only retained events reach the UI or live broadcast path. Handshake and live
merge callers log invalid or incomplete histories without publishing events
that compaction discarded.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
ddidderr committed 2026-07-23 17:44:03 +02:00
1 parent 872692e3f4
commit be7ad2e560
4 files changed
+582 -216

No files matched your search

@@ -195,9 +195,21 @@ async fn merge_call_to_play_events(
tx_notify_ui: &UnboundedSender<PeerEvent>,
incoming: Vec<lanspread_proto::CallToPlayEvent>,
) {
let accepted = store.write().await.insert_all(incoming);
if !accepted.is_empty() {
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(accepted));
match store.write().await.merge_batch(incoming) {
Ok(merged) => {
if merged.needs_history() {
log::warn!(
"Call to Play handshake omitted roots for calls: {}",
merged.missing_call_ids.join(", ")
);
}
if !merged.applied.is_empty() {
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied));
}
}
Err(err) => {
log::warn!("Rejecting Call to Play handshake history: {err}");
}
}
}
@@ -371,8 +383,8 @@ mod tests {
ctx.call_to_play
.write()
.await
.insert(call_to_play_event())
.expect("valid event should be inserted");
.merge_batch(vec![call_to_play_event()])
.expect("valid event should be merged");
let hello = build_hello_from_state(&ctx)
.await
+18 -6
View File
@@ -146,12 +146,24 @@ async fn handle_call_to_play_events(
return;
}
let accepted = ctx.call_to_play.write().await.insert_all(incoming);
if !accepted.is_empty() {
events::send(
&ctx.tx_notify_ui,
crate::PeerEvent::CallToPlayEvents(accepted),
);
match ctx.call_to_play.write().await.merge_batch(incoming) {
Ok(merged) => {
if merged.needs_history() {
log::warn!(
"Ignoring Call to Play actions without history from {peer_id}: {}",
merged.missing_call_ids.join(", ")
);
}
if !merged.applied.is_empty() {
events::send(
&ctx.tx_notify_ui,
crate::PeerEvent::CallToPlayEvents(merged.applied),
);
}
}
Err(err) => {
log::warn!("Rejecting Call to Play events from {peer_id}: {err}");
}
}
}