Skip to content

Commit 05f3024

Browse files
authored
Merge pull request #352 from beyondessential/runs-transfer-column
feat(ui): show restore downloads in the recent-runs Transfer column
2 parents 7a96d1c + 538391e commit 05f3024

8 files changed

Lines changed: 389 additions & 27 deletions

File tree

crates/database/src/backups.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,52 @@ impl BackupRun {
12591259
Ok((totals.sent, totals.received))
12601260
}
12611261

1262+
/// The size of each named snapshot, resolved from the *backup* runs that
1263+
/// produced them: the device-reported size (`bytes_uploaded`) or, failing
1264+
/// that, the inspection-observed size (`snapshot_logical_bytes`) — the same
1265+
/// preference the recent-runs UI applies to the producing run itself. Keyed
1266+
/// by snapshot id; ids with no sized producing run are absent.
1267+
///
1268+
/// This is what lets a restore run show the size of the snapshot it used
1269+
/// immediately: the snapshot was sized when the backup that created it ran
1270+
/// (or was inspected), so there is no need to wait for inspection to
1271+
/// backfill the restore run's own row.
1272+
pub async fn snapshot_sizes_by_id(
1273+
db: &mut AsyncPgConnection,
1274+
group_id: Uuid,
1275+
snapshot_ids: &[String],
1276+
) -> Result<HashMap<String, i64>> {
1277+
use crate::schema::backup_runs::dsl;
1278+
1279+
if snapshot_ids.is_empty() {
1280+
return Ok(HashMap::new());
1281+
}
1282+
1283+
let rows: Vec<Self> = dsl::backup_runs
1284+
.filter(dsl::group_id.eq(group_id))
1285+
.filter(dsl::purpose.eq(BackupPurpose::Backup))
1286+
.filter(dsl::snapshot_id.eq_any(snapshot_ids))
1287+
.filter(
1288+
dsl::bytes_uploaded
1289+
.is_not_null()
1290+
.or(dsl::snapshot_logical_bytes.is_not_null()),
1291+
)
1292+
.distinct_on(dsl::snapshot_id)
1293+
.order_by((dsl::snapshot_id, dsl::reported_at.desc()))
1294+
.load(db)
1295+
.await
1296+
.map_err(AppError::from)?;
1297+
1298+
Ok(rows
1299+
.into_iter()
1300+
.filter_map(|r| {
1301+
let id = r.snapshot_id?;
1302+
let size = r.bytes_uploaded.or(r.snapshot_logical_bytes)?;
1303+
Some((id, size))
1304+
})
1305+
.collect())
1306+
}
1307+
12621308
/// Fill `snapshot_logical_bytes` from repo inspection for runs matched by
12631309
/// snapshot id, only where it is still unset — write-once, since a snapshot
12641310
/// is immutable. `sizes` maps a snapshot id to its observed logical size.

crates/database/tests/backups.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,82 @@ async fn backfill_snapshot_logical_bytes_writes_once() {
335335
.await;
336336
}
337337

