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 |