(bugfix) Avoid live backup data scans in web views
Use stored snapshot storage metadata for dashboard and host backup data summaries instead of walking snapshot directories during request rendering. Snapshots without recorded storage metadata are counted as not measured so large backup targets cannot trigger unbounded filesystem scans from live-refresh views. Closes #97
This commit is contained in:
@@ -5,7 +5,7 @@ from typing import Any, Iterable
|
|||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from pobsync.run_stats import filesystem_capacity, tree_usage
|
from pobsync.run_stats import filesystem_capacity
|
||||||
|
|
||||||
from .models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
|
from .models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
|
||||||
|
|
||||||
@@ -118,14 +118,26 @@ def _backup_data_by_kind(host: HostConfig) -> dict[str, Any]:
|
|||||||
for snapshot in host.snapshots.all():
|
for snapshot in host.snapshots.all():
|
||||||
summary = _snapshot_summary(snapshot)
|
summary = _snapshot_summary(snapshot)
|
||||||
row = rows.setdefault(snapshot.kind, _empty_snapshot_data_row())
|
row = rows.setdefault(snapshot.kind, _empty_snapshot_data_row())
|
||||||
allocated = summary.get("allocated_size_bytes") or summary.get("apparent_size_bytes") or 0
|
|
||||||
apparent = summary.get("apparent_size_bytes") or 0
|
|
||||||
unique_apparent = summary.get("unique_apparent_size_bytes") or 0
|
|
||||||
row["count"] += 1
|
row["count"] += 1
|
||||||
|
total["count"] += 1
|
||||||
|
if not summary.get("storage_measured"):
|
||||||
|
row["unknown_count"] += 1
|
||||||
|
total["unknown_count"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
allocated = summary.get("allocated_size_bytes")
|
||||||
|
if allocated is None:
|
||||||
|
allocated = summary.get("apparent_size_bytes")
|
||||||
|
apparent = summary.get("apparent_size_bytes")
|
||||||
|
unique_apparent = summary.get("unique_apparent_size_bytes")
|
||||||
|
allocated = int(allocated or 0)
|
||||||
|
apparent = int(apparent or 0)
|
||||||
|
unique_apparent = int(unique_apparent or 0)
|
||||||
|
row["measured_count"] += 1
|
||||||
row["allocated_size_bytes"] += int(allocated)
|
row["allocated_size_bytes"] += int(allocated)
|
||||||
row["apparent_size_bytes"] += int(apparent)
|
row["apparent_size_bytes"] += int(apparent)
|
||||||
row["unique_apparent_size_bytes"] += int(unique_apparent)
|
row["unique_apparent_size_bytes"] += int(unique_apparent)
|
||||||
total["count"] += 1
|
total["measured_count"] += 1
|
||||||
total["allocated_size_bytes"] += int(allocated)
|
total["allocated_size_bytes"] += int(allocated)
|
||||||
total["apparent_size_bytes"] += int(apparent)
|
total["apparent_size_bytes"] += int(apparent)
|
||||||
total["unique_apparent_size_bytes"] += int(unique_apparent)
|
total["unique_apparent_size_bytes"] += int(unique_apparent)
|
||||||
@@ -141,6 +153,8 @@ def _backup_data_by_kind(host: HostConfig) -> dict[str, Any]:
|
|||||||
def _empty_snapshot_data_row() -> dict[str, int]:
|
def _empty_snapshot_data_row() -> dict[str, int]:
|
||||||
return {
|
return {
|
||||||
"count": 0,
|
"count": 0,
|
||||||
|
"measured_count": 0,
|
||||||
|
"unknown_count": 0,
|
||||||
"allocated_size_bytes": 0,
|
"allocated_size_bytes": 0,
|
||||||
"apparent_size_bytes": 0,
|
"apparent_size_bytes": 0,
|
||||||
"unique_apparent_size_bytes": 0,
|
"unique_apparent_size_bytes": 0,
|
||||||
@@ -159,6 +173,8 @@ def _sum_backup_data_by_kind(rows: Iterable[dict[str, dict[str, int]]]) -> dict[
|
|||||||
for kind, values in row.items():
|
for kind, values in row.items():
|
||||||
total_row = total_rows.setdefault(kind, _empty_snapshot_data_row())
|
total_row = total_rows.setdefault(kind, _empty_snapshot_data_row())
|
||||||
total_row["count"] += values.get("count", 0)
|
total_row["count"] += values.get("count", 0)
|
||||||
|
total_row["measured_count"] += values.get("measured_count", 0)
|
||||||
|
total_row["unknown_count"] += values.get("unknown_count", 0)
|
||||||
total_row["allocated_size_bytes"] += values.get("allocated_size_bytes", 0)
|
total_row["allocated_size_bytes"] += values.get("allocated_size_bytes", 0)
|
||||||
total_row["apparent_size_bytes"] += values.get("apparent_size_bytes", 0)
|
total_row["apparent_size_bytes"] += values.get("apparent_size_bytes", 0)
|
||||||
total_row["unique_apparent_size_bytes"] += values.get("unique_apparent_size_bytes", 0)
|
total_row["unique_apparent_size_bytes"] += values.get("unique_apparent_size_bytes", 0)
|
||||||
@@ -173,15 +189,7 @@ def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
|
|||||||
stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
|
stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
|
||||||
storage = stats.get("storage") if isinstance(stats.get("storage"), dict) else {}
|
storage = stats.get("storage") if isinstance(stats.get("storage"), dict) else {}
|
||||||
snapshot_storage = storage.get("snapshot") if isinstance(storage.get("snapshot"), dict) else {}
|
snapshot_storage = storage.get("snapshot") if isinstance(storage.get("snapshot"), dict) else {}
|
||||||
if snapshot.kind == SnapshotRecord.Kind.INCOMPLETE:
|
storage_measured = _has_recorded_snapshot_storage(snapshot_storage)
|
||||||
snapshot_storage = _snapshot_storage_from_filesystem(snapshot)
|
|
||||||
else:
|
|
||||||
has_recorded_size = (
|
|
||||||
_int_at(snapshot_storage, "allocated_size_bytes") is not None
|
|
||||||
or _int_at(snapshot_storage, "apparent_size_bytes") is not None
|
|
||||||
)
|
|
||||||
if not has_recorded_size:
|
|
||||||
snapshot_storage = _snapshot_storage_from_filesystem(snapshot)
|
|
||||||
apparent_size = _int_at(snapshot_storage, "apparent_size_bytes")
|
apparent_size = _int_at(snapshot_storage, "apparent_size_bytes")
|
||||||
hardlinked_apparent = _int_at(snapshot_storage, "hardlinked_apparent_size_bytes") or 0
|
hardlinked_apparent = _int_at(snapshot_storage, "hardlinked_apparent_size_bytes") or 0
|
||||||
return {
|
return {
|
||||||
@@ -195,19 +203,18 @@ def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
|
|||||||
"hardlinked_files": _int_at(snapshot_storage, "hardlinked_files"),
|
"hardlinked_files": _int_at(snapshot_storage, "hardlinked_files"),
|
||||||
"hardlinked_apparent_size_bytes": hardlinked_apparent,
|
"hardlinked_apparent_size_bytes": hardlinked_apparent,
|
||||||
"unique_apparent_size_bytes": max((apparent_size or 0) - hardlinked_apparent, 0),
|
"unique_apparent_size_bytes": max((apparent_size or 0) - hardlinked_apparent, 0),
|
||||||
|
"storage_measured": storage_measured,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_storage_from_filesystem(snapshot: SnapshotRecord) -> dict[str, Any]:
|
def _has_recorded_snapshot_storage(snapshot_storage: dict[str, Any]) -> bool:
|
||||||
if not snapshot.path:
|
return any(
|
||||||
return {}
|
_int_at(snapshot_storage, key) is not None
|
||||||
snapshot_path = Path(snapshot.path)
|
for key in (
|
||||||
data_path = snapshot_path / "data"
|
"allocated_size_bytes",
|
||||||
if snapshot_path.name == "data":
|
"apparent_size_bytes",
|
||||||
return tree_usage(snapshot_path)
|
)
|
||||||
if data_path.exists():
|
)
|
||||||
return tree_usage(data_path)
|
|
||||||
return tree_usage(snapshot_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_real_run(run: BackupRun) -> bool:
|
def _is_real_run(run: BackupRun) -> bool:
|
||||||
|
|||||||
@@ -190,27 +190,35 @@
|
|||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="label">Scheduled</div>
|
<div class="label">Scheduled</div>
|
||||||
<div class="value">{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
|
||||||
<div class="muted">unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</div>
|
<div class="muted">
|
||||||
|
unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.scheduled.unknown_count %}; {{ stats_summary.backup_data.scheduled.unknown_count }} not measured{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="label">Manual</div>
|
<div class="label">Manual</div>
|
||||||
<div class="value">{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
|
||||||
<div class="muted">unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</div>
|
<div class="muted">
|
||||||
|
unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.manual.unknown_count %}; {{ stats_summary.backup_data.manual.unknown_count }} not measured{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="label">Incomplete</div>
|
<div class="label">Incomplete</div>
|
||||||
<div class="value">{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
|
||||||
<div class="muted">measured from disk</div>
|
<div class="muted">
|
||||||
|
stored metadata{% if stats_summary.backup_data.incomplete.unknown_count %}; {{ stats_summary.backup_data.incomplete.unknown_count }} not measured{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="label">Total</div>
|
<div class="label">Total</div>
|
||||||
<div class="value">{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
|
||||||
<div class="muted">unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</div>
|
<div class="muted">
|
||||||
|
unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.total.unknown_count %}; {{ stats_summary.backup_data.total.unknown_count }} not measured{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Main totals use allocated snapshot size. Unique values estimate non-hardlinked visible data; incomplete
|
Main totals use stored snapshot metadata. Unique values estimate non-hardlinked visible data; snapshots without
|
||||||
snapshots are measured from disk because their metadata can be stale.
|
recorded storage metadata are shown as not measured until a backup or metrics refresh records them.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -105,22 +105,30 @@
|
|||||||
<div class="host-card-stat">
|
<div class="host-card-stat">
|
||||||
<div class="label">Scheduled data</div>
|
<div class="label">Scheduled data</div>
|
||||||
<div class="value">{{ host.stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ host.stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
|
||||||
<div class="muted">unique {{ host.stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</div>
|
<div class="muted">
|
||||||
|
unique {{ host.stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}{% if host.stats_summary.backup_data.scheduled.unknown_count %}; {{ host.stats_summary.backup_data.scheduled.unknown_count }} not measured{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="host-card-stat">
|
<div class="host-card-stat">
|
||||||
<div class="label">Manual data</div>
|
<div class="label">Manual data</div>
|
||||||
<div class="value">{{ host.stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ host.stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
|
||||||
<div class="muted">unique {{ host.stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</div>
|
<div class="muted">
|
||||||
|
unique {{ host.stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}{% if host.stats_summary.backup_data.manual.unknown_count %}; {{ host.stats_summary.backup_data.manual.unknown_count }} not measured{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="host-card-stat">
|
<div class="host-card-stat">
|
||||||
<div class="label">Incomplete data</div>
|
<div class="label">Incomplete data</div>
|
||||||
<div class="value">{{ host.stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ host.stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
|
||||||
<div class="muted">measured from disk</div>
|
<div class="muted">
|
||||||
|
stored metadata{% if host.stats_summary.backup_data.incomplete.unknown_count %}; {{ host.stats_summary.backup_data.incomplete.unknown_count }} not measured{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="host-card-stat">
|
<div class="host-card-stat">
|
||||||
<div class="label">Total data</div>
|
<div class="label">Total data</div>
|
||||||
<div class="value">{{ host.stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
|
<div class="value">{{ host.stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
|
||||||
<div class="muted">unique {{ host.stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</div>
|
<div class="muted">
|
||||||
|
unique {{ host.stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}{% if host.stats_summary.backup_data.total.unknown_count %}; {{ host.stats_summary.backup_data.total.unknown_count }} not measured{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -130,22 +130,30 @@
|
|||||||
<div>
|
<div>
|
||||||
<span class="label">Scheduled data</span>
|
<span class="label">Scheduled data</span>
|
||||||
<strong>{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</strong>
|
<strong>{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
<span class="muted">unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</span>
|
<span class="muted">
|
||||||
|
unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.scheduled.unknown_count %}; {{ stats_summary.backup_data.scheduled.unknown_count }} not measured{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Manual data</span>
|
<span class="label">Manual data</span>
|
||||||
<strong>{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</strong>
|
<strong>{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
<span class="muted">unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</span>
|
<span class="muted">
|
||||||
|
unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.manual.unknown_count %}; {{ stats_summary.backup_data.manual.unknown_count }} not measured{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Incomplete data</span>
|
<span class="label">Incomplete data</span>
|
||||||
<strong>{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</strong>
|
<strong>{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
<span class="muted">measured from disk</span>
|
<span class="muted">
|
||||||
|
stored metadata{% if stats_summary.backup_data.incomplete.unknown_count %}; {{ stats_summary.backup_data.incomplete.unknown_count }} not measured{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Total snapshot data</span>
|
<span class="label">Total snapshot data</span>
|
||||||
<strong>{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</strong>
|
<strong>{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
<span class="muted">unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</span>
|
<span class="muted">
|
||||||
|
unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.total.unknown_count %}; {{ stats_summary.backup_data.total.unknown_count }} not measured{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from unittest.mock import patch
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from pobsync.run_stats import tree_usage
|
|
||||||
from pobsync_backend.models import HostConfig, SnapshotRecord
|
from pobsync_backend.models import HostConfig, SnapshotRecord
|
||||||
from pobsync_backend.stats_summary import collect_dashboard_stats, collect_host_stats
|
from pobsync_backend.stats_summary import collect_dashboard_stats, collect_host_stats
|
||||||
|
|
||||||
@@ -18,114 +16,109 @@ class StatsSummaryTests(TestCase):
|
|||||||
self._snapshot(web, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100)
|
self._snapshot(web, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100)
|
||||||
self._snapshot(web, "20260519-031500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=200)
|
self._snapshot(web, "20260519-031500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=200)
|
||||||
self._snapshot(db, "20260519-041500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=300)
|
self._snapshot(db, "20260519-041500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=300)
|
||||||
with TemporaryDirectory() as tmp:
|
self._snapshot(db, "20260519-051500Z__BROKEN1", SnapshotRecord.Kind.INCOMPLETE, allocated=400)
|
||||||
incomplete_usage = self._incomplete_snapshot_on_disk(
|
|
||||||
db,
|
|
||||||
Path(tmp),
|
|
||||||
"20260519-051500Z__BROKEN1",
|
|
||||||
)
|
|
||||||
|
|
||||||
stats = collect_dashboard_stats(hosts=[web, db], global_config=None)
|
stats = collect_dashboard_stats(hosts=[web, db], global_config=None)
|
||||||
|
|
||||||
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2)
|
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2)
|
||||||
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 400)
|
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 400)
|
||||||
self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 200)
|
self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 200)
|
||||||
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], incomplete_usage["allocated_size_bytes"])
|
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], 400)
|
||||||
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
|
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
|
||||||
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 600 + incomplete_usage["allocated_size_bytes"])
|
self.assertEqual(stats["backup_data"]["total"]["measured_count"], 4)
|
||||||
self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 1200 + incomplete_usage["apparent_size_bytes"])
|
self.assertEqual(stats["backup_data"]["total"]["unknown_count"], 0)
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 1000)
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 2000)
|
||||||
|
|
||||||
def test_collect_host_stats_sums_backup_data_by_snapshot_kind(self) -> None:
|
def test_collect_host_stats_sums_backup_data_by_snapshot_kind(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
self._snapshot(host, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100)
|
self._snapshot(host, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100)
|
||||||
self._snapshot(host, "20260519-031500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=200)
|
self._snapshot(host, "20260519-031500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=200)
|
||||||
self._snapshot(host, "20260519-041500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=300)
|
self._snapshot(host, "20260519-041500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=300)
|
||||||
with TemporaryDirectory() as tmp:
|
self._snapshot(host, "20260519-051500Z__BROKEN1", SnapshotRecord.Kind.INCOMPLETE, allocated=400)
|
||||||
incomplete_usage = self._incomplete_snapshot_on_disk(
|
|
||||||
host,
|
|
||||||
Path(tmp),
|
|
||||||
"20260519-051500Z__BROKEN1",
|
|
||||||
)
|
|
||||||
|
|
||||||
stats = collect_host_stats(host=host)
|
stats = collect_host_stats(host=host)
|
||||||
|
|
||||||
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2)
|
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2)
|
||||||
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 300)
|
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 300)
|
||||||
self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 300)
|
self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 300)
|
||||||
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], incomplete_usage["allocated_size_bytes"])
|
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], 400)
|
||||||
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
|
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
|
||||||
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 600 + incomplete_usage["allocated_size_bytes"])
|
self.assertEqual(stats["backup_data"]["total"]["measured_count"], 4)
|
||||||
self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 1200 + incomplete_usage["apparent_size_bytes"])
|
self.assertEqual(stats["backup_data"]["total"]["unknown_count"], 0)
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 1000)
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 2000)
|
||||||
|
|
||||||
def test_collect_host_stats_falls_back_to_filesystem_usage_for_snapshots_without_metadata(self) -> None:
|
def test_collect_host_stats_marks_snapshots_without_storage_metadata_unknown(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
with TemporaryDirectory() as tmp:
|
SnapshotRecord.objects.create(
|
||||||
incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-051500Z__BROKEN1"
|
host=host,
|
||||||
data_dir = incomplete_dir / "data"
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
meta_dir = incomplete_dir / "meta"
|
dirname="20260519-051500Z__BROKEN1",
|
||||||
data_dir.mkdir(parents=True)
|
path="/backups/web-01/.incomplete/20260519-051500Z__BROKEN1",
|
||||||
meta_dir.mkdir()
|
status="failed",
|
||||||
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
|
metadata={},
|
||||||
meta_dir.joinpath("rsync.log").write_text("not part of the backup data total\n", encoding="utf-8")
|
)
|
||||||
expected_usage = tree_usage(data_dir)
|
|
||||||
SnapshotRecord.objects.create(
|
|
||||||
host=host,
|
|
||||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
|
||||||
dirname=incomplete_dir.name,
|
|
||||||
path=str(incomplete_dir),
|
|
||||||
status="failed",
|
|
||||||
metadata={},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
with patch("pobsync_backend.stats_summary.tree_usage", create=True) as tree_usage:
|
||||||
stats = collect_host_stats(host=host)
|
stats = collect_host_stats(host=host)
|
||||||
|
|
||||||
|
tree_usage.assert_not_called()
|
||||||
self.assertEqual(stats["backup_data"]["incomplete"]["count"], 1)
|
self.assertEqual(stats["backup_data"]["incomplete"]["count"], 1)
|
||||||
self.assertEqual(
|
self.assertEqual(stats["backup_data"]["incomplete"]["measured_count"], 0)
|
||||||
stats["backup_data"]["incomplete"]["allocated_size_bytes"],
|
self.assertEqual(stats["backup_data"]["incomplete"]["unknown_count"], 1)
|
||||||
expected_usage["allocated_size_bytes"],
|
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], 0)
|
||||||
)
|
self.assertEqual(stats["backup_data"]["incomplete"]["apparent_size_bytes"], 0)
|
||||||
self.assertEqual(
|
self.assertEqual(stats["backup_data"]["total"]["unknown_count"], 1)
|
||||||
stats["backup_data"]["incomplete"]["apparent_size_bytes"],
|
|
||||||
expected_usage["apparent_size_bytes"],
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
stats["backup_data"]["total"]["allocated_size_bytes"],
|
|
||||||
expected_usage["allocated_size_bytes"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_collect_host_stats_measures_incomplete_data_from_disk_even_with_stale_metadata(self) -> None:
|
def test_collect_host_stats_uses_recorded_zero_storage_without_rescanning(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
with TemporaryDirectory() as tmp:
|
SnapshotRecord.objects.create(
|
||||||
incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-051500Z__BROKEN1"
|
host=host,
|
||||||
data_dir = incomplete_dir / "data"
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
data_dir.mkdir(parents=True)
|
dirname="20260519-051500Z__BROKEN1",
|
||||||
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
|
path="/backups/web-01/.incomplete/20260519-051500Z__BROKEN1",
|
||||||
expected_usage = tree_usage(data_dir)
|
status="failed",
|
||||||
SnapshotRecord.objects.create(
|
metadata={
|
||||||
host=host,
|
"stats": {
|
||||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
"storage": {
|
||||||
dirname=incomplete_dir.name,
|
"snapshot": {
|
||||||
path=str(incomplete_dir),
|
"apparent_size_bytes": 0,
|
||||||
status="failed",
|
"allocated_size_bytes": 0,
|
||||||
metadata={
|
|
||||||
"stats": {
|
|
||||||
"storage": {
|
|
||||||
"snapshot": {
|
|
||||||
"apparent_size_bytes": 0,
|
|
||||||
"allocated_size_bytes": 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pobsync_backend.stats_summary.tree_usage", create=True) as tree_usage:
|
||||||
stats = collect_host_stats(host=host)
|
stats = collect_host_stats(host=host)
|
||||||
|
|
||||||
self.assertEqual(
|
tree_usage.assert_not_called()
|
||||||
stats["backup_data"]["incomplete"]["allocated_size_bytes"],
|
self.assertEqual(stats["backup_data"]["incomplete"]["measured_count"], 1)
|
||||||
expected_usage["allocated_size_bytes"],
|
self.assertEqual(stats["backup_data"]["incomplete"]["unknown_count"], 0)
|
||||||
|
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], 0)
|
||||||
|
self.assertEqual(stats["backup_data"]["incomplete"]["apparent_size_bytes"], 0)
|
||||||
|
|
||||||
|
def test_collect_dashboard_stats_does_not_scan_filesystem_for_missing_snapshot_metadata(self) -> None:
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
SnapshotRecord.objects.create(
|
||||||
|
host=host,
|
||||||
|
kind=SnapshotRecord.Kind.SCHEDULED,
|
||||||
|
dirname="20260519-051500Z__SCHED01",
|
||||||
|
path="/backups/web-01/scheduled/20260519-051500Z__SCHED01",
|
||||||
|
status="success",
|
||||||
|
metadata={},
|
||||||
)
|
)
|
||||||
self.assertGreater(stats["backup_data"]["incomplete"]["apparent_size_bytes"], 0)
|
|
||||||
|
with patch("pobsync_backend.stats_summary.tree_usage", create=True) as tree_usage:
|
||||||
|
stats = collect_dashboard_stats(hosts=[host], global_config=None)
|
||||||
|
|
||||||
|
tree_usage.assert_not_called()
|
||||||
|
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 1)
|
||||||
|
self.assertEqual(stats["backup_data"]["scheduled"]["measured_count"], 0)
|
||||||
|
self.assertEqual(stats["backup_data"]["scheduled"]["unknown_count"], 1)
|
||||||
|
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 0)
|
||||||
|
|
||||||
def test_collect_host_stats_reports_non_hardlinked_snapshot_data(self) -> None:
|
def test_collect_host_stats_reports_non_hardlinked_snapshot_data(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
@@ -147,21 +140,6 @@ class StatsSummaryTests(TestCase):
|
|||||||
def _snapshot(self, host: HostConfig, dirname: str, kind: str, *, allocated: int) -> SnapshotRecord:
|
def _snapshot(self, host: HostConfig, dirname: str, kind: str, *, allocated: int) -> SnapshotRecord:
|
||||||
return self._snapshot_with_sizes(host, dirname, kind, allocated=allocated)
|
return self._snapshot_with_sizes(host, dirname, kind, allocated=allocated)
|
||||||
|
|
||||||
def _incomplete_snapshot_on_disk(self, host: HostConfig, root: Path, dirname: str) -> dict:
|
|
||||||
incomplete_dir = root / host.host / ".incomplete" / dirname
|
|
||||||
data_dir = incomplete_dir / "data"
|
|
||||||
data_dir.mkdir(parents=True)
|
|
||||||
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
|
|
||||||
usage = tree_usage(data_dir)
|
|
||||||
SnapshotRecord.objects.create(
|
|
||||||
host=host,
|
|
||||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
|
||||||
dirname=dirname,
|
|
||||||
path=str(incomplete_dir),
|
|
||||||
status="failed",
|
|
||||||
)
|
|
||||||
return usage
|
|
||||||
|
|
||||||
def _snapshot_with_sizes(
|
def _snapshot_with_sizes(
|
||||||
self,
|
self,
|
||||||
host: HostConfig,
|
host: HostConfig,
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.template.defaultfilters import filesizeformat
|
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from pobsync.run_stats import tree_usage
|
|
||||||
from pobsync.util import write_yaml_atomic
|
from pobsync.util import write_yaml_atomic
|
||||||
from pobsync_backend.models import (
|
from pobsync_backend.models import (
|
||||||
BackupRun,
|
BackupRun,
|
||||||
@@ -464,23 +462,12 @@ class ViewTests(TestCase):
|
|||||||
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||||
scheduled = self._snapshot(web, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
|
scheduled = self._snapshot(web, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
|
||||||
manual = self._snapshot(web, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL)
|
manual = self._snapshot(web, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL)
|
||||||
|
incomplete = self._snapshot(db, "20260519-041500Z__BROKEN1", kind=SnapshotRecord.Kind.INCOMPLETE)
|
||||||
self._set_snapshot_storage(scheduled, allocated=100)
|
self._set_snapshot_storage(scheduled, allocated=100)
|
||||||
self._set_snapshot_storage(manual, allocated=200)
|
self._set_snapshot_storage(manual, allocated=200)
|
||||||
with TemporaryDirectory() as tmp:
|
self._set_snapshot_storage(incomplete, allocated=400)
|
||||||
incomplete_dir = Path(tmp) / db.host / ".incomplete" / "20260519-041500Z__BROKEN1"
|
|
||||||
data_dir = incomplete_dir / "data"
|
|
||||||
data_dir.mkdir(parents=True)
|
|
||||||
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
|
|
||||||
expected_usage = tree_usage(data_dir)
|
|
||||||
SnapshotRecord.objects.create(
|
|
||||||
host=db,
|
|
||||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
|
||||||
dirname=incomplete_dir.name,
|
|
||||||
path=str(incomplete_dir),
|
|
||||||
status="failed",
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(reverse("dashboard_priority_live"))
|
response = self.client.get(reverse("dashboard_priority_live"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Scheduled data")
|
self.assertContains(response, "Scheduled data")
|
||||||
@@ -489,8 +476,8 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Total snapshot data")
|
self.assertContains(response, "Total snapshot data")
|
||||||
self.assertContains(response, "100 bytes", html=True)
|
self.assertContains(response, "100 bytes", html=True)
|
||||||
self.assertContains(response, "200 bytes", html=True)
|
self.assertContains(response, "200 bytes", html=True)
|
||||||
self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"]))
|
self.assertContains(response, "400 bytes", html=True)
|
||||||
self.assertContains(response, filesizeformat(300 + expected_usage["allocated_size_bytes"]))
|
self.assertContains(response, "700 bytes", html=True)
|
||||||
|
|
||||||
def test_dashboard_hosts_live_returns_hosts_partial(self) -> None:
|
def test_dashboard_hosts_live_returns_hosts_partial(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -510,23 +497,12 @@ class ViewTests(TestCase):
|
|||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
scheduled = self._snapshot(host, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
|
scheduled = self._snapshot(host, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
|
||||||
manual = self._snapshot(host, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL)
|
manual = self._snapshot(host, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL)
|
||||||
|
incomplete = self._snapshot(host, "20260519-041500Z__BROKEN1", kind=SnapshotRecord.Kind.INCOMPLETE)
|
||||||
self._set_snapshot_storage(scheduled, allocated=100)
|
self._set_snapshot_storage(scheduled, allocated=100)
|
||||||
self._set_snapshot_storage(manual, allocated=200)
|
self._set_snapshot_storage(manual, allocated=200)
|
||||||
with TemporaryDirectory() as tmp:
|
self._set_snapshot_storage(incomplete, allocated=400)
|
||||||
incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-041500Z__BROKEN1"
|
|
||||||
data_dir = incomplete_dir / "data"
|
|
||||||
data_dir.mkdir(parents=True)
|
|
||||||
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
|
|
||||||
expected_usage = tree_usage(data_dir)
|
|
||||||
SnapshotRecord.objects.create(
|
|
||||||
host=host,
|
|
||||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
|
||||||
dirname=incomplete_dir.name,
|
|
||||||
path=str(incomplete_dir),
|
|
||||||
status="failed",
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(reverse("dashboard_hosts_live"))
|
response = self.client.get(reverse("dashboard_hosts_live"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Scheduled data")
|
self.assertContains(response, "Scheduled data")
|
||||||
@@ -535,32 +511,27 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Total data")
|
self.assertContains(response, "Total data")
|
||||||
self.assertContains(response, "100 bytes", html=True)
|
self.assertContains(response, "100 bytes", html=True)
|
||||||
self.assertContains(response, "200 bytes", html=True)
|
self.assertContains(response, "200 bytes", html=True)
|
||||||
self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"]))
|
self.assertContains(response, "400 bytes", html=True)
|
||||||
self.assertContains(response, filesizeformat(300 + expected_usage["allocated_size_bytes"]))
|
self.assertContains(response, "700 bytes", html=True)
|
||||||
|
|
||||||
def test_dashboard_host_cards_measure_incomplete_data_without_snapshot_metadata(self) -> None:
|
def test_dashboard_host_cards_mark_incomplete_data_without_snapshot_metadata_unmeasured(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
with TemporaryDirectory() as tmp:
|
SnapshotRecord.objects.create(
|
||||||
incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-041500Z__BROKEN1"
|
host=host,
|
||||||
data_dir = incomplete_dir / "data"
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
data_dir.mkdir(parents=True)
|
dirname="20260519-041500Z__BROKEN1",
|
||||||
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
|
path="/backups/web-01/.incomplete/20260519-041500Z__BROKEN1",
|
||||||
expected_usage = tree_usage(data_dir)
|
status="failed",
|
||||||
SnapshotRecord.objects.create(
|
metadata={},
|
||||||
host=host,
|
)
|
||||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
|
||||||
dirname=incomplete_dir.name,
|
|
||||||
path=str(incomplete_dir),
|
|
||||||
status="failed",
|
|
||||||
metadata={},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(reverse("dashboard_hosts_live"))
|
response = self.client.get(reverse("dashboard_hosts_live"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Incomplete data")
|
self.assertContains(response, "Incomplete data")
|
||||||
self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"]))
|
self.assertContains(response, "0 bytes", html=True)
|
||||||
|
self.assertContains(response, "1 not measured")
|
||||||
|
|
||||||
def test_hosts_list_renders_host_cards_and_controls(self) -> None:
|
def test_hosts_list_renders_host_cards_and_controls(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
|
|||||||
Reference in New Issue
Block a user