summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/document.rs77
-rw-r--r--src/api/mod.rs2
-rw-r--r--src/swarm/stats.rs9
3 files changed, 88 insertions, 0 deletions
diff --git a/src/api/document.rs b/src/api/document.rs
new file mode 100644
index 0000000..303f5c9
--- /dev/null
+++ b/src/api/document.rs
@@ -0,0 +1,77 @@
+//! `GET /admin/document`: the local document as Automerge bytes, for backups and as
+//! input to an `import` remake.
+//!
+//! The whole document is saved: history, and protocol data under `_continuum`. Where
+//! it came from is in headers: `X-Continuum-Swarm`, `X-Continuum-Epoch`
+//! (`<generation>:<id hex>`) and `X-Continuum-Heads` (hex, comma-separated).
+
+use axum::{
+ extract::State,
+ http::{HeaderName, HeaderValue, StatusCode, header},
+ response::{IntoResponse, Response},
+};
+use tokio::time::Instant;
+use tracing::{info, warn};
+
+use crate::state::AppState;
+
+pub(crate) async fn get_document(State(state): State<AppState>) -> Response {
+ if !state.is_ready().await {
+ return (StatusCode::SERVICE_UNAVAILABLE, "node is not ready\n").into_response();
+ }
+ let swarm_id = state.swarm_id().await.unwrap_or_default();
+
+ let start = Instant::now();
+ let doc = std::sync::Arc::clone(&state.doc);
+ // Both steps take time proportional to the document: off the async threads, and
+ // the lock only for the copy.
+ let saved = tokio::task::spawn_blocking(move || {
+ let (mut copy, epoch) = {
+ let replica = doc.blocking_read();
+ (replica.doc.clone(), replica.epoch)
+ };
+ let heads = copy.get_heads();
+ (copy.save(), heads, epoch)
+ })
+ .await;
+ let (bytes, heads, epoch) = match saved {
+ Ok(saved) => saved,
+ Err(error) => {
+ warn!(error = %error, "Saving the document for export failed");
+ return (StatusCode::INTERNAL_SERVER_ERROR, "saving failed\n").into_response();
+ }
+ };
+ let took = start.elapsed();
+ state.stats.saved_size_measured(bytes.len() as u64, took);
+ info!(
+ bytes = bytes.len(),
+ took_ms = took.as_millis() as u64,
+ "Exported the document"
+ );
+
+ let heads = heads
+ .iter()
+ .map(|head| head.to_string())
+ .collect::<Vec<_>>()
+ .join(",");
+ let filename = format!(
+ "attachment; filename=\"continuum-{swarm_id}-{}.automerge\"",
+ epoch.generation
+ );
+ let mut response = bytes.into_response();
+ let headers = response.headers_mut();
+ let mut set = |name: HeaderName, value: String| {
+ if let Ok(value) = HeaderValue::from_str(&value) {
+ headers.insert(name, value);
+ }
+ };
+ set(header::CONTENT_TYPE, "application/octet-stream".to_owned());
+ set(header::CONTENT_DISPOSITION, filename);
+ set(HeaderName::from_static("x-continuum-swarm"), swarm_id);
+ set(
+ HeaderName::from_static("x-continuum-epoch"),
+ format!("{}:{}", epoch.generation, epoch.id_hex()),
+ );
+ set(HeaderName::from_static("x-continuum-heads"), heads);
+ response
+}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 85427b0..3233b16 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -2,6 +2,7 @@
pub(crate) mod auth;
mod debug;
+mod document;
mod log;
mod peers;
mod stats;
@@ -44,6 +45,7 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
get(log::get_log_filter).put(log::put_log_filter),
)
.route("/admin/stats", get(stats::get_stats))
+ .route("/admin/document", get(document::get_document))
.merge(crate::remake::router::<AppState>());
if debug_api {
diff --git a/src/swarm/stats.rs b/src/swarm/stats.rs
index fd88167..786734a 100644
--- a/src/swarm/stats.rs
+++ b/src/swarm/stats.rs
@@ -170,6 +170,15 @@ impl NodeStats {
due
}
+ /// The document was saved for another reason (an export): a measurement for free.
+ pub(crate) fn saved_size_measured(&self, bytes: u64, took: Duration) {
+ self.lock().saved_size = Some(SavedSize {
+ bytes,
+ at: Instant::now(),
+ took,
+ });
+ }
+
fn finish_measuring(&self, measured: Option<SavedSize>) {
let mut inner = self.lock();
inner.measuring = false;