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:
+185
-30
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user