fix(call-to-play): expire elapsed calls clearly
Deadline completion previously shared the green Ready presentation with a full roster and remained visible forever. An abandoned call therefore looked ready to launch and required its creator to return and cancel it. Give elapsed calls a distinct Time's up state and a five-minute grace period in which the creator can start or extend them. After that, both the reducer and peer store remove the call as a unit. Filled calls remain ready until their deadline, and active calls continue to retain complete history for late joiners. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed, 147 peer tests - `just frontend-test` -- passed, 22 tests - `just build` -- passed - `git diff --cached --check` -- passed
This commit is contained in:
@@ -56,7 +56,9 @@ or `Cancel` event replaces that terminal call with one small tombstone; this
|
||||
lets a peer that missed the live action heal on its next handshake without
|
||||
retaining the inactive call's full history. Active calls are never partially
|
||||
trimmed. If genuinely active history reaches the bound, local publishes return
|
||||
an error to the caller instead of appearing to succeed. A
|
||||
an error to the caller instead of appearing to succeed. A call whose deadline
|
||||
elapses remains available for five minutes so the creator can start or extend
|
||||
it, then its history is evicted as a unit. A
|
||||
local action is applied to that history, sent to the UI, and broadcast to every
|
||||
currently known peer. An incoming live event is applied once and sent to the UI
|
||||
without being rebroadcast, which prevents forwarding loops.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Replicated event history for Call to Play coordination.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_proto::{CallToPlayAction, CallToPlayEvent};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
@@ -12,6 +15,7 @@ const MAX_ID_CHARS: usize = 128;
|
||||
const MAX_GAME_ID_CHARS: usize = 256;
|
||||
const MAX_USERNAME_CHARS: usize = 24;
|
||||
const MAX_MESSAGE_CHARS: usize = 500;
|
||||
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct CallToPlayStore {
|
||||
@@ -33,7 +37,7 @@ impl CallToPlayStore {
|
||||
let event_id = event.id.clone();
|
||||
self.event_ids.insert(event_id.clone());
|
||||
self.events.push(event);
|
||||
self.evict_terminal_calls();
|
||||
self.compact_inactive_calls(now_ms());
|
||||
if self.events.len() > MAX_EVENTS {
|
||||
self.events.retain(|event| event.id != event_id);
|
||||
self.event_ids.remove(&event_id);
|
||||
@@ -54,13 +58,13 @@ impl CallToPlayStore {
|
||||
accepted
|
||||
}
|
||||
|
||||
fn evict_terminal_calls(&mut self) {
|
||||
let mut creators = HashMap::<String, (i64, String, String)>::new();
|
||||
fn compact_inactive_calls(&mut self, now: i64) {
|
||||
let mut creators = HashMap::<String, (i64, String, String, i64)>::new();
|
||||
for event in &self.events {
|
||||
if !matches!(event.action, CallToPlayAction::Create { .. }) {
|
||||
let CallToPlayAction::Create { deadline, .. } = event.action else {
|
||||
continue;
|
||||
}
|
||||
let candidate = (event.at, event.id.clone(), event.actor_id.clone());
|
||||
};
|
||||
let candidate = (event.at, event.id.clone(), event.actor_id.clone(), deadline);
|
||||
creators
|
||||
.entry(event.call_id.clone())
|
||||
.and_modify(|current| {
|
||||
@@ -73,7 +77,7 @@ impl CallToPlayStore {
|
||||
|
||||
let mut terminal_events = HashMap::<String, (i64, String)>::new();
|
||||
for event in &self.events {
|
||||
let Some((created_at, create_id, creator_id)) = creators.get(&event.call_id) else {
|
||||
let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(
|
||||
@@ -94,7 +98,42 @@ impl CallToPlayStore {
|
||||
})
|
||||
.or_insert(candidate);
|
||||
}
|
||||
|
||||
let mut extensions = HashMap::<String, (i64, String, i64)>::new();
|
||||
for event in &self.events {
|
||||
let CallToPlayAction::AddTime { deadline } = event.action else {
|
||||
continue;
|
||||
};
|
||||
let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else {
|
||||
continue;
|
||||
};
|
||||
if event.actor_id != *creator_id || (event.at, &event.id) <= (*created_at, create_id) {
|
||||
continue;
|
||||
}
|
||||
let candidate = (event.at, event.id.clone(), deadline);
|
||||
extensions
|
||||
.entry(event.call_id.clone())
|
||||
.and_modify(|current| {
|
||||
if (candidate.0, &candidate.1) > (current.0, ¤t.1) {
|
||||
current.clone_from(&candidate);
|
||||
}
|
||||
})
|
||||
.or_insert(candidate);
|
||||
}
|
||||
let expired_calls = creators
|
||||
.iter()
|
||||
.filter_map(|(call_id, (_, _, _, original_deadline))| {
|
||||
let deadline = extensions
|
||||
.get(call_id)
|
||||
.map_or(*original_deadline, |(_, _, deadline)| *deadline);
|
||||
(now - deadline > EXPIRED_RETENTION_MS).then(|| call_id.clone())
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
self.events.retain(|event| {
|
||||
if expired_calls.contains(&event.call_id) {
|
||||
return false;
|
||||
}
|
||||
terminal_events
|
||||
.get(&event.call_id)
|
||||
.is_none_or(|(_, terminal_id)| event.id == *terminal_id)
|
||||
@@ -102,6 +141,16 @@ impl CallToPlayStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
i64::try_from(
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis(),
|
||||
)
|
||||
.unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
pub(crate) async fn publish(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
@@ -208,18 +257,20 @@ mod tests {
|
||||
|
||||
use super::{CallToPlayStore, MAX_EVENTS};
|
||||
|
||||
const TEST_NOW: i64 = 8_000_000_000_000;
|
||||
|
||||
fn create_event(id: &str) -> CallToPlayEvent {
|
||||
CallToPlayEvent {
|
||||
id: id.to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
actor_id: "peer-alice".to_string(),
|
||||
actor_name: "Alice".to_string(),
|
||||
at: 1_000,
|
||||
at: TEST_NOW,
|
||||
action: CallToPlayAction::Create {
|
||||
game_id: "game-1".to_string(),
|
||||
max_players: 4,
|
||||
scheduled_for: None,
|
||||
deadline: 61_000,
|
||||
deadline: TEST_NOW + 60_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -230,7 +281,7 @@ mod tests {
|
||||
call_id: call_id.to_string(),
|
||||
actor_id: "peer-alice".to_string(),
|
||||
actor_name: "Alice".to_string(),
|
||||
at: 2_000,
|
||||
at: TEST_NOW + 1_000,
|
||||
action,
|
||||
}
|
||||
}
|
||||
@@ -360,4 +411,16 @@ mod tests {
|
||||
);
|
||||
assert_eq!(store.snapshot().len(), MAX_EVENTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_call_history_is_evicted_as_a_unit() {
|
||||
let mut store = CallToPlayStore::default();
|
||||
store
|
||||
.insert(create_event("create"))
|
||||
.expect("active call should fit");
|
||||
|
||||
store.compact_inactive_calls(TEST_NOW + 5 * 60_000 + 60_001);
|
||||
|
||||
assert!(store.snapshot().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,12 +315,12 @@ mod tests {
|
||||
call_id: "call-1".to_string(),
|
||||
actor_id: "peer-alice".to_string(),
|
||||
actor_name: "Alice".to_string(),
|
||||
at: 1_000,
|
||||
at: 8_000_000_000_000,
|
||||
action: CallToPlayAction::Create {
|
||||
game_id: "game".to_string(),
|
||||
max_players: 4,
|
||||
scheduled_for: None,
|
||||
deadline: 61_000,
|
||||
deadline: 8_000_000_060_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user