Skip to content

Commit 1e8e50c

Browse files
authored
Merge pull request #355 from beyondessential/repo-stats-timeago
fix(ui): repo-stats Observed timestamp placement + bucket bytes measurement time
2 parents ed9c494 + 8c08484 commit 1e8e50c

12 files changed

Lines changed: 123 additions & 21 deletions

File tree

crates/database/src/backups.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,8 +1607,7 @@ impl BackupRepoSnapshot {
16071607

16081608
/// When the group's repo was last inspected (newest `observed_at` across its
16091609
/// sources), or `None` if never. This table is written *only* by the
1610-
/// inspection Job — unlike `backup_repo_stats.observed_at`, which the daily
1611-
/// s3-metrics writer also bumps — so it's the clean "last inspected" signal.
1610+
/// inspection Job, so it's the clean "last inspected" signal.
16121611
pub async fn last_inspected_at_for_group(
16131612
db: &mut AsyncPgConnection,
16141613
group_id: Uuid,
@@ -1657,10 +1656,19 @@ pub struct BackupRepoStats {
16571656
/// content outside the repository, or reflects a different point in
16581657
/// time).
16591658
pub bucket_bytes: Option<i64>,
1660-
/// When these stats were last refreshed. Whichever of the two collectors
1661-
/// wrote most recently.
1659+
/// When the repo-derived figures (snapshot/source counts, logical/physical
1660+
/// bytes) were last refreshed by the inspection job.
16621661
#[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)]
16631662
pub observed_at: Timestamp,
1663+
/// When `bucket_bytes` was last refreshed by the (daily) S3-metrics
1664+
/// collector, if ever. Tracked separately from `observed_at` because the
1665+
/// two figures are collected on independent cadences.
1666+
#[diesel(
1667+
deserialize_as = jiff_diesel::NullableTimestamp,
1668+
serialize_as = jiff_diesel::NullableTimestamp,
1669+
treat_none_as_default_value = false
1670+
)]
1671+
pub bucket_bytes_observed_at: Option<Timestamp>,
16641672
}
16651673

