summaryrefslogtreecommitdiffstats
path: root/src/compaction/api.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/compaction/api.rs')
-rw-r--r--src/compaction/api.rs369
1 files changed, 369 insertions, 0 deletions
diff --git a/src/compaction/api.rs b/src/compaction/api.rs
new file mode 100644
index 0000000..b8c55db
--- /dev/null
+++ b/src/compaction/api.rs
@@ -0,0 +1,369 @@
+//! `/admin/compaction(s)` handlers.
+//!
+//! * `POST /admin/compaction` proposes a compaction of the current epoch. The receiving
+//! node becomes its coordinator. `202` with the report, or with `?wait=true` the final
+//! report once decided. A dropped request does not abort anything.
+//! * `GET /admin/compactions` lists all compactions.
+//! * `GET /admin/compactions/{id}` reports one; `?wait=true` long-polls until decided
+//! (bounded by the proposal's own expiry).
+//! * `GET /admin/epochs` shows the local epoch and the archived previous ones.
+//! * `DELETE /admin/compactions/{id}` asks the coordinator to abort. Only possible while
+//! proposed; `409` once committed (a switch can't be undone) or already over.
+//!
+//! Request body for POST (all optional):
+//! `{ "replayWindow": {"epochs": 1, "seconds": 604800},
+//! "archiveRetention": {"epochs": 2, "seconds": 2592000},
+//! "settleSecs": 480 }`
+
+use std::time::Duration;
+
+use axum::{
+ body::Bytes,
+ extract::{Path, Query, State},
+ http::{header::LOCATION, StatusCode},
+ response::{IntoResponse, Response},
+ Json,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value as JsonValue};
+use tokio::sync::broadcast;
+use tracing::{error, info};
+use uuid::Uuid;
+
+use super::coordinator;
+use super::local::LocalStatus;
+use super::records::{
+ self, unix_millis, AbortRequest, EpochRecord, Phase, Policy, Proposal, Record, Retention,
+};
+use super::Host;
+use crate::store::StoreError;
+
+#[derive(Debug, Default, Deserialize)]
+pub(crate) struct WaitQuery {
+ #[serde(default)]
+ wait: bool,
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+struct StartRequest {
+ replay_window: Option<Retention>,
+ archive_retention: Option<Retention>,
+ settle_secs: Option<u64>,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Report {
+ id: String,
+ phase: Phase,
+ #[serde(flatten)]
+ record: Record,
+ /// What happened to this compaction on the node answering the request.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ local: Option<LocalStatus>,
+}
+
+#[derive(Debug, thiserror::Error)]
+enum StartError {
+ #[error("compaction {0} is already open for this epoch")]
+ Open(String),
+ #[error(transparent)]
+ Store(#[from] StoreError),
+}
+
+fn error_response(
+ status: StatusCode,
+ message: impl Into<String>,
+ details: Option<JsonValue>,
+) -> Response {
+ let mut body = json!({ "error": message.into() });
+ if let (Some(JsonValue::Object(extra)), Some(object)) = (details, body.as_object_mut()) {
+ object.extend(extra);
+ }
+ (status, Json(body)).into_response()
+}
+
+fn grace<H: Host>(host: &H) -> u64 {
+ host.dormant_after().as_millis() as u64
+}
+
+fn report<H: Host>(host: &H, id: String, record: Record) -> Report {
+ Report {
+ phase: record.phase(unix_millis(), grace(host)),
+ local: host.compaction().status(&id),
+ id,
+ record,
+ }
+}
+
+async fn load_report<H: Host>(host: &H, id: &str) -> Option<Report> {
+ let wanted = id.to_owned();
+ let record = host
+ .read_doc(move |doc| records::load(doc, &wanted))
+ .await?;
+ Some(report(host, id.to_owned(), record))
+}
+
+/// Waits until the compaction leaves `proposed`. Bounded by the proposal's own expiry,
+/// at which point it is reported as `expired`.
+async fn wait_until_final<H: Host>(host: &H, id: &str) -> Option<Report> {
+ let mut changes = host.subscribe_changes();
+ loop {
+ let report = load_report(host, id).await?;
+ if report.phase.is_final() {
+ return Some(report);
+ }
+ // Re-read on every published change, and at least once a second: expiry is a
+ // matter of time, not of document changes.
+ if let Ok(Err(broadcast::error::RecvError::Closed)) =
+ tokio::time::timeout(Duration::from_secs(1), changes.recv()).await
+ {
+ tokio::time::sleep(Duration::from_secs(1)).await;
+ }
+ }
+}
+
+pub(crate) async fn start<H: Host>(
+ State(host): State<H>,
+ Query(query): Query<WaitQuery>,
+ body: Bytes,
+) -> Response {
+ if !host.is_node_ready().await {
+ return error_response(StatusCode::SERVICE_UNAVAILABLE, "node is not ready", None);
+ }
+
+ let request: StartRequest = if body.is_empty() {
+ StartRequest::default()
+ } else {
+ match serde_json::from_slice(&body) {
+ Ok(request) => request,
+ Err(error) => {
+ return error_response(
+ StatusCode::BAD_REQUEST,
+ format!("invalid request: {error}"),
+ None,
+ )
+ }
+ }
+ };
+
+ let dormant_after = host.dormant_after();
+ let settle = request
+ .settle_secs
+ .map(Duration::from_secs)
+ .unwrap_or(dormant_after * 2);
+ if settle < dormant_after {
+ return error_response(
+ StatusCode::BAD_REQUEST,
+ format!(
+ "settleSecs must be at least {} (the dormancy threshold), so a partition is detected before deciding",
+ dormant_after.as_secs()
+ ),
+ None,
+ );
+ }
+
+ // Fail fast instead of proposing something that will abort anyway.
+ let peers = host.peer_summaries().await;
+ let dormant: Vec<String> = peers
+ .iter()
+ .filter(|peer| !peer.active)
+ .map(|peer| peer.id.clone())
+ .collect();
+ if !dormant.is_empty() {
+ return error_response(
+ StatusCode::CONFLICT,
+ "dormant peers block compaction; forget them first (DELETE /peers/{pk})",
+ Some(json!({ "dormantPeers": dormant })),
+ );
+ }
+
+ let mut peers_considered: Vec<String> = peers.into_iter().map(|peer| peer.id).collect();
+ peers_considered.sort();
+
+ let now = unix_millis();
+ let settle_ms = settle.as_millis() as u64;
+ let id = Uuid::new_v4().to_string();
+ let grace = grace(&host);
+
+ let mut proposal = Proposal {
+ coordinator: host.node_id(),
+ from_epoch: host.current_epoch().into(),
+ base_heads: Vec::new(),
+ policy: Policy {
+ replay_window: request
+ .replay_window
+ .unwrap_or_else(Policy::default_replay_window),
+ archive_retention: request
+ .archive_retention
+ .unwrap_or_else(Policy::default_archive_retention),
+ },
+ settle_ms,
+ proposed_at: now,
+ settle_until: now + settle_ms,
+ peers_considered,
+ };
+
+ let record_id = id.clone();
+ let written = host
+ .commit_doc(move |doc| -> Result<Record, StartError> {
+ if let Some((open, _)) = records::load_all(doc).into_iter().find(|(_, record)| {
+ record.proposal.from_epoch == proposal.from_epoch
+ && record.phase(now, grace).is_open()
+ }) {
+ 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();
+ heads.sort();
+ proposal.base_heads = heads;
+
+ records::write_proposal(doc, &record_id, &proposal)?;
+ Ok(Record {
+ proposal,
+ decision: None,
+ abort_requested: None,
+ })
+ })
+ .await;
+
+ let record = match written {
+ Ok(record) => record,
+ Err(StartError::Open(open)) => {
+ return error_response(
+ StatusCode::CONFLICT,
+ "another compaction of this epoch is still open",
+ Some(json!({ "compaction": open })),
+ );
+ }
+ Err(StartError::Store(error)) => {
+ error!(error = %format!("{error:#}"), "Failed to write compaction proposal");
+ return error_response(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "failed to write the proposal",
+ None,
+ );
+ }
+ };
+
+ info!(
+ compaction = %id,
+ settle_secs = settle.as_secs(),
+ "Compaction proposed"
+ );
+ tokio::spawn(coordinator::run(host.clone(), id.clone()));
+
+ if query.wait {
+ return match wait_until_final(&host, &id).await {
+ Some(report) => Json(report).into_response(),
+ None => error_response(StatusCode::NOT_FOUND, "compaction record vanished", None),
+ };
+ }
+
+ let location = format!("/admin/compactions/{id}");
+ (
+ StatusCode::ACCEPTED,
+ [(LOCATION, location)],
+ Json(report(&host, id, record)),
+ )
+ .into_response()
+}
+
+pub(crate) async fn list<H: Host>(State(host): State<H>) -> Response {
+ let all = host.read_doc(records::load_all).await;
+ let mut reports: Vec<Report> = all
+ .into_iter()
+ .map(|(id, record)| report(&host, id, record))
+ .collect();
+ reports.sort_by_key(|report| report.record.proposal.proposed_at);
+ Json(reports).into_response()
+}
+
+pub(crate) async fn get<H: Host>(
+ State(host): State<H>,
+ Path(id): Path<String>,
+ Query(query): Query<WaitQuery>,
+) -> Response {
+ let found = if query.wait {
+ wait_until_final(&host, &id).await
+ } else {
+ load_report(&host, &id).await
+ };
+ match found {
+ Some(report) => Json(report).into_response(),
+ None => error_response(StatusCode::NOT_FOUND, "no such compaction", None),
+ }
+}
+
+pub(crate) async fn abort<H: Host>(
+ State(host): State<H>,
+ Path(id): Path<String>,
+ Query(query): Query<WaitQuery>,
+) -> Response {
+ let Some(current) = load_report(&host, &id).await else {
+ return error_response(StatusCode::NOT_FOUND, "no such compaction", None);
+ };
+
+ match current.phase {
+ Phase::Proposed => {}
+ Phase::Committed => {
+ return error_response(
+ StatusCode::CONFLICT,
+ "already committed; the switch cannot be undone",
+ Some(json!({ "compaction": current })),
+ )
+ }
+ Phase::Aborted | Phase::Expired => {
+ return error_response(
+ StatusCode::CONFLICT,
+ "already over",
+ Some(json!({ "compaction": current })),
+ )
+ }
+ }
+
+ // The coordinator applies the request on its next check (within about a second).
+ // If it is gone, the proposal expires anyway.
+ if current.record.abort_requested.is_none() {
+ let request = AbortRequest {
+ by: host.node_id(),
+ at: unix_millis(),
+ };
+ let record_id = id.clone();
+ if let Err(error) = host
+ .commit_doc(move |doc| records::write_abort_request(doc, &record_id, &request))
+ .await
+ {
+ error!(compaction = %id, error = %format!("{error:#}"), "Failed to write abort request");
+ return error_response(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "failed to record the abort request",
+ None,
+ );
+ }
+ info!(compaction = %id, "Abort requested");
+ }
+
+ let report = if query.wait {
+ wait_until_final(&host, &id).await
+ } else {
+ load_report(&host, &id).await
+ };
+ match report {
+ Some(report) if report.phase.is_final() => Json(report).into_response(),
+ Some(report) => (StatusCode::ACCEPTED, Json(report)).into_response(),
+ None => error_response(StatusCode::NOT_FOUND, "no such compaction", None),
+ }
+}
+
+/// `GET /admin/epochs`: the local epoch and the previous epochs this node still keeps.
+pub(crate) async fn epochs<H: Host>(State(host): State<H>) -> Response {
+ let current = EpochRecord::from(host.current_epoch());
+ Json(json!({
+ "current": current,
+ "archived": host.compaction().archived_epochs(),
+ }))
+ .into_response()
+}