mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
Every write of a document's text keeps the text it replaces, in the same transaction. Autosave writes every few seconds, so an ordinary save keeps one at most every ten minutes; `:w!`, a save over the SFTP mount and a restore always keep one, because each may undo something. A rename alone, or a document that was empty, keeps nothing. `gh` or `:history` lists the open document's earlier versions, the newest first, and shows the highlighted one as a line diff against the text as it is now. Enter brings it back, and what it replaces becomes a version of its own, so a restore is undone the same way. The daily sweep forgets versions older than WORKSPACE_REVISION_DAYS (90) but never the newest 20 of a document, and keeps an upload as long as a version still embeds it. Versions stay out of the export, the mount and the readable mirror; a document in the trash shows none.
282 lines
9.5 KiB
Rust
282 lines
9.5 KiB
Rust
//! Housekeeping for `uploads/`: what a crash, a killed upload or a forgotten
|
|
//! embed leaves behind. Nothing else ever deletes these, so without this they
|
|
//! would only grow — on the disk and in every backup of it. The trash is
|
|
//! emptied here too, of what has waited in it long enough, and the revisions
|
|
//! of documents are thinned out.
|
|
|
|
use std::{collections::HashSet, path::Path, time::Duration};
|
|
|
|
use sqlx::SqlitePool;
|
|
|
|
use crate::routes::media;
|
|
|
|
/// A file this much younger may still be on its way in: an upload streaming
|
|
/// to `.part`, an import whose rows are about to be written.
|
|
const IN_FLIGHT: Duration = Duration::from_secs(60 * 60);
|
|
|
|
/// How long an upload that nothing embeds is kept. Long enough to paste an
|
|
/// image, delete the line by mistake, and put it back days later.
|
|
const UNREFERENCED_DAYS: i64 = 7;
|
|
|
|
const EVERY: Duration = Duration::from_secs(24 * 60 * 60);
|
|
|
|
/// How long what the sweep thins out is kept, in days.
|
|
#[derive(Clone, Copy)]
|
|
pub struct Keep {
|
|
pub trash_days: i64,
|
|
pub revision_days: i64,
|
|
}
|
|
|
|
impl From<&crate::config::Config> for Keep {
|
|
fn from(config: &crate::config::Config) -> Self {
|
|
Self {
|
|
trash_days: config.trash_days,
|
|
revision_days: config.revision_days,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf, keep: Keep) {
|
|
crate::tasks::supervise("the uploads sweep", move || {
|
|
let (db, uploads) = (db.clone(), uploads.clone());
|
|
sweep_forever(db, uploads, keep)
|
|
});
|
|
}
|
|
|
|
async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf, keep: Keep) {
|
|
{
|
|
let mut ticker = tokio::time::interval(EVERY);
|
|
loop {
|
|
ticker.tick().await;
|
|
match run(&db, &uploads, keep).await {
|
|
Ok(swept) if swept.any() => tracing::info!(
|
|
"swept: {} out of the trash, {} old revisions; uploads: {} partial, {} without a row, {} that nothing embeds",
|
|
swept.trashed,
|
|
swept.revisions,
|
|
swept.partial,
|
|
swept.stray,
|
|
swept.unreferenced
|
|
),
|
|
Ok(_) => {}
|
|
Err(e) => tracing::warn!("could not sweep uploads: {e:#}"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, PartialEq)]
|
|
pub struct Swept {
|
|
/// Things deleted long enough ago to be purged from the trash.
|
|
pub trashed: usize,
|
|
/// Revisions older than they are kept for.
|
|
pub revisions: u64,
|
|
pub partial: usize,
|
|
pub stray: usize,
|
|
pub unreferenced: usize,
|
|
}
|
|
|
|
impl Swept {
|
|
fn any(&self) -> bool {
|
|
self.trashed + self.revisions as usize + self.partial + self.stray + self.unreferenced > 0
|
|
}
|
|
}
|
|
|
|
pub async fn run(db: &SqlitePool, uploads: &Path, keep: Keep) -> anyhow::Result<Swept> {
|
|
// First, so the files only the purged nodes and the forgotten revisions
|
|
// held are released and go in this same pass.
|
|
let trashed =
|
|
crate::routes::trash::purge_older(db, uploads, crate::now() - keep.trash_days * 86_400)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("emptying the trash: {e:?}"))?;
|
|
let revisions =
|
|
crate::routes::revisions::forget_older(db, crate::now() - keep.revision_days * 86_400)
|
|
.await?;
|
|
let mut swept = Swept {
|
|
trashed,
|
|
revisions,
|
|
..Swept::default()
|
|
};
|
|
|
|
sqlx::query("DELETE FROM sessions WHERE expires_at < ?1")
|
|
.bind(crate::now())
|
|
.execute(db)
|
|
.await?;
|
|
|
|
// Rows first, so the files they leave are strays by the time the
|
|
// directory is walked, and go in the same pass.
|
|
let cutoff = crate::now() - UNREFERENCED_DAYS * 86_400;
|
|
let unreferenced: Vec<String> = sqlx::query_scalar(
|
|
"SELECT m.id FROM media m
|
|
WHERE m.created_at < ?1
|
|
AND NOT EXISTS (SELECT 1 FROM node_media nm WHERE nm.media_id = m.id)
|
|
AND NOT EXISTS (SELECT 1 FROM nodes n WHERE n.media_id = m.id)
|
|
-- Belt and braces: a body or a card that names the file keeps it,
|
|
-- whatever the link table says.
|
|
AND NOT EXISTS (SELECT 1 FROM nodes n WHERE instr(n.body, m.id) > 0)
|
|
AND NOT EXISTS (SELECT 1 FROM cards c WHERE instr(c.body, m.id) > 0)
|
|
-- An earlier version of a document brings its pictures back with it.
|
|
AND NOT EXISTS (SELECT 1 FROM revisions r WHERE instr(r.body, m.id) > 0)",
|
|
)
|
|
.bind(cutoff)
|
|
.fetch_all(db)
|
|
.await?;
|
|
for id in &unreferenced {
|
|
sqlx::query("DELETE FROM media WHERE id = ?1")
|
|
.bind(id)
|
|
.execute(db)
|
|
.await?;
|
|
}
|
|
swept.unreferenced = unreferenced.len();
|
|
|
|
let known: HashSet<String> = sqlx::query_scalar("SELECT id FROM media")
|
|
.fetch_all(db)
|
|
.await?
|
|
.into_iter()
|
|
.collect();
|
|
|
|
let mut entries = tokio::fs::read_dir(uploads).await?;
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
let meta = entry.metadata().await?;
|
|
if !meta.is_file() {
|
|
continue;
|
|
}
|
|
let age = meta.modified()?.elapsed().unwrap_or_default();
|
|
if age < IN_FLIGHT {
|
|
continue;
|
|
}
|
|
let kind = if name.ends_with(".part") || name.starts_with(".import-") {
|
|
&mut swept.partial
|
|
} else if media::canonical_id(&name).as_deref() == Some(name.as_str())
|
|
&& !known.contains(&name)
|
|
{
|
|
&mut swept.stray
|
|
} else {
|
|
// Not ours to judge: anything that is not a uuid or a leftover of
|
|
// one was put there by someone else.
|
|
continue;
|
|
};
|
|
match tokio::fs::remove_file(entry.path()).await {
|
|
Ok(()) => *kind += 1,
|
|
Err(e) => tracing::warn!("could not remove {name} from uploads: {e}"),
|
|
}
|
|
}
|
|
|
|
Ok(swept)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{run, Keep, Swept};
|
|
|
|
#[tokio::test]
|
|
async fn only_what_nothing_knows_about_goes() {
|
|
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.connect("sqlite::memory:")
|
|
.await
|
|
.unwrap();
|
|
sqlx::migrate!("./migrations").run(&db).await.unwrap();
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let old = crate::now() - 30 * 86_400;
|
|
let kept = "11111111-1111-4111-8111-111111111111";
|
|
let embedded = "22222222-2222-4222-8222-222222222222";
|
|
let forgotten = "33333333-3333-4333-8333-333333333333";
|
|
let stray = "44444444-4444-4444-8444-444444444444";
|
|
let remembered = "66666666-6666-4666-8666-666666666666";
|
|
for id in [kept, embedded, forgotten, remembered] {
|
|
sqlx::query("INSERT INTO media (id, filename, mime, size, created_at) VALUES (?1, 'x', 'image/png', 1, ?2)")
|
|
.bind(id)
|
|
.bind(old)
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let space: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'")
|
|
.fetch_one(&db)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
|
|
VALUES (?1, 'document', 'a', 'a', ?2, 0, 0)",
|
|
)
|
|
.bind(space)
|
|
.bind(format!(""))
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
// `kept` is linked the way a saved document links what it embeds.
|
|
sqlx::query(
|
|
"INSERT INTO node_media (node_id, media_id) SELECT id, ?1 FROM nodes WHERE slug = 'a'",
|
|
)
|
|
.bind(kept)
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
// `remembered` only by an earlier version of that document.
|
|
sqlx::query(
|
|
"INSERT INTO revisions (node_id, name, body, saved_at, reason)
|
|
SELECT id, 'a', ?1, 0, 'edit' FROM nodes WHERE slug = 'a'",
|
|
)
|
|
.bind(format!(""))
|
|
.execute(&db)
|
|
.await
|
|
.unwrap();
|
|
|
|
let hour_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
|
|
for name in [
|
|
kept,
|
|
embedded,
|
|
remembered,
|
|
forgotten,
|
|
stray,
|
|
"x.part",
|
|
"notes.txt",
|
|
] {
|
|
let path = dir.path().join(name);
|
|
std::fs::write(&path, b"x").unwrap();
|
|
std::fs::File::options()
|
|
.write(true)
|
|
.open(&path)
|
|
.unwrap()
|
|
.set_modified(hour_ago)
|
|
.unwrap();
|
|
}
|
|
let fresh = "55555555-5555-4555-8555-555555555555.part";
|
|
std::fs::write(dir.path().join(fresh), b"x").unwrap();
|
|
|
|
let swept = run(
|
|
&db,
|
|
dir.path(),
|
|
Keep {
|
|
trash_days: 30,
|
|
revision_days: 90,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
swept,
|
|
Swept {
|
|
trashed: 0,
|
|
revisions: 0,
|
|
partial: 1,
|
|
stray: 2,
|
|
unreferenced: 1
|
|
}
|
|
);
|
|
|
|
let left: std::collections::BTreeSet<String> = std::fs::read_dir(dir.path())
|
|
.unwrap()
|
|
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
|
.collect();
|
|
let expected: std::collections::BTreeSet<String> =
|
|
[kept, embedded, remembered, "notes.txt", fresh]
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
assert_eq!(left, expected);
|
|
}
|
|
}
|