16661674
impl BackupRepoStats {
@@ -1711,7 +1719,8 @@ impl BackupRepoStats {
17111719
}
17121720

17131721
/// Writer 2: the S3-metrics task. Touches only `bucket_bytes`
1714-
/// (+ `observed_at`), never the repo-derived fields.
1722+
/// (+ `bucket_bytes_observed_at`), never the repo-derived fields or their
1723+
/// `observed_at`.
17151724
pub async fn upsert_bucket_bytes(
17161725
db: &mut AsyncPgConnection,
17171726
group_id: Uuid,
@@ -1723,10 +1732,14 @@ impl BackupRepoStats {
17231732
.values((
17241733
dsl::group_id.eq(group_id),
17251734
dsl::bucket_bytes.eq(bucket_bytes),
1735+
dsl::bucket_bytes_observed_at.eq(now),
17261736
))
17271737
.on_conflict(dsl::group_id)
17281738
.do_update()
1729-
.set((dsl::bucket_bytes.eq(bucket_bytes), dsl::observed_at.eq(now)))
1739+
.set((
1740+
dsl::bucket_bytes.eq(bucket_bytes),
1741+
dsl::bucket_bytes_observed_at.eq(now),
1742+
))
17301743
.execute(db)
17311744
.await
17321745
.map_err(AppError::from)?;

crates/database/src/schema.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ diesel::table! {
8282
physical_bytes -> Nullable<Int8>,
8383
bucket_bytes -> Nullable<Int8>,
8484
observed_at -> Timestamptz,
85+
bucket_bytes_observed_at -> Nullable<Timestamptz>,
8586
}
8687
}
8788

crates/database/tests/backups.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,7 +1237,11 @@ async fn repo_stats_split_writers_do_not_clobber() {
12371237
(Some(10), Some(3), Some(1000), Some(500), Some(777))
12381238
);
12391239

1240-
// Bucket-bytes writer must not clobber the repo fields.
1240+
let repo_observed_at = s.observed_at;
1241+
let bucket_observed_at = s.bucket_bytes_observed_at.unwrap();
1242+
1243+
// Bucket-bytes writer must not clobber the repo fields, and must bump
1244+
// only its own timestamp — not the inspection one.
12411245
BackupRepoStats::upsert_bucket_bytes(&mut conn, group_id, Some(888))
12421246
.await
12431247
.unwrap();
@@ -1247,8 +1251,15 @@ async fn repo_stats_split_writers_do_not_clobber() {
12471251
.unwrap();
12481252
assert_eq!(s.bucket_bytes, Some(888));
12491253
assert_eq!(s.snapshot_count, Some(10), "repo fields preserved");
1254+
assert_eq!(s.observed_at, repo_observed_at, "observed_at untouched");
1255+
assert!(
1256+
s.bucket_bytes_observed_at.unwrap() >= bucket_observed_at,
1257+
"bucket_bytes_observed_at bumped"
1258+
);
1259+
let bucket_observed_at = s.bucket_bytes_observed_at.unwrap();
12501260

1251-
// And the inspection writer must not clobber bucket_bytes.
1261+
// And the inspection writer must not clobber bucket_bytes or its
1262+
// timestamp.
12521263
BackupRepoStats::upsert_repo_fields(
12531264
&mut conn,
12541265
group_id,
@@ -1265,6 +1276,11 @@ async fn repo_stats_split_writers_do_not_clobber() {
12651276
.unwrap();
12661277
assert_eq!(s.snapshot_count, Some(11));
12671278
assert_eq!(s.bucket_bytes, Some(888), "bucket_bytes preserved");
1279+
assert_eq!(
1280+
s.bucket_bytes_observed_at,
1281+
Some(bucket_observed_at),
1282+
"bucket_bytes_observed_at preserved"
1283+
);
12681284
})
12691285
.await;
12701286
}

crates/jobs/src/backup/s3_metrics.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ async fn tick(
151151
for c in &ready {
152152
// Fire once per day at the group's jittered deadline, catching up on a
153153
// later tick if this one drifts past the slot or a slow predecessor group
154-
// pushed us late. The anchor is in-memory (best-effort daily metric with
155-
// no persisted timestamp): it resets on restart, re-firing at the next
154+
// pushed us late. The anchor is in-memory (`bucket_bytes_observed_at` is
155+
// only persisted on success): it resets on restart, re-firing at the next
156156
// deadline. Recorded on attempt, not success, so a persistently-failing
157157
// group doesn't hammer CloudWatch every tick.
158158
if !slot_deadline_due(

crates/private-server/tests/backups.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,8 +607,8 @@ async fn stats_includes_runs_and_pending_requests() {
607607
"INSERT INTO devices (id, role) VALUES ('{device_id}', 'server');
608608
INSERT INTO servers (id, host, kind, group_id, device_id) VALUES \
609609
('{server_id}', 'https://e.test', 'central', '{group_id}', '{device_id}');
610-
INSERT INTO backup_repo_stats (group_id, snapshot_count, source_count, logical_bytes, physical_bytes) \
611-
VALUES ('{group_id}', 12, 3, 1000, 800);
610+
INSERT INTO backup_repo_stats (group_id, snapshot_count, source_count, logical_bytes, physical_bytes, bucket_bytes, bucket_bytes_observed_at) \
611+
VALUES ('{group_id}', 12, 3, 1000, 800, 2000, now() - interval '1 day');
612612
INSERT INTO backup_runs (id, device_id, group_id, server_id, type, purpose, outcome, bytes_uploaded) \
613613
VALUES ('{run_id}', '{device_id}', '{group_id}', '{server_id}', 'tamanu-postgres', 'backup', 'success', 500);
614614
-- The issuance that started the backup 5 minutes before it reported, so
@@ -645,6 +645,10 @@ async fn stats_includes_runs_and_pending_requests() {
645645
resp.assert_status_ok();
646646
let body: serde_json::Value = resp.json();
647647
assert_eq!(body["stats"]["snapshot_count"], 12);
648+
assert_eq!(body["stats"]["bucket_bytes"], 2000);
649+
// The bucket figure carries its own measurement timestamp, separate
650+
// from the inspection-owned observed_at.
651+
assert!(body["stats"]["bucket_bytes_observed_at"].is_string());
648652
// The reported backup, plus an inferred in-flight restore from its issuance.
649653
// The consumer device's restore issuance is excluded (it's shown in the
650654
// restore-checks table instead).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE backup_repo_stats DROP COLUMN bucket_bytes_observed_at;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Give the S3-metrics collector its own timestamp on backup_repo_stats, so
2+
-- `observed_at` can belong solely to the repo-inspection writer. Backfill from
3+
-- `observed_at` where a bucket figure exists: the collector runs daily and
4+
-- bumped `observed_at` until now, so it's within a day of the real measurement.
5+
ALTER TABLE backup_repo_stats ADD COLUMN bucket_bytes_observed_at TIMESTAMPTZ;
6+
UPDATE backup_repo_stats SET bucket_bytes_observed_at = observed_at WHERE bucket_bytes IS NOT NULL;

private-web/e2e/backups.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,18 @@ test.describe("backups ready: stats + backup-now", () => {
341341
await expect(page.getByText("42")).toBeVisible();
342342
// bucket_bytes NULL → "unknown" shown, per the indicators rule.
343343
await expect(page.getByText(/bucket bytes:\s*unknown/i)).toBeVisible();
344+
// The "Observed" timestamp belongs to the kopia-reported repo stats, so
345+
// it sits under "Physical bytes", above the bucket figure.
346+
const statsPanel = page
347+
.getByRole("heading", { name: /repository stats/i })
348+
.locator("..");
349+
const panelText = await statsPanel.innerText();
350+
expect(panelText.indexOf("Observed")).toBeGreaterThan(
351+
panelText.indexOf("Physical bytes"),
352+
);
353+
expect(panelText.indexOf("Observed")).toBeLessThan(
354+
panelText.indexOf("Bucket bytes"),
355+
);
344356
await expect(page.getByText(/recent runs/i)).toBeVisible();
345357
await expect(page.getByText("success")).toBeVisible();
346358
// The run carries a server_id, so the table names which server it's from.
@@ -488,6 +500,7 @@ test.describe("backups ready: stats + backup-now", () => {
488500
await seedBackupRepoStats(sql, {
489501
groupId: group.id,
490502
bucketBytes: 107374182400, // 100 GiB
503+
bucketBytesObservedAt: new Date(Date.now() - 86_400_000).toISOString(),
491504
});
492505

493506
await page.goto(`/groups/${group.id}/backups`);
@@ -497,6 +510,12 @@ test.describe("backups ready: stats + backup-now", () => {
497510
const tooltip = page.getByRole("tooltip");
498511
await expect(tooltip).toContainText("$2.50/month");
499512
await expect(tooltip).toContainText("ap-southeast-2");
513+
// The bucket figure comes from CloudWatch on its own daily cadence, so
514+
// the tooltip carries its measurement time and says it updates less
515+
// often than the kopia-reported stats.
516+
await expect(tooltip).toContainText("From CloudWatch storage metrics");
517+
await expect(tooltip).toContainText("measured");
518+
await expect(tooltip).toContainText("about once a day");
500519
});
501520

502521
test("recent run shows a truncated, copyable snapshot id", async ({

private-web/e2e/seed.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -568,19 +568,22 @@ export async function seedBackupRepoStats(
568568
logicalBytes?: number | null;
569569
physicalBytes?: number | null;
570570
bucketBytes?: number | null;
571+
/** ISO timestamp of the bucket-bytes measurement. */
572+
bucketBytesObservedAt?: string | null;
571573
},
572574
): Promise<void> {
573575
await sql.query(
574576
`INSERT INTO backup_repo_stats
575-
(group_id, snapshot_count, source_count, logical_bytes, physical_bytes, bucket_bytes)
576-
VALUES ($1, $2, $3, $4, $5, $6)`,
577+
(group_id, snapshot_count, source_count, logical_bytes, physical_bytes, bucket_bytes, bucket_bytes_observed_at)
578+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
577579
[
578580
opts.groupId,
579581
opts.snapshotCount ?? null,
580582
opts.sourceCount ?? null,
581583
opts.logicalBytes ?? null,
582584
opts.physicalBytes ?? null,
583585
opts.bucketBytes ?? null,
586+
opts.bucketBytesObservedAt ?? null,
584587
],
585588
);
586589
}

private-web/openapi.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6641,6 +6641,14 @@
66416641
"format": "int64",
66426642
"description": "Total size of the underlying S3 bucket, in bytes, as reported by cloud\nstorage metrics. Can differ from `physical_bytes` (e.g. it includes\ncontent outside the repository, or reflects a different point in\ntime)."
66436643
},
6644+
"bucket_bytes_observed_at": {
6645+
"type": [
6646+
"string",
6647+
"null"
6648+
],
6649+
"format": "date-time",
6650+
"description": "When `bucket_bytes` was last refreshed by the (daily) S3-metrics\ncollector, if ever. Tracked separately from `observed_at` because the\ntwo figures are collected on independent cadences."
6651+
},
66446652
"group_id": {
66456653
"type": "string",
66466654
"format": "uuid",
@@ -6657,7 +6665,7 @@
66576665
"observed_at": {
66586666
"type": "string",
66596667
"format": "date-time",
6660-
"description": "When these stats were last refreshed. Whichever of the two collectors\nwrote most recently."
6668+
"description": "When the repo-derived figures (snapshot/source counts, logical/physical\nbytes) were last refreshed by the inspection job."
66616669
},
66626670
"physical_bytes": {
66636671
"type": [

0 commit comments

Comments
 (0)