crates/monetize-server/src/backup/mod.rs
//! **The operational-state backup: export to text, restore from text.**
//!
//! The standby gunnar machine is reused as the destination: the state is sent
//! away to it, rather than to a backup server of monetize's own.
//!
//! This is the half that reuses what already works. monetize's operational state
//! is small and, exported as text, it is a git repository's natural food: push it
//! to gunnar over the machine principal monetize already holds, and **gunnar's
//! own §11.1 replication carries it to the standby** — measured
//! working, `pending 0, failures 0`. No new box, no new mechanism, no new
//! credential path, and it dogfoods gunnar.
//!
//! # A backup nobody has restored is not a backup
//!
//! That is why [`import`] exists in the same module as [`export`] and is tested
//! by round trip rather than by inspection: export, destroy the stores, import,
//! and assert the LEDGER FOLDS IDENTICALLY — not that the bytes match, which
//! would prove only that a file copy works.
//!
//! # Text, not the redb files
//!
//! Copying `ledger.redb` would be simpler and much worse. A binary blob does not
//! diff, so every snapshot is a whole new object in git history, and nobody can
//! read a backup to see what it holds. As JSON lines an append-only ledger
//! delta-compresses to almost nothing between snapshots, `git log -p` is an audit
//! trail, and a human can answer "was this tenant in the backup" with grep.
//!
//! Output is DETERMINISTIC — every table walked in key order, one record per
//! line. Two exports of unchanged state are byte-identical, so a git-backed
//! backup produces no commit when nothing happened, and a commit means something
//! did.
//!
//! # What is NOT in here, and why the manifest says so out loud
//!
//! No secrets. Not the signing key, not the gunnar identity, not the bearer, not
//! the vendor credentials. Those must not live in a repository that gunnar
//! replicates to a box whose contents cannot currently be verified — anyone with
//! read on that namespace would hold monetize's signing key.
//!
//! It is not, however, free of PERSONAL data, and that is a different word.
//! [`SIGNUPS`] carries the e-mail a customer typed on a vendor's checkout page,
//! because a signup left PENDING is retried against the sink after a restore and
//! the retry cannot ask again without it. A deployment that will not put a
//! customer's address into a namespace gunnar replicates must point
//! `backup_dir` somewhere else — the choice is an operator's, made once, in
//! `monetize.toml`, and it is said here so it is made knowingly.
//!
//! The manifest DOES record the signing key's PUBLIC half, which is not a secret
//! and is what makes a restore honest: every `EntitlementFact` in the ledger was
//! signed by that key, so restoring into a server holding a different one gives a
//! ledger full of facts this server cannot reproduce and products holding facts
//! it cannot re-sign. [`import`] refuses that by default and names it.
use std::path::{Path, PathBuf};
use monetize::ledger::Row;
use monetize::Ledger;
use crate::reconcile::ReconcileStore;
use crate::signup::{SignupRecord, SignupStore};
use crate::state::UsageStore;
/// Files a backup directory holds. Named so a human reading the directory knows
/// what each one is without this doc.
pub const MANIFEST: &str = "MANIFEST.toml";
pub const LEDGER: &str = "ledger.jsonl";
/// **The ledger's current verdicts**, `{product, fact}` per line. Not a fold of
/// [`LEDGER`]: an operator's `Entitlement.Set`, a ladder move and a refund's
/// take-back change a verdict and write no step row, so a restore that
/// re-derived them from the rows gave a refunded customer the room back.
pub const ENTITLEMENTS: &str = "entitlements.jsonl";
/// **The pushes the product has not acknowledged**, `{product, pending}` per
/// line (`monetize::ledger::PendingPush`): the to-do that tells the product
/// what monetize already decided.
pub const PUSHES: &str = "pushes.jsonl";
pub const USAGE: &str = "usage.jsonl";
/// **The paid-signup store.** It is in the backup because it is the
/// IDEMPOTENCY KEY of the money path, not merely a record of it: lose it and
/// the vendor's next retry of a webhook it already delivered creates a second
/// account for one payment. The ledger cannot stand in for it — a signup holds
/// no reference, which is the whole reason it has its own store.
pub const SIGNUPS: &str = "signups.jsonl";
/// **Each vendor's last finished reconciliation pass.** `{"vendor":…,
/// "last_pass_unix_ms":…}` per line. The CASE ROWS of `reconcile.redb` are
/// deliberately not here (see `crate::reconcile`); these stamps are, because
/// they are where the next pass asks the vendor FROM. Without them a restored
/// box asks only `now − 48 h` and every payment the outage swallowed that is
/// older than that is never asked about by anything.
pub const PASSES: &str = "passes.jsonl";
/// **The signup refunds** (`SignupAdmin.Refund` / `Settle`, g-refund):
/// `{vendor, session, refund}` per line. Left out, a restored box put every
/// refunded signup back on the owed-back queue — an alarm about money already
/// given back, and a second refund one click away. (An ORDER's refund is a
/// ledger row and travels in [`LEDGER`].)
pub const REFUNDS: &str = "refunds.jsonl";
/// **The nonpay journal** (`crate::nonpay`): every lapse's fixed deletion date,
/// every warning sent, every cancellation. Left out, a restored front
/// rescheduled every deletion from the restore and mailed every lapsed
/// customer day 0 again. The rows carry their seq; the manifest holds the
/// count and the hash, like every other file's.
pub const NONPAY: &str = "nonpay.jsonl";
/// What an export writes into [`MANIFEST`].
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Manifest {
/// The agent string of the build that wrote it.
pub agent: String,
/// Rows in [`LEDGER`], readings in [`USAGE`] — so a truncated file is
/// visible without parsing it.
pub ledger_rows: usize,
/// Lines in [`ENTITLEMENTS`] and [`PUSHES`].
pub entitlements: usize,
pub pending_pushes: usize,
pub usage_readings: usize,
/// Rows in [`SIGNUPS`], [`PASSES`], [`REFUNDS`], [`NONPAY`]. (A backup
/// written before 2026-10-11 also names `reclaim_rows` and a
/// `reclaim.jsonl`: the reclamation queue is gone, and both are ignored.)
pub signups: usize,
pub vendor_passes: usize,
pub signup_refunds: usize,
pub nonpay_rows: usize,
/// **SHA-256 (hex) of every file**, by name. A count catches a lost line; only a
/// hash catches a line edited in place. [`import`] refuses a backup whose files
/// do not match, before it writes anything.
pub files: std::collections::BTreeMap<String, String>,
/// Hex of monetize's Ed25519 PUBLIC key. Not a secret. Every fact in the
/// ledger was signed by its private half, which is why [`import`] compares.
pub signing_public_key: String,
/// Stated in the file itself so nobody restores this and wonders why
/// signing fails.
pub contains_no_secrets: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum BackupError {
#[error("backup io at {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("backup encode/decode: {0}")]
Codec(String),
#[error("ledger: {0}")]
Ledger(#[from] monetize::ledger::LedgerError),
#[error("usage store: {0}")]
Usage(String),
#[error("signup store: {0}")]
Signups(String),
#[error(
"this backup was written by monetize signing key {backup} but this server holds {current}. \
Every EntitlementFact in it was signed by the backup's key, so restoring here would give a \
ledger full of facts this server cannot reproduce and products holding facts it cannot \
re-sign. Restore the key alongside the state, or pass the override if you have decided that \
re-signing everything is what you want."
)]
KeyMismatch { backup: String, current: String },
#[error("reconcile store: {0}")]
Reconcile(String),
#[error("nonpay journal: {0}")]
Nonpay(String),
/// A restore lands in empty stores or not at all: interleaving it with live
/// history would make a state that was never true.
#[error("restore into a {0} that already holds rows")]
NotEmpty(&'static str),
#[error("this server has no backup_dir configured, so there is nowhere to write")]
NoBackupDir,
}
/// **Every store a backup carries**, borrowed together.
#[derive(Clone, Copy)]
pub struct Stores<'a> {
pub ledger: &'a Ledger,
pub usage: &'a UsageStore,
pub signups: &'a SignupStore,
pub reconcile: &'a ReconcileStore,
pub nonpay: &'a crate::nonpay::NonpayJournal,
}
impl<'a> Stores<'a> {
/// The running server's own stores.
pub fn of(state: &'a crate::state::AppState) -> Stores<'a> {
Stores {
ledger: &state.ledger,
usage: &state.usage,
signups: &state.signups,
reconcile: &state.reconcile,
nonpay: &state.nonpay,
}
}
}
/// Every file a backup holds, in the order [`import`] reads them.
pub const FILES: [&str; 8] = [
LEDGER,
ENTITLEMENTS,
PUSHES,
USAGE,
SIGNUPS,
REFUNDS,
PASSES,
NONPAY,
];
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::Digest;
sha2::Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn io(path: &Path) -> impl Fn(std::io::Error) -> BackupError + '_ {
move |source| BackupError::Io {
path: path.display().to_string(),
source,
}
}
/// One exported ledger row. Flat on purpose: `reference` is the key's first half
/// and belongs beside the row, not inside it.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct LedgerLine {
reference: String,
#[serde(flatten)]
row: Row,
}
/// One exported verdict.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct EntitlementLine {
product: String,
fact: monetize_product::EntitlementFact,
}
/// One exported pending push.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct PushLine {
product: String,
pending: monetize::ledger::PendingPush,
}
/// One exported signup refund.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct RefundLine {
vendor: String,
session: String,
refund: crate::signup::SignupRefund,
}
/// One exported vendor pass stamp.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct PassLine {
vendor: String,
last_pass_unix_ms: i64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct UsageLine {
product: String,
tenant: String,
usage: monetize_product::Usage,
}
/// **One export at a time per backup directory** (A22-O W11, F-T-08): the backup timer and
/// an operator's `Ledger.Export` (or the CLI) into the same directory share every file's
/// temp name, so two at once took each other's temps and a manifest could name another's
/// files. The second waits for the first. Keyed by the directory, so separate servers (and
/// tests) never wait on each other.
fn export_lock(dir: &Path) -> std::sync::Arc<std::sync::Mutex<()>> {
static LOCKS: std::sync::Mutex<
std::collections::BTreeMap<std::path::PathBuf, std::sync::Arc<std::sync::Mutex<()>>>,
> = std::sync::Mutex::new(std::collections::BTreeMap::new());
let key = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
LOCKS
.lock()
.unwrap_or_else(|p| p.into_inner())
.entry(key)
.or_default()
.clone()
}
/// **Write the operational state to `dir` as text.** Deterministic: the same
/// state exports to the same bytes. One export at a time per directory ([`export_lock`]).
pub fn export(
dir: &Path,
stores: &Stores<'_>,
signing_public_key: &str,
) -> Result<Manifest, BackupError> {
std::fs::create_dir_all(dir).map_err(io(dir))?;
let lock = export_lock(dir);
let _one = lock.lock().unwrap_or_else(|p| p.into_inner());
let Stores {
ledger,
usage,
signups,
reconcile,
nonpay,
} = *stores;
std::fs::create_dir_all(dir).map_err(io(dir))?;
let mut files = std::collections::BTreeMap::new();
let mut file = |name: &str, text: &str| -> Result<(), BackupError> {
write(&dir.join(name), text)?;
files.insert(name.to_owned(), sha256_hex(text.as_bytes()));
Ok(())
};
// The ledger's three tables at ONE instant (see [`Ledger::snapshot`]).
// The other stores are files of their own and are read one after another.
let monetize::ledger::LedgerSnapshot {
rows,
entitlements,
pushes,
} = ledger.snapshot()?;
let mut ledger_text = String::new();
for (reference, row) in &rows {
let line = LedgerLine {
reference: reference.clone(),
row: row.clone(),
};
ledger_text.push_str(
&serde_json::to_string(&line).map_err(|e| BackupError::Codec(e.to_string()))?,
);
ledger_text.push('\n');
}
file(LEDGER, &ledger_text)?;
let mut text = String::new();
for (product, fact) in &entitlements {
text.push_str(
&serde_json::to_string(&EntitlementLine {
product: product.clone(),
fact: fact.clone(),
})
.map_err(|e| BackupError::Codec(e.to_string()))?,
);
text.push('\n');
}
file(ENTITLEMENTS, &text)?;
let mut text = String::new();
for (product, pending) in &pushes {
text.push_str(
&serde_json::to_string(&PushLine {
product: product.clone(),
pending: pending.clone(),
})
.map_err(|e| BackupError::Codec(e.to_string()))?,
);
text.push('\n');
}
file(PUSHES, &text)?;
let readings = usage
.export_all()
.map_err(|e| BackupError::Usage(e.to_string()))?;
let mut usage_text = String::new();
for (product, tenant, u) in &readings {
let line = UsageLine {
product: product.clone(),
tenant: tenant.clone(),
usage: u.clone(),
};
usage_text.push_str(
&serde_json::to_string(&line).map_err(|e| BackupError::Codec(e.to_string()))?,
);
usage_text.push('\n');
}
file(USAGE, &usage_text)?;
// The signups. One record per line, in key order, whole — including the
// e-mail, which is the one place in this backup it appears. It has to be:
// a PENDING signup is retried against the sink after a restore, and the
// retry cannot ask again without the thing it was asked about. That is a
// customer's address in a repository gunnar replicates, and it is stated
// here rather than discovered later; a deployment that will not have that
// must not point `backup_dir` at a replicated namespace.
let records = signups
.export_all()
.map_err(|e| BackupError::Signups(e.to_string()))?;
let mut signup_text = String::new();
for rec in &records {
signup_text
.push_str(&serde_json::to_string(rec).map_err(|e| BackupError::Codec(e.to_string()))?);
signup_text.push('\n');
}
file(SIGNUPS, &signup_text)?;
// The vendor pass stamps, in key order.
let passes = reconcile
.export_passes()
.map_err(|e| BackupError::Reconcile(e.to_string()))?;
let mut passes_text = String::new();
for (vendor, at) in &passes {
let line = PassLine {
vendor: vendor.clone(),
last_pass_unix_ms: *at,
};
passes_text.push_str(
&serde_json::to_string(&line).map_err(|e| BackupError::Codec(e.to_string()))?,
);
passes_text.push('\n');
}
file(PASSES, &passes_text)?;
// The signup refunds, in key order.
let refunds = signups
.export_refunds()
.map_err(|e| BackupError::Signups(e.to_string()))?;
let mut refunds_text = String::new();
for (vendor, session, refund) in &refunds {
let line = RefundLine {
vendor: vendor.clone(),
session: session.clone(),
refund: refund.clone(),
};
refunds_text.push_str(
&serde_json::to_string(&line).map_err(|e| BackupError::Codec(e.to_string()))?,
);
refunds_text.push('\n');
}
file(REFUNDS, &refunds_text)?;
// The nonpay journal, whole and in file order; its rows carry their seq.
let nonpay_rows = nonpay.entries();
let mut nonpay_text = String::new();
for e in &nonpay_rows {
nonpay_text
.push_str(&serde_json::to_string(e).map_err(|e| BackupError::Codec(e.to_string()))?);
nonpay_text.push('\n');
}
file(NONPAY, &nonpay_text)?;
// The price lists and the policy are `monetize.toml`'s — an operator
// document, backed up by hand with the config; there is no store of them
// here to export. (The plan catalogue's store, `plans.toml`, went with the
// catalogue on 2026-09-05; a backup directory holding one is old.)
let _ = std::fs::remove_file(dir.join("plans.toml"));
let manifest = Manifest {
agent: monetize_proto::AGENT.to_string(),
ledger_rows: rows.len(),
entitlements: entitlements.len(),
pending_pushes: pushes.len(),
usage_readings: readings.len(),
signups: records.len(),
vendor_passes: passes.len(),
signup_refunds: refunds.len(),
nonpay_rows: nonpay_rows.len(),
files,
signing_public_key: signing_public_key.to_owned(),
contains_no_secrets: true,
};
let header = "# monetize OPERATIONAL-STATE backup. Text on purpose: an append-only ledger\n\
# delta-compresses in git and `git log -p` is an audit trail.\n\
#\n\
# IT CONTAINS NO SECRETS — not the signing key, not a product's identity, not the\n\
# bearer, not a vendor credential. Those must never enter a repository that a forge\n\
# replicates, and they are backed up separately by hand.\n\
#\n\
# `signing_public_key` is the PUBLIC half, recorded so a restore can tell whether\n\
# the key that signed this ledger is the key the target server holds.\n";
let body = toml::to_string_pretty(&manifest).map_err(|e| BackupError::Codec(e.to_string()))?;
write(&dir.join(MANIFEST), &format!("{header}{body}"))?;
Ok(manifest)
}
/// Write only when the content differs, so an unchanged export does not touch
/// mtimes — a git-backed backup then has nothing to commit and nothing to push.
fn write(path: &Path, content: &str) -> Result<(), BackupError> {
if std::fs::read_to_string(path).is_ok_and(|old| old == content) {
return Ok(());
}
// A sibling temp, fsync, rename over, fsync the directory: a crash leaves
// the last backup's file or this one, never half of either.
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let tmp = path.with_file_name(format!("{name}.tmp"));
let written = std::fs::File::create(&tmp)
.map_err(io(&tmp))
.and_then(|mut f| {
put(&mut f, content.as_bytes(), &tmp)?;
f.sync_all().map_err(io(&tmp))
});
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
std::fs::rename(&tmp, path).map_err(io(path))?;
if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::File::open(dir)
.and_then(|d| d.sync_all())
.map_err(io(dir))?;
}
Ok(())
}
fn put(f: &mut std::fs::File, bytes: &[u8], path: &Path) -> Result<(), BackupError> {
use std::io::Write;
#[cfg(test)]
if let Some(n) = CRASH_AFTER.with(|c| c.take()) {
let _ = f.write_all(&bytes[..n.min(bytes.len())]);
return Err(io(path)(std::io::Error::other("crashed mid-write (test)")));
}
f.write_all(bytes).map_err(io(path))
}
#[cfg(test)]
thread_local! {
/// Tests: the next file written stops after this many bytes, as a crash would.
static CRASH_AFTER: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
}
/// **Read a backup back into empty stores — all of it, or none of it.**
///
/// Two phases. FIRST every file is read, its SHA-256 checked against the
/// manifest, every line parsed and every count checked, and every target store
/// checked empty; any refusal here has written nothing. THEN the stores are
/// written. What can still fail in the second phase is the disk itself, not the
/// backup.
///
/// `allow_key_mismatch` exists because the honest default is to refuse: see
/// [`BackupError::KeyMismatch`]. Passing it is a decision, not a convenience.
pub fn import(
dir: &Path,
stores: &Stores<'_>,
current_signing_public_key: &str,
allow_key_mismatch: bool,
) -> Result<Manifest, BackupError> {
let Stores {
ledger,
usage,
signups,
reconcile,
nonpay,
} = *stores;
let manifest = read_manifest(dir)?;
if manifest.signing_public_key != current_signing_public_key && !allow_key_mismatch {
return Err(BackupError::KeyMismatch {
backup: manifest.signing_public_key.clone(),
current: current_signing_public_key.to_owned(),
});
}
// Phase 1: read and check everything.
let Contents {
rows,
entitlements,
pushes,
readings,
records,
refunds,
passes,
nonpay_rows,
} = read_contents(dir, &manifest)?;
let held = ledger.snapshot()?;
if !(held.rows.is_empty() && held.entitlements.is_empty() && held.pushes.is_empty()) {
return Err(BackupError::NotEmpty("ledger"));
}
if !signups
.export_all()
.map_err(|e| BackupError::Signups(e.to_string()))?
.is_empty()
|| !signups
.export_refunds()
.map_err(|e| BackupError::Signups(e.to_string()))?
.is_empty()
{
return Err(BackupError::NotEmpty("signup store"));
}
if !nonpay.entries().is_empty() {
return Err(BackupError::NotEmpty("nonpay journal"));
}
// Phase 2: write.
ledger.restore(&rows, &entitlements, &pushes)?;
for l in &readings {
usage
.put(&l.product, &l.tenant, &l.usage)
.map_err(|e| BackupError::Usage(e.to_string()))?;
}
signups
.restore(&records)
.map_err(|e| BackupError::Signups(e.to_string()))?;
for r in &refunds {
signups
.put_refund(&r.vendor, &r.session, &r.refund)
.map_err(|e| BackupError::Signups(e.to_string()))?;
}
reconcile
.restore_passes(&passes)
.map_err(|e| BackupError::Reconcile(e.to_string()))?;
nonpay
.restore(&nonpay_rows)
.map_err(|e| BackupError::Nonpay(e.to_string()))?;
Ok(manifest)
}
/// [`MANIFEST`], parsed.
fn read_manifest(dir: &Path) -> Result<Manifest, BackupError> {
let manifest_path = dir.join(MANIFEST);
toml::from_str(&std::fs::read_to_string(&manifest_path).map_err(io(&manifest_path))?)
.map_err(|e| BackupError::Codec(format!("{}: {e}", manifest_path.display())))
}
/// **Everything a backup holds**, read whole: every file's hash checked
/// against the manifest, every line parsed, every count checked. Shared by
/// [`import`]; nothing here writes.
struct Contents {
rows: Vec<(String, Row)>,
entitlements: Vec<(String, monetize_product::EntitlementFact)>,
pushes: Vec<(String, monetize::ledger::PendingPush)>,
readings: Vec<UsageLine>,
records: Vec<SignupRecord>,
refunds: Vec<RefundLine>,
passes: Vec<(String, i64)>,
nonpay_rows: Vec<crate::nonpay::Entry>,
}
fn read_contents(dir: &Path, manifest: &Manifest) -> Result<Contents, BackupError> {
let read = |name: &str| read_file(dir, manifest, name);
Ok(Contents {
rows: lines::<LedgerLine>(&read(LEDGER)?, manifest.ledger_rows, "rows")?
.into_iter()
.map(|l| (l.reference, l.row))
.collect(),
entitlements: lines::<EntitlementLine>(
&read(ENTITLEMENTS)?,
manifest.entitlements,
"verdict(s)",
)?
.into_iter()
.map(|l| (l.product, l.fact))
.collect(),
pushes: lines::<PushLine>(&read(PUSHES)?, manifest.pending_pushes, "pending push(es)")?
.into_iter()
.map(|l| (l.product, l.pending))
.collect(),
readings: lines::<UsageLine>(&read(USAGE)?, manifest.usage_readings, "readings")?,
records: lines::<SignupRecord>(&read(SIGNUPS)?, manifest.signups, "signups")?,
refunds: lines::<RefundLine>(&read(REFUNDS)?, manifest.signup_refunds, "refund(s)")?,
passes: lines::<PassLine>(&read(PASSES)?, manifest.vendor_passes, "vendor pass(es)")?
.into_iter()
.map(|p| (p.vendor, p.last_pass_unix_ms))
.collect(),
nonpay_rows: lines::<crate::nonpay::Entry>(
&read(NONPAY)?,
manifest.nonpay_rows,
"nonpay rows",
)?,
})
}
/// One backup file as read: its text, and whether its SHA-256 is the one the
/// manifest holds.
struct Read {
path: PathBuf,
text: String,
hash_matches: bool,
}
fn read_file(dir: &Path, manifest: &Manifest, name: &str) -> Result<Read, BackupError> {
let path = dir.join(name);
let want = manifest.files.get(name).ok_or_else(|| {
BackupError::Codec(format!(
"the manifest has no hash for {name}: it is not a whole backup"
))
})?;
let bytes = std::fs::read(&path).map_err(io(&path))?;
let hash_matches = &sha256_hex(&bytes) == want;
let text = String::from_utf8(bytes)
.map_err(|e| BackupError::Codec(format!("{}: {e}", path.display())))?;
Ok(Read {
path,
text,
hash_matches,
})
}
/// Every line of a read file, parsed; refused unless there are exactly
/// `expected` and the hash matches. The count is checked first because it says
/// more: a truncated file is the failure most likely to go unnoticed. The hash
/// then catches a line edited in place, which keeps the count.
fn lines<T: serde::de::DeserializeOwned>(
read: &Read,
expected: usize,
what: &str,
) -> Result<Vec<T>, BackupError> {
let path = read.path.display();
let mut out = Vec::new();
for (n, line) in read
.text
.lines()
.enumerate()
.filter(|(_, l)| !l.trim().is_empty())
{
out.push(
serde_json::from_str(line)
.map_err(|e| BackupError::Codec(format!("{path}:{}: {e}", n + 1)))?,
);
}
if out.len() != expected {
return Err(BackupError::Codec(format!(
"{path} holds {} {what} but the manifest says {expected}: the backup is truncated or the manifest is stale",
out.len()
)));
}
if !read.hash_matches {
return Err(BackupError::Codec(format!("{path} does not match the manifest's hash: the file was changed or damaged after the export")));
}
Ok(out)
}
/// **One tick of the backup timer** (gap G17), and the body of `Ledger.Export`:
/// the whole state into the server's own `backup_dir`.
pub fn scheduled_export(state: &crate::state::AppState) -> Result<Manifest, BackupError> {
let dir = state
.backup_dir
.as_deref()
.ok_or(BackupError::NoBackupDir)?;
let pubkey: String = state
.keys
.root()
.public_key()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
export(dir, &Stores::of(state), &pubkey)
}
/// **The backup timer** (gap G17): [`scheduled_export`] every `every`, the first
/// one `every` after start. Each tick says how it went on the functional status.
pub async fn run(state: std::sync::Arc<crate::state::AppState>, every: std::time::Duration) {
loop {
tokio::time::sleep(every).await;
let s = state.clone();
let (well, line) = match tokio::task::spawn_blocking(move || scheduled_export(&s)).await {
Ok(Ok(m)) => (
true,
format!(
"{} ledger row(s), {} signup(s), {} nonpay row(s)",
m.ledger_rows, m.signups, m.nonpay_rows
),
),
Ok(Err(e)) => (false, e.to_string()),
Err(e) => (false, format!("the export task died: {e}")),
};
if !well {
eprintln!("monetize-server/backup: scheduled export FAILED: {line}");
}
skidbladnir::functional_status("monetize-server/backup", "scheduled", well, &line);
}
}
#[cfg(test)]
mod tests;