OXYGENHUB

Reports retention: files beyond the newest 50 runs off the default bookmark expire 20b8fe85888b

Before each garbage collection, report files expire under retention
(docs/reports.md §3.3): every run on a repository's default bookmark
keeps them, and the newest 50 runs of each report on any other bookmark;
an expired run keeps its metrics and summary (migration 0056), and its
page says its files expired. Their blobs then go with the gc.

refs #127
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 23:20 UTC
Signature
✔ signed by Andy McCall
Hash
20b8fe85888b0cfd4f91c6884cbf43750f98a00daa52f0dd0cca7d40cc192ce6
Depends on
42fa95540468 501d8b268dd4 50d93fa443dc 8a3334b7a364 95b525b0b977 a36bab6b9ffc c8df33a6829f d23464c0ac60
Depended on by
fac6853c4e46 166f6e9bf4f1 3b1bc27fe805 5d90e0e045e2 c2be148cfe83 4fa0da126b70 a46d15e72101 1c8276b90314
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
8 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/hub/migrations/0056_report_retention.sql +3 −0
1 +
-- Report retention (oxygenhub#127, docs/reports.md §3.3): when a report's
2 +
-- files expired. Its metrics and summary stay; its page says so.
3 +
ALTER TABLE reports ADD COLUMN files_expired_ms INTEGER;
modified crates/hub/src/maintenance.rs +6 −0
…
23 unchanged lines
24 24
/// Garbage-collect every repository, in turn. A repository that fails is
25 25
/// reported and the rest still run.
26 26
pub async fn gc_all(app: &Shared) -> Vec<Done> {
27 +
    // Report files past retention first (#127), so this run prunes them.
28 +
    match crate::reports::expire(&app.db).await {
29 +
        Ok(0) => {}
30 +
        Ok(n) => tracing::info!("gc: {n} reports' files expired under retention"),
31 +
        Err(e) => tracing::warn!("gc: expiring report files: {e}"),
32 +
    }
27 33
    let rows = match db::repos::list(&app.db).await {
28 34
        Ok(rows) => rows,
29 35
        Err(e) => {
…
84 unchanged lines
modified crates/hub/src/reports.rs +32 −0
…
349 unchanged lines
350 350
    pub files: i64,
351 351
    pub bytes: i64,
352 352
    pub created_ms: i64,
353 +
    /// When its files expired under retention (§3.3); its metrics stay.
354 +
    pub files_expired_ms: Option<i64>,
353 355
}
354 356
355 357
impl Report {
…
61 unchanged lines
417 419
    app.cookie_key.mac(format!("report-files:{id}").as_bytes())[..32].to_string()
418 420
}
419 421
422 +
/// Runs of a report on a bookmark other than the default whose files are
423 +
/// kept (docs/reports.md §3.3); older runs keep their metrics only.
424 +
pub const KEEP_OTHER_RUNS: i64 = 50;
425 +
426 +
/// Expire report files under retention (docs/reports.md §3.3): every run on
427 +
/// a repository's default bookmark keeps them; on any other bookmark (an
428 +
/// upload for a change has none) the newest [`KEEP_OTHER_RUNS`] runs of each
429 +
/// report do. An expired report keeps its metrics and summary and loses
430 +
/// its `report_files` rows, so garbage collection may prune the blobs.
431 +
/// Answers how many reports expired.
432 +
pub async fn expire(pool: &Pool) -> sqlx::Result<u64> {
433 +
    let mut tx = crate::db::begin_write(pool).await?;
434 +
    let ids: Vec<i64> = sqlx::query_scalar(
435 +
        "SELECT r.id FROM reports r JOIN repositories p ON p.id = r.repo_id \
436 +
         WHERE r.files_expired_ms IS NULL AND r.bookmark != p.default_bookmark \
437 +
         AND (SELECT COUNT(*) FROM reports s WHERE s.repo_id = r.repo_id AND s.name = r.name AND s.bookmark = r.bookmark \
438 +
              AND (s.created_ms > r.created_ms OR (s.created_ms = r.created_ms AND s.id > r.id))) >= ?",
439 +
    )
440 +
    .bind(KEEP_OTHER_RUNS)
441 +
    .fetch_all(&mut *tx)
442 +
    .await?;
443 +
    let now = now_ms();
444 +
    for id in &ids {
445 +
        sqlx::query("DELETE FROM report_files WHERE report_id = ?").bind(id).execute(&mut *tx).await?;
446 +
        sqlx::query("UPDATE reports SET files_expired_ms = ? WHERE id = ?").bind(now).bind(id).execute(&mut *tx).await?;
447 +
    }
448 +
    tx.commit().await?;
449 +
    Ok(ids.len() as u64)
450 +
}
451 +
420 452
/// Report `id`'s metrics, by key.
421 453
pub async fn metrics(pool: &Pool, id: i64) -> sqlx::Result<Vec<Metric>> {
422 454
    sqlx::query_as("SELECT key, value, unit, better, fail_above, fail_below FROM report_metrics WHERE report_id = ? ORDER BY key")
…
74 unchanged lines
modified crates/hub/tests/flow.rs +56 −0
…
2863 unchanged lines
2864 2864
    let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/1", Some("site"), None).await;
2865 2865
    assert!(body.contains("\"placed\":\"holds the image"), "{body}");
2866 2866
}
2867 +
2868 +
/// oxygenhub#127 §3.3: report files expire beyond the newest 50 runs of a
2869 +
/// report on a bookmark other than the default; the default keeps all,
2870 +
/// and an expired report keeps its metrics.
2871 +
#[tokio::test(flavor = "multi_thread")]
2872 +
async fn report_files_expire_under_retention() {
2873 +
    let h = hub().await;
2874 +
    create(&h.app, "andy", "demo").await;
2875 +
    let repo = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap();
2876 +
    let add = |bookmark: &'static str, at: i64| {
2877 +
        let db = h.app.db.clone();
2878 +
        async move {
2879 +
            let id: i64 = sqlx::query_scalar(
2880 +
                "INSERT INTO reports (repo_id, name, kind, title, summary, context, job, bookmark, state, files, bytes, created_ms) \
2881 +
                 VALUES (?, 'perf', 'performance', 'Perf', '', '{}', 'perf', ?, 's', 1, 1, ?) RETURNING id",
2882 +
            )
2883 +
            .bind(repo.id)
2884 +
            .bind(bookmark)
2885 +
            .bind(at)
2886 +
            .fetch_one(&db)
2887 +
            .await
2888 +
            .unwrap();
2889 +
            sqlx::query("INSERT INTO report_files (report_id, path, blob, size) VALUES (?, 'report.toml', ?, 1)")
2890 +
                .bind(id)
2891 +
                .bind(format!("{:064x}", id))
2892 +
                .execute(&db)
2893 +
                .await
2894 +
                .unwrap();
2895 +
            sqlx::query("INSERT INTO report_metrics (report_id, key, value, unit, better) VALUES (?, 'ms', 1, '', '')")
2896 +
                .bind(id)
2897 +
                .execute(&db)
2898 +
                .await
2899 +
                .unwrap();
2900 +
            id
2901 +
        }
2902 +
    };
2903 +
    let main_old = add("main", 1).await;
2904 +
    add("main", 2).await;
2905 +
    let mut feature = Vec::new();
2906 +
    for at in 0..52 {
2907 +
        feature.push(add("feature", 10 + at).await);
2908 +
    }
2909 +
    assert_eq!(oxygenhub::reports::expire(&h.app.db).await.unwrap(), 2, "the two oldest feature runs");
2910 +
    let files = |id: i64| {
2911 +
        let db = h.app.db.clone();
2912 +
        async move {
2913 +
            sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM report_files WHERE report_id = ?").bind(id).fetch_one(&db).await.unwrap()
2914 +
        }
2915 +
    };
2916 +
    assert_eq!((files(feature[0]).await, files(feature[1]).await, files(feature[2]).await), (0, 0, 1));
2917 +
    assert_eq!(files(main_old).await, 1, "the default bookmark keeps every run");
2918 +
    assert_eq!(oxygenhub::reports::metrics(&h.app.db, feature[0]).await.unwrap().len(), 1, "metrics are kept");
2919 +
    assert_eq!(oxygenhub::reports::expire(&h.app.db).await.unwrap(), 0, "once");
2920 +
    let (_, page) = call(&h.app, "GET", &format!("/andy/demo/reports/perf?id={}", feature[0]), None, None).await;
2921 +
    assert!(page.contains("files have expired"), "the page says so");
2922 +
}
modified docs/reports.md +6 −2
…
210 unchanged lines
211 211
every metric's difference and the two pages side by side, and the frame
212 212
interface: inside the frame, `report.run()` and `report.history("file")`
213 213
answer from the hub page by postMessage, `history` reading that file of
214 -
`data/` from each run's capability URL. Retention (§3.3) and `oh report`
215 -
(4) follow.
214 +
`data/` from each run's capability URL. Retention (§3.3) runs before each garbage
215 +
collection: every run on the default bookmark keeps its files, and the
216 +
newest 50 runs of each report on any other bookmark (proposal and tag
217 +
rules, and a per-repository setting, follow); an expired run keeps its
218 +
metrics and summary, and its page says its files expired. `oh report`
219 +
(4) follows.
216 220
217 221
218 222
1. Storage and index: the `report` output kind in OxygenFlow, the runner's
…
5 unchanged lines
modified templates/report.html +1 −0
…
66 unchanged lines
67 67
</section>
68 68
{% endif %}
69 69
70 +
{% if report.files_expired_ms.is_some() %}<p class="strip">This run's files have expired under retention: its metrics and summary are kept, its page and data are not. Runs on {{ repo.default_bookmark }} keep their files, and the newest {{ crate::reports::KEEP_OTHER_RUNS }} runs on any other bookmark.</p>{% endif %}
70 71
{% if let Some(src) = frame %}
71 72
<section class="rep-frame-wrap{% if let Some((_, _, Some(_))) = compare %} rep-side{% endif %}">
72 73
  <iframe class="rep-frame" src="{{ src }}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" title="{{ report.title }}" data-report-runs="{{ runs_json }}" data-report-current="{{ report.id }}"></iframe>
…
22 unchanged lines