1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

Merge branch 'task/async-capture-backend'

This commit is contained in:
TheGeneralist 2026-06-26 12:58:31 +02:00
commit 5e31a4188c
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
4 changed files with 247 additions and 4 deletions

View file

@ -60,6 +60,17 @@ pub struct RunSummary {
pub error_summary: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CaptureJobSummary {
pub job_uid: String,
pub archive_id: String,
pub run_uid: Option<String>,
pub status: String,
pub error_text: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Tag {
pub tag_uid: String,
@ -299,6 +310,21 @@ pub fn list_runs(conn: &rusqlite::Connection) -> Result<Vec<RunSummary>> {
Ok(runs)
}
pub fn get_capture_job(
conn: &rusqlite::Connection,
job_uid: &str,
) -> Result<Option<CaptureJobSummary>> {
Ok(database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary {
job_uid: r.job_uid,
archive_id: r.archive_id,
run_uid: r.run_uid,
status: r.status,
error_text: r.error_text,
created_at: r.created_at,
updated_at: r.updated_at,
}))
}
/// Resolves an artifact to its absolute on-disk path under `store_path`.
///
/// `artifact.relpath` is a store-relative path (e.g. `raw/a/b/abc.pdf`).

View file

@ -98,6 +98,17 @@ pub struct ApiTokenRecord {
pub last_used_at: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CaptureJobRecord {
pub job_uid: String,
pub archive_id: String,
pub run_uid: Option<String>,
pub status: String,
pub error_text: Option<String>,
pub created_at: String,
pub updated_at: String,
}
pub fn database_path(archive_path: &Path) -> PathBuf {
archive_path.join(DATABASE_FILE_NAME)
}
@ -240,6 +251,17 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
PRIMARY KEY (entry_id, tag_id)
);
CREATE TABLE IF NOT EXISTS capture_jobs (
id INTEGER PRIMARY KEY,
job_uid TEXT NOT NULL UNIQUE,
archive_id TEXT NOT NULL,
run_uid TEXT,
status TEXT NOT NULL CHECK (status IN ('pending','running','completed','failed')) DEFAULT 'pending',
error_text TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_capture_jobs_status ON capture_jobs(status);
CREATE INDEX IF NOT EXISTS idx_archive_run_items_run_id ON archive_run_items(run_id);
CREATE INDEX IF NOT EXISTS idx_archived_entries_source_identity_id ON archived_entries(source_identity_id);
CREATE INDEX IF NOT EXISTS idx_archived_entries_created_by_user_id ON archived_entries(created_by_user_id);
@ -582,6 +604,71 @@ pub fn ensure_default_user(conn: &Connection) -> Result<i64> {
Ok(conn.last_insert_rowid())
}
/// Creates a pending capture job. Returns the new `job_uid`.
pub fn create_capture_job(conn: &Connection, archive_id: &str) -> Result<String> {
let job_uid = public_id("job");
let now = now_timestamp();
conn.execute(
"INSERT INTO capture_jobs (job_uid, archive_id, run_uid, status, error_text, created_at, updated_at)
VALUES (?1, ?2, NULL, 'pending', NULL, ?3, ?3)",
rusqlite::params![job_uid, archive_id, now],
)?;
Ok(job_uid)
}
/// Updates the status (and optionally run_uid / error_text) of a capture job.
pub fn update_capture_job_status(
conn: &Connection,
job_uid: &str,
status: &str,
run_uid: Option<&str>,
error_text: Option<&str>,
) -> Result<()> {
let now = now_timestamp();
conn.execute(
"UPDATE capture_jobs SET status = ?1, run_uid = COALESCE(?2, run_uid),
error_text = ?3, updated_at = ?4 WHERE job_uid = ?5",
rusqlite::params![status, run_uid, error_text, now, job_uid],
)?;
Ok(())
}
/// Returns a capture job by uid.
pub fn get_capture_job(conn: &Connection, job_uid: &str) -> Result<Option<CaptureJobRecord>> {
conn.query_row(
"SELECT job_uid, archive_id, run_uid, status, error_text, created_at, updated_at
FROM capture_jobs WHERE job_uid = ?1",
[job_uid],
|row| {
Ok(CaptureJobRecord {
job_uid: row.get(0)?,
archive_id: row.get(1)?,
run_uid: row.get(2)?,
status: row.get(3)?,
error_text: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.optional()
.map_err(Into::into)
}
/// Marks all 'running' capture jobs as 'failed' with a restart message.
/// Called at server startup to clean up jobs interrupted by a previous shutdown.
pub fn fail_stalled_capture_jobs(conn: &Connection) -> Result<usize> {
let now = now_timestamp();
let n = conn.execute(
"UPDATE capture_jobs SET status = 'failed',
error_text = 'interrupted by server restart',
updated_at = ?1
WHERE status = 'running'",
[now],
)?;
Ok(n)
}
pub fn create_archive_run(
conn: &Connection,
created_by_user_id: i64,
@ -1568,4 +1655,37 @@ mod tests {
let conn = make_auth_conn();
assert!(get_user_for_token(&conn, "unknown").unwrap().is_none());
}
#[test]
fn capture_job_create_and_get() {
let conn = conn();
let job_uid = create_capture_job(&conn, "personal").unwrap();
let job = get_capture_job(&conn, &job_uid).unwrap().unwrap();
assert_eq!(job.status, "pending");
assert_eq!(job.archive_id, "personal");
assert!(job.run_uid.is_none());
}
#[test]
fn capture_job_status_transitions() {
let conn = conn();
let job_uid = create_capture_job(&conn, "test").unwrap();
update_capture_job_status(&conn, &job_uid, "running", None, None).unwrap();
update_capture_job_status(&conn, &job_uid, "completed", Some("run_abc"), None).unwrap();
let job = get_capture_job(&conn, &job_uid).unwrap().unwrap();
assert_eq!(job.status, "completed");
assert_eq!(job.run_uid.as_deref(), Some("run_abc"));
}
#[test]
fn fail_stalled_jobs_on_restart() {
let conn = conn();
let uid = create_capture_job(&conn, "test").unwrap();
update_capture_job_status(&conn, &uid, "running", None, None).unwrap();
let n = fail_stalled_capture_jobs(&conn).unwrap();
assert_eq!(n, 1);
let job = get_capture_job(&conn, &uid).unwrap().unwrap();
assert_eq!(job.status, "failed");
assert!(job.error_text.as_deref().unwrap().contains("interrupted"));
}
}

View file

@ -26,6 +26,17 @@ async fn main() -> Result<()> {
let app = routes::app(registry.clone(), auth_db_path.clone());
// On startup, mark any jobs that were 'running' when the server last stopped as 'failed'.
for archive in &registry.archives {
if let Ok(conn) = archivr_core::database::open_or_initialize(&archive.archive_path) {
match archivr_core::database::fail_stalled_capture_jobs(&conn) {
Ok(n) if n > 0 => eprintln!("info: marked {n} stalled capture job(s) as failed in '{}'", archive.id),
Err(e) => eprintln!("warn: stalled job cleanup failed for '{}': {e:#}", archive.id),
_ => {}
}
}
}
// Spawn session cleanup: runs at startup and every 24h.
let cleanup_auth_path = auth_db_path.clone();
tokio::spawn(async move {

View file

@ -103,6 +103,10 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router
)
.route("/api/archives/:archive_id/runs", get(list_runs))
.route("/api/archives/:archive_id/captures", post(capture_handler))
.route(
"/api/archives/:archive_id/capture_jobs/:job_uid",
get(get_capture_job_handler),
)
.route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler))
.route(
"/api/archives/:archive_id/entries/:entry_uid/tags",
@ -366,7 +370,7 @@ async fn capture_handler(
auth_user: AuthUser,
Path(archive_id): Path<String>,
Json(body): Json<CaptureBody>,
) -> Result<Json<capture::CaptureResult>, ApiError> {
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
auth_user.require_role(ROLE_USER)?;
if body.locator.trim().is_empty() {
return Err(ApiError::bad_request("locator must not be empty"));
@ -374,9 +378,67 @@ async fn capture_handler(
let mounted = mounted_archive(&state, &archive_id)?;
let archive_paths = archive::read_archive_paths(&mounted.archive_path)
.map_err(ApiError::from)?;
let result = capture::perform_capture(&archive_paths, &body.locator, Some(&archive_id))
.map_err(ApiError::from)?;
Ok(Json(result))
// Create job record in the archive DB.
let conn = database::open_or_initialize(&mounted.archive_path)?;
let job_uid = database::create_capture_job(&conn, &archive_id)?;
drop(conn);
// Spawn background capture.
let locator = body.locator.trim().to_string();
let archive_path = mounted.archive_path.clone();
let job_uid_bg = job_uid.clone();
let archive_id_bg = archive_id.clone();
tokio::task::spawn_blocking(move || {
let conn = match database::open_or_initialize(&archive_path) {
Ok(c) => c,
Err(e) => {
eprintln!("warn: capture job {job_uid_bg}: db open failed: {e:#}");
return;
}
};
database::update_capture_job_status(&conn, &job_uid_bg, "running", None, None).ok();
match capture::perform_capture(&archive_paths, &locator, Some(&archive_id_bg)) {
Ok(result) => {
database::update_capture_job_status(
&conn,
&job_uid_bg,
"completed",
Some(&result.run_uid),
None,
)
.ok();
}
Err(e) => {
database::update_capture_job_status(
&conn,
&job_uid_bg,
"failed",
None,
Some(&format!("{e:#}")),
)
.ok();
}
}
});
Ok((
StatusCode::ACCEPTED,
Json(serde_json::json!({ "job_uid": job_uid, "status": "pending" })),
))
}
async fn get_capture_job_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path((archive_id, job_uid)): Path<(String, String)>,
) -> Result<Json<archive::CaptureJobSummary>, ApiError> {
auth_user.require_role(ROLE_USER)?;
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
archive::get_capture_job(&conn, &job_uid)?
.map(Json)
.ok_or_else(|| ApiError::not_found("capture job not found"))
}
async fn auth_setup_status(
@ -1421,4 +1483,28 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn capture_post_returns_accepted_with_job_uid() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/captures")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(Body::from(r#"{"locator":"local:/nonexistent"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["job_uid"].as_str().is_some(), "response must have job_uid");
assert_eq!(json["status"], "pending");
}
}