summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-21 06:06:34 +0200
committertv <tv@krebsco.de>2026-10-21 06:06:34 +0200
commitc0f1220e62494805234cf509aad2c1dcf1451667 (patch)
tree23c22295838fe82d7e8cbcfc8ced2d29aaa0f963 /src
parentcfde06efcb73f4106620fc7a1d85d06d4c237413 (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')
-rw-r--r--src/remake/api.rs57
-rw-r--r--src/remake/records.rs2
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;
}