diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/document.rs | 77 | ||||
| -rw-r--r-- | src/api/mod.rs | 2 | ||||
| -rw-r--r-- | src/swarm/stats.rs | 9 |
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; |