338+
#[tokio::test(flavor = "multi_thread")]
339+
async fn snapshot_sizes_by_id_resolves_from_producing_backups_only() {
340+
TestDb::run(|mut conn, _url| async move {
341+
let group_id = insert_group(&mut conn, "g").await;
342+
let other_group = insert_group(&mut conn, "other").await;
343+
let server_id = insert_server(&mut conn, group_id).await;
344+
let other_server = insert_server(&mut conn, other_group).await;
345+
let device_id = insert_device(&mut conn).await;
346+
347+
// The backup that produced kopia-snap-1, sized by the device (42 from
348+
// new_run's bytes_uploaded).
349+
BackupRun::record(
350+
&mut conn,
351+
new_run(
352+
Uuid::new_v4(),
353+
device_id,
354+
group_id,
355+
Some(server_id),
356+
BackupPurpose::Backup,
357+
RunOutcome::Success,
358+
),
359+
)
360+
.await
361+
.expect("record backup");
362+
// A restore of the same snapshot must not satisfy the lookup itself.
363+
BackupRun::record(
364+
&mut conn,
365+
NewBackupRun {
366+
bytes_uploaded: None,
367+
..new_run(
368+
Uuid::new_v4(),
369+
device_id,
370+
group_id,
371+
Some(server_id),
372+
BackupPurpose::Restore,
373+
RunOutcome::Success,
374+
)
375+
},
376+
)
377+
.await
378+
.expect("record restore");
379+
// Same snapshot id in another group: out of scope.
380+
BackupRun::record(
381+
&mut conn,
382+
new_run(
383+
Uuid::new_v4(),
384+
device_id,
385+
other_group,
386+
Some(other_server),
387+
BackupPurpose::Backup,
388+
RunOutcome::Success,
389+
),
390+
)
391+
.await
392+
.expect("record other-group backup");
393+
394+
let sizes =
395+
BackupRun::snapshot_sizes_by_id(&mut conn, group_id, &["kopia-snap-1".to_string()])
396+
.await
397+
.expect("lookup");
398+
assert_eq!(sizes.get("kopia-snap-1"), Some(&42));
399+
assert_eq!(sizes.len(), 1);
400+
401+
// Unknown ids resolve to nothing; the empty query short-circuits.
402+
let miss = BackupRun::snapshot_sizes_by_id(&mut conn, group_id, &["nope".to_string()])
403+
.await
404+
.expect("lookup miss");
405+
assert!(miss.is_empty());
406+
let none = BackupRun::snapshot_sizes_by_id(&mut conn, group_id, &[])
407+
.await
408+
.expect("empty lookup");
409+
assert!(none.is_empty());
410+
})
411+
.await;
412+
}
413+
338414
#[tokio::test(flavor = "multi_thread")]
339415
async fn latest_sized_lists_only_runs_with_both_sizes() {
340416
TestDb::run(|mut conn, _url| async move {

crates/private-server/src/fns/backups.rs

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,13 @@ pub struct RecentRun {
375375
pub error: Option<String>,
376376
/// Bytes the device reported uploading, if any (reported runs only).
377377
pub bytes_uploaded: Option<i64>,
378-
/// Snapshot the run produced, if any (reported runs only).
378+
/// Snapshot the run produced (backup) or used (restore), if any (reported
379+
/// runs only).
379380
pub snapshot_id: Option<String>,
380-
/// Logical snapshot size from repo inspection, if known (reported runs only).
381+
/// Logical size of the run's snapshot, if known (reported runs only). For
382+
/// a backup this comes from repo inspection. For a restore it is resolved
383+
/// from the backup run that produced the snapshot (matched by snapshot id),
384+
/// falling back to inspection's backfill onto the restore run itself.
381385
pub snapshot_logical_bytes: Option<i64>,
382386
/// Raw bytes sent to S3 during the run, if tallied (reported runs only).
383387
pub s3_sent_raw_bytes: Option<i64>,
@@ -409,6 +413,12 @@ pub struct RecentRun {
409413
/// surface as inferred rows — in-flight while their creds are still valid,
410414
/// otherwise unreported past attempts (e.g. manual restores, which don't report).
411415
///
416+
/// `source_snapshot_sizes` maps snapshot ids to sizes already known from the
417+
/// backup runs that produced them (see [`BackupRun::snapshot_sizes_by_id`]);
418+
/// a restore run's snapshot size resolves from it first, so the figure shows
419+
/// immediately rather than waiting for inspection to backfill the restore
420+
/// run's own row (which stays as the fallback).
421+
///
412422
/// Pairing is exact when the client stamped its run-uuid on the credential
413423
/// request (`run_id`): those issuances group by that id, so concurrent runs of
414424
/// the same type on one server never conflate. Issuances without a `run_id`
@@ -419,6 +429,7 @@ fn build_recent_runs(
419429
runs: Vec<BackupRun>,
420430
issuances: Vec<BackupCredentialIssuance>,
421431
device_to_server: &std::collections::HashMap<Uuid, Uuid>,
432+
source_snapshot_sizes: &std::collections::HashMap<String, i64>,
422433
now: Timestamp,
423434
limit: usize,
424435
) -> Vec<RecentRun> {
@@ -439,6 +450,15 @@ fn build_recent_runs(
439450
.zip(starts)
440451
.map(|(run, started_at)| {
441452
let duration_seconds = started_at.map(|s| run.reported_at.as_second() - s.as_second());
453+
let snapshot_logical_bytes = if run.purpose == BackupPurpose::Restore {
454+
run.snapshot_id
455+
.as_ref()
456+
.and_then(|id| source_snapshot_sizes.get(id))
457+
.copied()
458+
.or(run.snapshot_logical_bytes)
459+
} else {
460+
run.snapshot_logical_bytes
461+
};
442462
RecentRun {
443463
key: format!("run-{}", run.id),
444464
server_id: run.server_id,
@@ -449,7 +469,7 @@ fn build_recent_runs(
449469
error: run.error,
450470
bytes_uploaded: run.bytes_uploaded,
451471
snapshot_id: run.snapshot_id,
452-
snapshot_logical_bytes: run.snapshot_logical_bytes,
472+
snapshot_logical_bytes,
453473
s3_sent_raw_bytes: run.s3_sent_raw_bytes,
454474
s3_sent_payload_bytes: run.s3_sent_payload_bytes,
455475
s3_received_raw_bytes: run.s3_received_raw_bytes,
@@ -1819,10 +1839,22 @@ pub async fn stats(
18191839
RECENT_LIMIT * 4,
18201840
)
18211841
.await?;
1842+
// A restore's snapshot was already sized when the backup that produced it
1843+
// ran (or was inspected), so resolve restore snapshot sizes from that data
1844+
// rather than waiting for inspection to backfill the restore run's row.
1845+
let restore_snapshot_ids: Vec<String> = reported_runs
1846+
.iter()
1847+
.filter(|r| r.purpose == BackupPurpose::Restore)
1848+
.filter_map(|r| r.snapshot_id.clone())
1849+
.collect();
1850+
let source_snapshot_sizes =
1851+
BackupRun::snapshot_sizes_by_id(&mut conn, args.server_group_id, &restore_snapshot_ids)
1852+
.await?;
18221853
let recent_runs = build_recent_runs(
18231854
reported_runs,
18241855
issuances,
18251856
&device_to_server,
1857+
&source_snapshot_sizes,
18261858
now,
18271859
RECENT_LIMIT as usize,
18281860
);
@@ -2334,13 +2366,18 @@ mod tests {
23342366
std::collections::HashMap::from([(device, Uuid::from_u128(server))])
23352367
}
23362368

2369+
fn no_sizes() -> std::collections::HashMap<String, i64> {
2370+
std::collections::HashMap::new()
2371+
}
2372+
23372373
#[test]
23382374
fn reported_run_gets_duration_from_its_issuance() {
23392375
let d = Uuid::from_u128(1);
23402376
let rows = build_recent_runs(
23412377
vec![run(10, d, BackupPurpose::Backup, 4000)],
23422378
vec![issuance(1, d, BackupPurpose::Backup, 1000, 4600)],
23432379
&device_map(d, 9),
2380+
&no_sizes(),
23442381
ts(5000),
23452382
20,
23462383
);
@@ -2358,6 +2395,7 @@ mod tests {
23582395
vec![],
23592396
vec![issuance(1, d, BackupPurpose::Restore, 4900, 8500)],
23602397
&device_map(d, 9),
2398+
&no_sizes(),
23612399
ts(5000),
23622400
20,
23632401
);
@@ -2375,6 +2413,7 @@ mod tests {
23752413
vec![],
23762414
vec![issuance(1, d, BackupPurpose::Restore, 1000, 4600)],
23772415
&device_map(d, 9),
2416+
&no_sizes(),
23782417
ts(5000),
23792418
20,
23802419
);
@@ -2389,6 +2428,7 @@ mod tests {
23892428
vec![run(10, d, BackupPurpose::Restore, 4000)],
23902429
vec![issuance(1, d, BackupPurpose::Restore, 1000, 4600)],
23912430
&device_map(d, 9),
2431+
&no_sizes(),
23922432
ts(5000),
23932433
20,
23942434
);
@@ -2407,6 +2447,7 @@ mod tests {
24072447
issuance(2, d, BackupPurpose::Backup, 4500, 8100),
24082448
],
24092449
&device_map(d, 9),
2450+
&no_sizes(),
24102451
ts(9000),
24112452
20,
24122453
);
@@ -2423,6 +2464,7 @@ mod tests {
24232464
vec![run(10, d, BackupPurpose::Backup, 8000)],
24242465
vec![issuance(1, d, BackupPurpose::Backup, 1000, 4600)],
24252466
&device_map(d, 9),
2467+
&no_sizes(),
24262468
ts(9000),
24272469
20,
24282470
);
@@ -2461,6 +2503,7 @@ mod tests {
24612503
),
24622504
],
24632505
&device_map(d, 9),
2506+
&no_sizes(),
24642507
ts(5000),
24652508
20,
24662509
);
@@ -2483,6 +2526,7 @@ mod tests {
24832526
vec![run(200, d, BackupPurpose::Backup, 100_000)],
24842527
vec![issuance_for(1, d, BackupPurpose::Backup, 1000, 4600, rid)],
24852528
&device_map(d, 9),
2529+
&no_sizes(),
24862530
ts(101_000),
24872531
20,
24882532
);
@@ -2491,6 +2535,67 @@ mod tests {
24912535
assert_eq!(rows[0].duration_seconds, Some(99_000));
24922536
}
24932537

2538+
#[test]
2539+
fn restore_snapshot_size_resolves_from_the_producing_backup() {
2540+
let d = Uuid::from_u128(9);
2541+
let mut restore = run(10, d, BackupPurpose::Restore, 4000);
2542+
restore.snapshot_id = Some("snap-a".into());
2543+
// The size the snapshot was recorded at when its backup ran/was inspected.
2544+
let sizes = std::collections::HashMap::from([("snap-a".to_string(), 8_192_i64)]);
2545+
let rows = build_recent_runs(
2546+
vec![restore],
2547+
vec![],
2548+
&device_map(d, 9),
2549+
&sizes,
2550+
ts(5000),
2551+
20,
2552+
);
2553+
assert_eq!(rows.len(), 1);
2554+
assert_eq!(
2555+
rows[0].snapshot_logical_bytes,
2556+
Some(8192),
2557+
"restore shows its source snapshot's size without inspection of its own row"
2558+
);
2559+
}
2560+
2561+
#[test]
2562+
fn restore_snapshot_size_falls_back_to_its_own_backfill() {
2563+
let d = Uuid::from_u128(9);
2564+
let mut restore = run(10, d, BackupPurpose::Restore, 4000);
2565+
restore.snapshot_id = Some("snap-a".into());
2566+
restore.snapshot_logical_bytes = Some(4096);
2567+
// No producing backup carries the size — the restore row's own
2568+
// (inspection-backfilled) figure still surfaces.
2569+
let rows = build_recent_runs(
2570+
vec![restore],
2571+
vec![],
2572+
&device_map(d, 9),
2573+
&no_sizes(),
2574+
ts(5000),
2575+
20,
2576+
);
2577+
assert_eq!(rows[0].snapshot_logical_bytes, Some(4096));
2578+
}
2579+
2580+
#[test]
2581+
fn backup_snapshot_size_ignores_the_lookup() {
2582+
let d = Uuid::from_u128(9);
2583+
let mut backup = run(10, d, BackupPurpose::Backup, 4000);
2584+
backup.snapshot_id = Some("snap-a".into());
2585+
// A backup's size is its own (device-reported or backfilled) figure; the
2586+
// source-snapshot lookup is restore-only.
2587+
let sizes = std::collections::HashMap::from([("snap-a".to_string(), 8_192_i64)]);
2588+
let rows = build_recent_runs(
2589+
vec![backup],
2590+
vec![],
2591+
&device_map(d, 9),
2592+
&sizes,
2593+
ts(5000),
2594+
20,
2595+
);
2596+
assert_eq!(rows[0].snapshot_logical_bytes, None);
2597+
}
2598+
24942599
#[test]
24952600
fn inferred_rows_only_for_member_devices() {
24962601
let member = Uuid::from_u128(1);
@@ -2503,6 +2608,7 @@ mod tests {
25032608
],
25042609
// Only the member device maps to a server; the consumer device doesn't.
25052610
&device_map(member, 9),
2611+
&no_sizes(),
25062612
ts(5000),
25072613
20,
25082614
);

0 commit comments

Comments
 (0)