diff options
| author | tv <tv@krebsco.de> | 2026-10-21 06:06:34 +0200 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-21 06:06:34 +0200 |
| commit | c0f1220e62494805234cf509aad2c1dcf1451667 (patch) | |
| tree | 23c22295838fe82d7e8cbcfc8ced2d29aaa0f963 /src/remake | |
| parent | cfde06efcb73f4106620fc7a1d85d06d4c237413 (diff) | |
allow choosing the base in remakes
POST /admin/remakes accepts "baseHeads": ["<hex>", ...], for example the
heads of an earlier GET /store?heads=true.
Every head must be in the coordinating node's document, otherwise the
request gets a 400. Without it, the base is the current heads. This
works for any kind.
Diffstat (limited to 'src/remake')
| -rw-r--r-- | src/remake/api.rs | 57 | ||||
| -rw-r--r-- | src/remake/records.rs | 2 |
2 files changed, 54 insertions, 5 deletions
diff --git a/src/remake/api.rs b/src/remake/api.rs index 9339ab3..b473a2d 100644 --- a/src/remake/api.rs +++ b/src/remake/api.rs @@ -16,13 +16,14 @@ //! is in progress, without having to know its ID. //! //! Request body for POST (all optional): -//! `{ "kind": "compact", "spec": null, +//! `{ "kind": "compact", "spec": null, "baseHeads": ["<hex>", …], //! "replayWindow": {"epochs": 1, "seconds": 604800}, //! "archiveRetention": {"epochs": 2, "seconds": 2592000}, //! "settleSecs": 480 }` use std::time::Duration; +use automerge::ChangeHash; use axum::{ body::Bytes, extract::{Path, Query, State}, @@ -66,6 +67,10 @@ struct StartRequest { kind: Option<String>, /// Kind-specific part of the request. spec: Option<JsonValue>, + /// The base: heads (hex) whose content the new document accounts for; writes after + /// them are carried over. The current heads if absent. For a remake derived from + /// a read, use the heads that read returned (`GET /store?heads=true`). + base_heads: Option<Vec<String>>, replay_window: Option<Retention>, archive_retention: Option<Retention>, settle_secs: Option<u64>, @@ -87,6 +92,8 @@ struct Report { enum StartError { #[error("remake {0} is already open for this epoch")] Open(String), + #[error("base head {0} is not in this node's document")] + UnknownBase(String), #[error(transparent)] Store(#[from] StoreError), } @@ -228,6 +235,24 @@ async fn start_kind<H: Host>( } }; + let base = match &request.base_heads { + None => None, + Some(heads) => { + let mut parsed = Vec::with_capacity(heads.len()); + for head in heads { + let Some(bytes) = records::decode_hex32(head.trim()) else { + return error_response( + StatusCode::BAD_REQUEST, + format!("invalid base head {head:?}"), + None, + ); + }; + parsed.push(ChangeHash(bytes)); + } + Some(parsed) + } + }; + let dormant_after = host.dormant_after(); let settle = request .settle_secs @@ -297,7 +322,14 @@ async fn start_kind<H: Host>( // From here on, nothing depends on the client: writing the proposal and starting the // coordinator run in their own task, so a client that disconnects mid-request can't // leave a proposal without a coordinator. Dropping the handle doesn't cancel it. - let proposing = tokio::spawn(propose(host.clone(), id.clone(), proposal, now, grace)); + let proposing = tokio::spawn(propose( + host.clone(), + id.clone(), + proposal, + base, + now, + grace, + )); let written = match proposing.await { Ok(written) => written, Err(error) => { @@ -319,6 +351,9 @@ async fn start_kind<H: Host>( Some(json!({ "remake": open })), ); } + Err(error @ StartError::UnknownBase(_)) => { + return error_response(StatusCode::BAD_REQUEST, error.to_string(), None); + } Err(StartError::Store(error)) => { error!(error = %format!("{error:#}"), "Failed to write remake proposal"); return error_response( @@ -357,6 +392,7 @@ async fn propose<H: Host>( host: H, id: String, mut proposal: Proposal, + base: Option<Vec<ChangeHash>>, now: u64, grace: u64, ) -> Result<Record, StartError> { @@ -370,8 +406,21 @@ async fn propose<H: Host>( return Err(StartError::Open(open)); } - // The base is everything before the proposal; the proposal itself is not in it. - let mut heads: Vec<String> = doc.get_heads().iter().map(|h| h.to_string()).collect(); + // The base is the requested heads, or everything before the proposal (the + // proposal itself is never in it). + let base = match base { + Some(base) => { + if let Some(unknown) = base + .iter() + .find(|head| doc.get_change_by_hash(head).is_none()) + { + return Err(StartError::UnknownBase(unknown.to_string())); + } + base + } + None => doc.get_heads(), + }; + let mut heads: Vec<String> = base.iter().map(|h| h.to_string()).collect(); heads.sort(); proposal.base_heads = heads; diff --git a/src/remake/records.rs b/src/remake/records.rs index 5e7841f..9edca6e 100644 --- a/src/remake/records.rs +++ b/src/remake/records.rs @@ -312,7 +312,7 @@ pub(crate) fn write_abort_request( write_part(doc, id, "abortRequested", request) } -fn decode_hex32(hex: &str) -> Option<[u8; 32]> { +pub(crate) fn decode_hex32(hex: &str) -> Option<[u8; 32]> { if hex.len() != 64 || !hex.is_ascii() { return None; } |
