feat(kanban): persist canonical acknowledgements

req: sync/001\nreq: sync/005\nreq: sync/007\nreq: sync/008\nreq: sync/013
This commit is contained in:
slhx agent
2026-07-13 16:41:18 +02:00
parent 599d1b838d
commit 0a7c380187
3 changed files with 316 additions and 32 deletions
+185 -30
View File
@@ -14,10 +14,13 @@ use hemx_axum::{
use hemx_kanban_example::ui::board::{self as board};
use hemx_kanban_example::ui::board_card as card_board;
use hemx_kanban_example::ui::{self, board as board_ui};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::convert::Infallible;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -27,8 +30,12 @@ const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"),
struct AppState {
board: Mutex<BoardState>,
sync: Mutex<SyncState>,
sync_store: Option<SyncStore>,
}
#[derive(Clone)]
struct SyncStore(PathBuf);
#[derive(Default)]
struct SyncState {
next_sequence: u64,
@@ -43,7 +50,10 @@ struct CommandId(String);
impl CommandId {
fn parse(value: Option<&String>) -> Result<Self, SyncRejection> {
let value = value.map(String::as_str).unwrap_or_default();
Self::parse_str(value.map(String::as_str).unwrap_or_default())
}
fn parse_str(value: &str) -> Result<Self, SyncRejection> {
if value.is_empty()
|| value.len() > 128
|| !value.bytes().all(|byte| {
@@ -71,6 +81,7 @@ enum SyncRejection {
BadRequest(&'static str),
Conflict(&'static str),
Transient,
Storage,
}
impl IntoResponse for SyncRejection {
@@ -79,11 +90,125 @@ impl IntoResponse for SyncRejection {
Self::BadRequest(error) => (StatusCode::BAD_REQUEST, error),
Self::Conflict(error) => (StatusCode::CONFLICT, error),
Self::Transient => (StatusCode::SERVICE_UNAVAILABLE, "transient sync failure"),
Self::Storage => (StatusCode::INTERNAL_SERVER_ERROR, "sync storage failed"),
};
(status, Json(serde_json::json!({ "error": error }))).into_response()
}
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PersistedSync {
schema_version: u8,
next_sequence: u64,
acknowledgements: Vec<PersistedAcknowledgement>,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PersistedAcknowledgement {
command_id: String,
server_sequence: u64,
card_id: u64,
}
impl SyncStore {
fn load(&self) -> Result<SyncState, String> {
if !self.0.exists() {
return Ok(SyncState {
next_sequence: 1,
..SyncState::default()
});
}
let bytes =
fs::read(&self.0).map_err(|error| format!("read {}: {error}", self.0.display()))?;
let persisted: PersistedSync = serde_json::from_slice(&bytes)
.map_err(|error| format!("decode {}: {error}", self.0.display()))?;
if persisted.schema_version != 1 || persisted.next_sequence == 0 {
return Err(format!("unsupported sync store {}", self.0.display()));
}
let mut acknowledgements = BTreeMap::new();
for stored in persisted.acknowledgements {
let command_id = CommandId::parse_str(&stored.command_id)
.map_err(|_| format!("invalid command id in {}", self.0.display()))?;
if stored.server_sequence == 0 || stored.card_id == 0 {
return Err(format!("invalid acknowledgement in {}", self.0.display()));
}
let acknowledgement = SyncAcknowledgement {
command_id: stored.command_id,
server_sequence: stored.server_sequence,
card_id: stored.card_id,
canonical_column: "done",
status: "accepted",
};
if acknowledgements
.insert(command_id, acknowledgement)
.is_some()
{
return Err(format!("duplicate command id in {}", self.0.display()));
}
}
Ok(SyncState {
next_sequence: persisted.next_sequence,
acknowledgements,
..SyncState::default()
})
}
fn persist(
&self,
sync: &SyncState,
acknowledgement: &SyncAcknowledgement,
) -> Result<(), String> {
let mut acknowledgements = sync
.acknowledgements
.values()
.map(PersistedAcknowledgement::from)
.collect::<Vec<_>>();
acknowledgements.push(PersistedAcknowledgement::from(acknowledgement));
acknowledgements.sort_by_key(|item| item.server_sequence);
let persisted = PersistedSync {
schema_version: 1,
next_sequence: acknowledgement.server_sequence + 1,
acknowledgements,
};
let bytes = serde_json::to_vec_pretty(&persisted)
.map_err(|error| format!("encode {}: {error}", self.0.display()))?;
if let Some(parent) = self.0.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("create {}: {error}", parent.display()))?;
}
let temporary = self.0.with_extension("tmp");
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&temporary)
.map_err(|error| format!("open {}: {error}", temporary.display()))?;
file.write_all(&bytes)
.and_then(|()| file.sync_all())
.map_err(|error| format!("write {}: {error}", temporary.display()))?;
fs::rename(&temporary, &self.0)
.map_err(|error| format!("replace {}: {error}", self.0.display()))?;
if let Some(parent) = self.0.parent() {
fs::File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| format!("sync {}: {error}", parent.display()))?;
}
Ok(())
}
}
impl From<&SyncAcknowledgement> for PersistedAcknowledgement {
fn from(value: &SyncAcknowledgement) -> Self {
Self {
command_id: value.command_id.clone(),
server_sequence: value.server_sequence,
card_id: value.card_id,
}
}
}
#[derive(Default, Clone)]
struct BoardState {
next_id: u64,
@@ -157,32 +282,33 @@ struct Presence {
#[tokio::main]
async fn main() {
let state = Arc::new(AppState {
board: Mutex::new(BoardState {
next_id: 4,
cards: vec![
Card {
id: 1,
title: "Write requirements".into(),
column: 0,
},
Card {
id: 2,
title: "Build browser example".into(),
column: 1,
},
Card {
id: 3,
title: "Verify with HTTP".into(),
column: 2,
},
],
}),
sync: Mutex::new(SyncState {
let sync_store = std::env::var_os("HEMX_KANBAN_SYNC_STORE")
.map(PathBuf::from)
.map(SyncStore);
let mut sync = sync_store
.as_ref()
.map(SyncStore::load)
.transpose()
.unwrap_or_else(|error| panic!("cannot start with sync store: {error}"))
.unwrap_or_else(|| SyncState {
next_sequence: 1,
fail_first_upload: std::env::var_os("HEMX_KANBAN_FAIL_FIRST_SYNC").is_some(),
..SyncState::default()
}),
});
sync.fail_first_upload = std::env::var_os("HEMX_KANBAN_FAIL_FIRST_SYNC").is_some();
let mut board = initial_board();
for acknowledgement in sync.acknowledgements.values() {
if let Some(card) = board
.cards
.iter_mut()
.find(|card| card.id == acknowledgement.card_id)
{
card.column = 2;
}
}
let state = Arc::new(AppState {
board: Mutex::new(board),
sync: Mutex::new(sync),
sync_store,
});
let app = Router::new()
@@ -203,6 +329,29 @@ async fn main() {
axum::serve(listener, app).await.unwrap();
}
fn initial_board() -> BoardState {
BoardState {
next_id: 4,
cards: vec![
Card {
id: 1,
title: "Write requirements".into(),
column: 0,
},
Card {
id: 2,
title: "Build browser example".into(),
column: 1,
},
Card {
id: 3,
title: "Verify with HTTP".into(),
column: 2,
},
],
}
}
// req: examples/001 req: component/003
async fn home(State(state): State<Arc<AppState>>, request: PageRequest) -> impl IntoResponse {
let board = state.board.lock().unwrap().clone();
@@ -289,12 +438,11 @@ async fn sync_command(
}
let mut board = state.board.lock().unwrap();
let card = board
let card_index = board
.cards
.iter_mut()
.find(|card| card.id == card_id)
.iter()
.position(|card| card.id == card_id)
.ok_or(SyncRejection::BadRequest("unknown card_id"))?;
card.column = 2;
let acknowledgement = SyncAcknowledgement {
command_id: command_id.0.clone(),
server_sequence: sync.next_sequence,
@@ -302,6 +450,13 @@ async fn sync_command(
canonical_column: "done",
status: "accepted",
};
if let Some(store) = &state.sync_store {
store.persist(&sync, &acknowledgement).map_err(|error| {
eprintln!("sync persistence failed: {error}");
SyncRejection::Storage
})?;
}
board.cards[card_index].column = 2;
sync.next_sequence += 1;
sync.acknowledgements
.insert(command_id, acknowledgement.clone());
+129
View File
@@ -1,4 +1,5 @@
use hemx_test::TestProcess;
use std::fs;
use std::net::TcpListener;
use std::process::Command;
use std::time::Duration;
@@ -368,6 +369,134 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
result.and(quit)
}
#[tokio::test]
async fn canonical_acknowledgement_survives_server_restart() -> WebDriverResult<()> {
// test req: sync/001 req: sync/005 req: sync/007 req: sync/008 req: sync/013
let app_port = available_port();
let app_addr = format!("127.0.0.1:{app_port}");
let store = std::env::temp_dir().join(format!(
"hemx-kanban-sync-{}-{app_port}.json",
std::process::id()
));
let _ = fs::remove_file(&store);
let mut first_app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example"));
first_app_command
.env("HEMX_KANBAN_ADDR", &app_addr)
.env("HEMX_KANBAN_SYNC_STORE", &store);
let first_app = TestProcess::start(
first_app_command,
"hemx-kanban-first",
&app_addr,
STARTUP_TIMEOUT,
)
.expect("start first hemx-kanban");
let webdriver_port = available_port();
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT)
.expect("start ready geckodriver");
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let result = async {
driver.goto(&format!("http://{app_addr}/")).await?;
let accepted = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
fetch('/sync/commands?command_id=restart-proof%3A1&card_id=1', { method: 'POST' })
.then(async (response) => done({ status: response.status, body: await response.json() }))
.catch((error) => done({ error: String(error) }));
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(accepted["status"], 200);
assert_eq!(accepted["body"]["serverSequence"], 1);
assert_eq!(accepted["body"]["canonicalColumn"], "done");
assert!(store.is_file(), "server did not materialize sync store");
let persisted = fs::read_to_string(&store).expect("read sync store");
assert!(persisted.contains("restart-proof:1"));
assert!(persisted.contains("\"schemaVersion\": 1"));
drop(first_app);
let mut second_app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example"));
second_app_command
.env("HEMX_KANBAN_ADDR", &app_addr)
.env("HEMX_KANBAN_SYNC_STORE", &store);
let second_app = TestProcess::start(
second_app_command,
"hemx-kanban-second",
&app_addr,
STARTUP_TIMEOUT,
)
.expect("restart hemx-kanban from durable sync store");
let after_restart = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
fetch('/sync/commands?command_id=restart-proof%3A1&card_id=1', { method: 'POST' })
.then(async (response) => done({ status: response.status, body: await response.json() }))
.catch((error) => done({ error: String(error) }));
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(after_restart["status"], 200);
assert_eq!(after_restart["body"], accepted["body"]);
driver
.execute(
r#"
window.__restartReplay = null;
const source = new EventSource('/sync/acknowledgements?after=0');
source.addEventListener('acknowledgement', (event) => {
window.__restartReplay = { id: event.lastEventId, body: JSON.parse(event.data) };
source.close();
});
return true;
"#,
Vec::new(),
)
.await?;
wait_until(&driver, "return window.__restartReplay !== null").await?;
let replay = driver
.execute("return window.__restartReplay", Vec::new())
.await?
.json()
.clone();
assert_eq!(replay["id"], "1");
assert_eq!(replay["body"], accepted["body"]);
driver.refresh().await?;
let canonical = driver
.execute(
"return [...document.querySelectorAll('section.column')].map((column) => ({ title: column.querySelector('h2').textContent, cards: [...column.querySelectorAll('[data-key]')].map((card) => card.dataset.key) }))",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(canonical[2]["title"], "Done");
assert_eq!(canonical[2]["cards"], serde_json::json!(["1", "3"]));
drop(second_app);
Ok(())
}
.await;
let quit = driver.quit().await;
let _ = fs::remove_file(&store);
result.and(quit)
}
async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> {
for _ in 0..200 {
if driver.execute(script, Vec::new()).await?.json().as_bool() == Some(true) {