feat(peer): add transactional local game operations

Implement the peer-owned state model from PLAN.md. A root-level version.ini
is now the download completion sentinel, local/ as a directory is the install
predicate, and exact root-level version.ini detection prevents nested files
from becoming sentinels by accident.

Add the peer operation table that gates downloads, installs, updates, and
uninstalls by game ID. Serving paths now reject non-catalog games, active
operations, missing sentinels, and any request that points under local/.
Remote aggregation treats LocalOnly peers as non-downloadable so they do not
contribute peer counts, candidate source selection, or latest-version checks.

Move install-side filesystem mutation into lanspread-peer::install. The new
module writes atomic .lanspread.json intents, uses .local.installing and
.local.backup with .lanspread_owned markers, and performs startup recovery
from recorded intent plus filesystem state. Downloads now buffer version.ini
chunks in memory and commit the sentinel last through .version.ini.tmp.

Replace the fixed 15-second monitor with notify-backed non-recursive watches,
per-ID rescan gating, and a 300-second fallback scan. The optimized rescan
path updates one cached library-index entry and active operation IDs preserve
their previous summary during scans.

Test Plan:
- just fmt
- just clippy
- just test
- just build

Refs: PLAN.md
This commit is contained in:
2026-05-15 18:18:55 +02:00
parent bff58c6013
commit 6c8a2bb9f0
21 changed files with 2652 additions and 246 deletions
+111 -16
View File
@@ -8,7 +8,7 @@ use std::{
};
use lanspread_db::db::{Game, GameFileDescription};
use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot};
use lanspread_proto::{Availability, GameSummary, LibraryDelta, LibrarySnapshot};
use crate::library::compute_library_digest;
pub type PeerId = String;
@@ -265,8 +265,8 @@ impl PeerGameDB {
// Count peers per game
for peer in self.peers.values() {
for game_id in peer.games.keys() {
*peer_counts.entry(game_id.clone()).or_insert(0) += 1;
for game in peer.games.values().filter(|game| game_is_ready(game)) {
*peer_counts.entry(game.id.clone()).or_insert(0) += 1;
}
}
@@ -276,20 +276,22 @@ impl PeerGameDB {
aggregated
.entry(game.id.clone())
.and_modify(|existing| {
if let (Some(new_version), Some(current)) =
(&game.eti_version, &existing.eti_game_version)
{
if new_version > current {
existing.eti_game_version = Some(new_version.clone());
if game_is_ready(game) {
if let (Some(new_version), Some(current)) =
(&game.eti_version, &existing.eti_game_version)
{
if new_version > current {
existing.eti_game_version = Some(new_version.clone());
}
} else if existing.eti_game_version.is_none() {
existing.eti_game_version.clone_from(&game.eti_version);
}
} else if existing.eti_game_version.is_none() {
existing.eti_game_version.clone_from(&game.eti_version);
}
existing.peer_count = peer_counts[&game.id];
existing.peer_count = *peer_counts.get(&game.id).unwrap_or(&0);
if game.size > existing.size {
existing.size = game.size;
}
if game.downloaded {
if game_is_ready(game) {
existing.downloaded = true;
}
if game.installed {
@@ -298,7 +300,8 @@ impl PeerGameDB {
})
.or_insert_with(|| {
let mut game_clone = summary_to_game(game);
game_clone.peer_count = peer_counts[&game.id];
game_clone.peer_count = *peer_counts.get(&game.id).unwrap_or(&0);
game_clone.downloaded = game_is_ready(game);
game_clone
});
}
@@ -316,6 +319,7 @@ impl PeerGameDB {
for peer in self.peers.values() {
if let Some(game) = peer.games.get(game_id)
&& game_is_ready(game)
&& let Some(ref version) = game.eti_version
{
match &latest_version {
@@ -373,7 +377,7 @@ impl PeerGameDB {
pub fn peers_with_game(&self, game_id: &str) -> Vec<SocketAddr> {
self.peers
.iter()
.filter(|(_, peer)| peer.games.contains_key(game_id))
.filter(|(_, peer)| peer.games.get(game_id).is_some_and(game_is_ready))
.map(|(_, peer)| peer.addr)
.collect()
}
@@ -388,7 +392,9 @@ impl PeerGameDB {
.iter()
.filter(|(_, peer)| {
if let Some(game) = peer.games.get(game_id) {
if let Some(ref version) = game.eti_version {
if game_is_ready(game)
&& let Some(ref version) = game.eti_version
{
version == latest
} else {
false
@@ -411,6 +417,9 @@ impl PeerGameDB {
self.peers
.values()
.filter_map(|peer| {
if !peer.games.get(game_id).is_some_and(game_is_ready) {
return None;
}
peer.files
.get(game_id)
.cloned()
@@ -438,6 +447,9 @@ impl PeerGameDB {
for peer in self.peers.values() {
if let Some(game) = peer.games.get(game_id) {
if !game_is_ready(game) {
continue;
}
if game.size == 0 {
continue;
}
@@ -711,7 +723,15 @@ fn create_peer_whitelist(peer_scores: HashMap<SocketAddr, usize>) -> Vec<SocketA
peers.into_iter().map(|(peer, _)| peer).collect()
}
fn game_is_ready(summary: &GameSummary) -> bool {
summary.availability == Availability::Ready
}
fn summary_to_game(summary: &GameSummary) -> Game {
let eti_game_version = game_is_ready(summary)
.then(|| summary.eti_version.clone())
.flatten();
Game {
id: summary.id.clone(),
name: summary.name.clone(),
@@ -724,8 +744,83 @@ fn summary_to_game(summary: &GameSummary) -> Game {
size: summary.size,
downloaded: summary.downloaded,
installed: summary.installed,
eti_game_version: summary.eti_version.clone(),
eti_game_version,
local_version: None,
peer_count: 0,
}
}
#[cfg(test)]
mod tests {
use std::net::SocketAddr;
use super::*;
fn addr(port: u16) -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], port))
}
fn summary(id: &str, version: &str, availability: Availability) -> GameSummary {
GameSummary {
id: id.to_string(),
name: id.to_string(),
size: 42,
downloaded: availability == Availability::Ready,
installed: true,
eti_version: Some(version.to_string()),
manifest_hash: 7,
availability,
}
}
#[test]
fn aggregation_counts_only_ready_peers_as_download_sources() {
let ready_addr = addr(12000);
let local_only_addr = addr(12001);
let mut db = PeerGameDB::new();
db.upsert_peer("ready".to_string(), ready_addr);
db.upsert_peer("local".to_string(), local_only_addr);
db.update_peer_games(
&"ready".to_string(),
vec![summary("game", "20240101", Availability::Ready)],
);
db.update_peer_games(
&"local".to_string(),
vec![summary("game", "20990101", Availability::LocalOnly)],
);
let games = db.get_all_games();
assert_eq!(games.len(), 1);
assert_eq!(games[0].peer_count, 1);
assert!(games[0].downloaded);
assert_eq!(games[0].eti_game_version.as_deref(), Some("20240101"));
assert_eq!(db.peers_with_game("game"), vec![ready_addr]);
assert_eq!(
db.get_latest_version_for_game("game").as_deref(),
Some("20240101")
);
assert_eq!(db.peers_with_latest_version("game"), vec![ready_addr]);
}
#[test]
fn local_only_peer_does_not_make_game_downloadable() {
let local_only_addr = addr(12002);
let mut db = PeerGameDB::new();
db.upsert_peer("local".to_string(), local_only_addr);
db.update_peer_games(
&"local".to_string(),
vec![summary("game", "20240101", Availability::LocalOnly)],
);
let games = db.get_all_games();
assert_eq!(games.len(), 1);
assert_eq!(games[0].peer_count, 0);
assert!(!games[0].downloaded);
assert_eq!(games[0].eti_game_version, None);
assert!(db.peers_with_game("game").is_empty());
assert_eq!(db.get_latest_version_for_game("game"), None);
assert!(db.peers_with_latest_version("game").is_empty());
}
}