From c17ad44ab8cb97433e68b5593f67d8672da0db4d Mon Sep 17 00:00:00 2001 From: tv Date: Tue, 20 Oct 2026 03:09:21 +0200 Subject: replace snapshots by quarantine & replay --- src/compaction/quarantine.rs | 160 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/compaction/quarantine.rs (limited to 'src/compaction/quarantine.rs') diff --git a/src/compaction/quarantine.rs b/src/compaction/quarantine.rs new file mode 100644 index 0000000..da39ec0 --- /dev/null +++ b/src/compaction/quarantine.rs @@ -0,0 +1,160 @@ +//! `/admin/quarantine`: this node's own changes that were not replayed when it rejoined +//! a newer epoch (outside the replay window, or impossible to tell apart). +//! +//! * `GET /admin/quarantine` lists the entries. +//! * `GET /admin/quarantine/{id}` shows the writes: every changed path with the value +//! the old document's merged view holds there (`deleted` if none). +//! * `GET /admin/quarantine/{id}/document` downloads the old document +//! (`AutoCommit::save()`), for inspection with other tools. +//! * `POST /admin/quarantine/{id}/replay` writes them into the current epoch anyway (as +//! fresh writes, so they win over what is there) and removes the entry. +//! * `DELETE /admin/quarantine/{id}` discards the entry. +//! +//! Quarantine lives in memory on the node that holds it. + +use std::convert::Infallible; + +use axum::{ + extract::{Path, State}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; +use serde_json::{json, Value as JsonValue}; +use tracing::info; + +use super::api::error_response; +use super::delta; +use super::local::Quarantined; +use super::Host; +use crate::store; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Summary { + id: String, + /// The epoch the changes were made in. + epoch: String, + /// The epoch the node rejoined. + target: String, + created_at: u64, + reason: String, + paths: usize, +} + +fn summary(entry: &Quarantined) -> Summary { + Summary { + id: entry.id.clone(), + epoch: entry.epoch.to_string(), + target: entry.target.to_string(), + created_at: entry.created_at, + reason: entry.reason.clone(), + paths: entry.paths.len(), + } +} + +fn not_found() -> Response { + error_response(StatusCode::NOT_FOUND, "no such quarantine entry", None) +} + +pub(crate) async fn list(State(host): State) -> Response { + Json(host.compaction().list_quarantine(summary)).into_response() +} + +pub(crate) async fn get(State(host): State, Path(id): Path) -> Response { + let Some((summary, document, paths)) = host.compaction().with_quarantined(&id, |entry| { + (summary(entry), entry.document.clone(), entry.paths.clone()) + }) else { + return not_found(); + }; + + let writes = tokio::task::spawn_blocking(move || { + paths + .into_iter() + .map(|path| match store::read_path(&document, &path) { + Ok(Some(value)) => json!({ "path": path, "value": value }), + Ok(None) => json!({ "path": path, "deleted": true }), + Err(error) => json!({ "path": path, "error": error.to_string() }), + }) + .collect::>() + }) + .await; + + match writes { + Ok(writes) => Json(json!({ "entry": summary, "writes": writes })).into_response(), + Err(_) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "reading the writes failed", + None, + ), + } +} + +pub(crate) async fn document(State(host): State, Path(id): Path) -> Response { + let Some(mut document) = host + .compaction() + .with_quarantined(&id, |entry| entry.document.clone()) + else { + return not_found(); + }; + + match tokio::task::spawn_blocking(move || document.save()).await { + Ok(bytes) => ( + [ + (header::CONTENT_TYPE, "application/octet-stream".to_owned()), + ( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"quarantine-{id}.automerge\""), + ), + ], + bytes, + ) + .into_response(), + Err(_) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "saving the document failed", + None, + ), + } +} + +pub(crate) async fn replay(State(host): State, Path(id): Path) -> Response { + if !host.is_node_ready().await { + return error_response(StatusCode::SERVICE_UNAVAILABLE, "node is not ready", None); + } + let Some(entry) = host.compaction().take_quarantined(&id) else { + return not_found(); + }; + + let (stats, entry) = host + .commit_doc(move |doc| { + let stats = delta::replay_paths(&entry.document, doc, &entry.paths); + Ok::<_, Infallible>((stats, entry)) + }) + .await + .unwrap_or_else(|never| match never {}); + + if stats.failed > 0 && stats.replayed == 0 { + // Nothing went in; keep the entry for another look. + host.compaction().restore_quarantined(entry); + return error_response( + StatusCode::CONFLICT, + "none of the writes could be replayed", + Some(json!({ "failed": stats.failed })), + ); + } + + info!(quarantine = %id, replayed = stats.replayed, failed = stats.failed, "Replayed quarantined changes"); + Json(json!({ "replayed": stats.replayed, "failed": stats.failed })).into_response() +} + +pub(crate) async fn discard(State(host): State, Path(id): Path) -> Response { + match host.compaction().take_quarantined(&id) { + Some(_) => { + info!(quarantine = %id, "Discarded quarantined changes"); + StatusCode::NO_CONTENT.into_response() + } + None => not_found(), + } +} -- cgit v1.3.1