cctv-stack 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cctv/__init__.py +19 -0
- cctv/atomic.py +184 -0
- cctv/cleanup.py +1014 -0
- cctv/clip.py +278 -0
- cctv/config.py +742 -0
- cctv/config_cli.py +217 -0
- cctv/db.py +1478 -0
- cctv/dispatch.py +270 -0
- cctv/ffprobe.py +246 -0
- cctv/frigate.py +411 -0
- cctv/health.py +413 -0
- cctv/logging.py +157 -0
- cctv/materialise.py +488 -0
- cctv/migrations/0001_init.sql +199 -0
- cctv/migrations/0002_finding_kinds.sql +35 -0
- cctv/mqtt.py +171 -0
- cctv/paths.py +290 -0
- cctv/reviews.py +279 -0
- cctv/storage.py +751 -0
- cctv/sweep.py +254 -0
- cctv/timeutil.py +154 -0
- cctv/uploader.py +1417 -0
- cctv/worker.py +385 -0
- cctv_stack-0.3.0.dist-info/METADATA +410 -0
- cctv_stack-0.3.0.dist-info/RECORD +28 -0
- cctv_stack-0.3.0.dist-info/WHEEL +4 -0
- cctv_stack-0.3.0.dist-info/entry_points.txt +5 -0
- cctv_stack-0.3.0.dist-info/licenses/LICENSE +201 -0
cctv/cleanup.py
ADDED
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
"""Local clip retention — cleanup job J1 ([LLD 05](../../../docs/architecture/lld/05-cleanup.md)).
|
|
2
|
+
|
|
3
|
+
This is the only module in the system that deletes a clip, and therefore the one that has to
|
|
4
|
+
be the most conservative. [INV-1](../../../docs/architecture/invariants.md) is the reason: a
|
|
5
|
+
clip is never deleted locally until its upload state is terminal-success, or the operator
|
|
6
|
+
explicitly forces it.
|
|
7
|
+
|
|
8
|
+
The implementation is shaped so that the unsafe thing is **impossible** rather than unlikely
|
|
9
|
+
(LLD 05 §2):
|
|
10
|
+
|
|
11
|
+
1. `db.local_expiry_batch` enumerates the states that permit deletion, so a state added later
|
|
12
|
+
is safe by default rather than deletable by default.
|
|
13
|
+
2. Each clip's row is re-read and re-checked immediately before its file is unlinked, which
|
|
14
|
+
catches a state change between the page query and the unlink.
|
|
15
|
+
3. An `UPLOADED` clip must also carry a `cloud_key` **and** an `uploaded_at`, so a row marked
|
|
16
|
+
uploaded without a recorded upload is never treated as backed up.
|
|
17
|
+
4. The path is resolved and must stay inside `clips/`, so a corrupted or tampered `path`
|
|
18
|
+
cannot reach anything else.
|
|
19
|
+
5. `DRY_RUN` logs every intended deletion and performs none.
|
|
20
|
+
|
|
21
|
+
What this module deliberately does **not** do (LLD 05 §2.3): walk the filesystem to decide
|
|
22
|
+
what to delete; delete differently for a retired camera; touch `data/media/` or
|
|
23
|
+
`data/frigate/`; list the bucket to decide anything; re-upload; or auto-resolve a finding.
|
|
24
|
+
|
|
25
|
+
## Scope
|
|
26
|
+
|
|
27
|
+
**J1 only.** Cloud retention (J2), reconciliation (J3), housekeeping (J4) and the full
|
|
28
|
+
retention report (J5) are not implemented yet; the deferred work is recorded in
|
|
29
|
+
[`docs/plans/milestones.md`](../../../docs/plans/milestones.md). The service wrapper, health
|
|
30
|
+
surface and compose entry point arrive with the rest of M4.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
import shutil
|
|
38
|
+
import signal
|
|
39
|
+
import socket
|
|
40
|
+
import sqlite3
|
|
41
|
+
import sys
|
|
42
|
+
import threading
|
|
43
|
+
from collections.abc import Mapping, Sequence
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from pathlib import Path, PurePosixPath
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from . import db, health, paths, timeutil
|
|
49
|
+
from .config import CamerasConfig, ConfigError, load_cameras_config
|
|
50
|
+
from .logging import configure_logging
|
|
51
|
+
from .timeutil import Instant
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"COMPONENT",
|
|
57
|
+
"DEFAULT_BATCH_SIZE",
|
|
58
|
+
"DEFAULT_HEALTH_PORT",
|
|
59
|
+
"DEFAULT_LOCAL_DAYS",
|
|
60
|
+
"DEFAULT_RETIRE_GRACE_DAYS",
|
|
61
|
+
"DEFAULT_RUN_INTERVAL_S",
|
|
62
|
+
"CleanupHealth",
|
|
63
|
+
"CleanupPathError",
|
|
64
|
+
"CleanupService",
|
|
65
|
+
"CleanupSettings",
|
|
66
|
+
"LocalRetention",
|
|
67
|
+
"RetentionRun",
|
|
68
|
+
"main",
|
|
69
|
+
"resolve_within_clips_dir",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
#: The component name used in logs, health reports and `system_events`.
|
|
73
|
+
COMPONENT = "cleanup"
|
|
74
|
+
|
|
75
|
+
#: The directory, under the data directory, that local retention owns.
|
|
76
|
+
CLIPS_DIR_NAME = "clips"
|
|
77
|
+
|
|
78
|
+
SECONDS_PER_DAY = 86400.0
|
|
79
|
+
|
|
80
|
+
DEFAULT_RUN_INTERVAL_S = 3600.0
|
|
81
|
+
DEFAULT_BATCH_SIZE = 200
|
|
82
|
+
DEFAULT_SCHEDULER_TICK_S = 1.0
|
|
83
|
+
|
|
84
|
+
#: The cleanup health surface (hld.md §11 — internal only, loopback in compose).
|
|
85
|
+
DEFAULT_HEALTH_PORT = 8083
|
|
86
|
+
|
|
87
|
+
#: Overdue-retention thresholds. `lld/05` §9: 0 is HEALTHY, 1-10 is DEGRADED, more than 10 -
|
|
88
|
+
#: or anything held longer than 30 days - is UNHEALTHY. The `warn` default is 1 rather than 10
|
|
89
|
+
#: because even one held clip means the invariant is costing disk, and the point of J5 is to
|
|
90
|
+
#: make that visible while it is still small.
|
|
91
|
+
DEFAULT_CLIPS_OVERDUE_WARN = 1
|
|
92
|
+
DEFAULT_CLIPS_OVERDUE_CRITICAL = 10
|
|
93
|
+
DEFAULT_OVERDUE_AGE_CRITICAL_DAYS = 30
|
|
94
|
+
|
|
95
|
+
#: `health.disk_warn_pct` / `disk_critical_pct` (lld/00 §4), shared by every service.
|
|
96
|
+
DEFAULT_DISK_WARN_PCT = 15.0
|
|
97
|
+
DEFAULT_DISK_CRITICAL_PCT = 5.0
|
|
98
|
+
|
|
99
|
+
EXIT_OK = 0
|
|
100
|
+
EXIT_CONFIG = 78
|
|
101
|
+
EXIT_UNAVAILABLE = 69
|
|
102
|
+
|
|
103
|
+
#: The `sweep_state` row recording when J1 last ran. That table is reused rather than a new one
|
|
104
|
+
#: created: it already carries `last_run_at`, `last_success_at` and `consecutive_failures`,
|
|
105
|
+
#: which is exactly what a staleness check needs.
|
|
106
|
+
LAST_RUN_NAME = "cleanup_local"
|
|
107
|
+
|
|
108
|
+
#: The retention applied when a camera is not in `cameras.yml` — a retired camera. Mirrors
|
|
109
|
+
#: `RetentionConfig`'s default, and is deliberately a copy rather than an import so that
|
|
110
|
+
#: changing the config default cannot silently change what happens to retired footage.
|
|
111
|
+
DEFAULT_LOCAL_DAYS = 90
|
|
112
|
+
DEFAULT_RETIRE_GRACE_DAYS = 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class CleanupPathError(ValueError):
|
|
116
|
+
"""A clip path that will not be acted on.
|
|
117
|
+
|
|
118
|
+
Raised rather than worked around: a `clips.path` that escapes `clips/` means either a bug
|
|
119
|
+
or tampering, and the only safe response is to refuse and report. Nothing is deleted.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True, slots=True)
|
|
124
|
+
class CleanupSettings:
|
|
125
|
+
"""Every input is an environment variable, so there are no CLI arguments to get wrong."""
|
|
126
|
+
|
|
127
|
+
data_dir: Path
|
|
128
|
+
cameras_path: Path
|
|
129
|
+
db_path: Path
|
|
130
|
+
run_interval_s: float
|
|
131
|
+
batch_size: int
|
|
132
|
+
dry_run: bool
|
|
133
|
+
retire_grace_days: int
|
|
134
|
+
health_host: str
|
|
135
|
+
health_port: int
|
|
136
|
+
scheduler_tick_s: float
|
|
137
|
+
clips_overdue_warn: int
|
|
138
|
+
clips_overdue_critical: int
|
|
139
|
+
overdue_age_critical_days: int
|
|
140
|
+
disk_warn_pct: float
|
|
141
|
+
disk_critical_pct: float
|
|
142
|
+
log_level: str | None
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def clips_dir(self) -> Path:
|
|
146
|
+
return self.data_dir / CLIPS_DIR_NAME
|
|
147
|
+
|
|
148
|
+
def configuration_problem(self) -> str | None:
|
|
149
|
+
if self.run_interval_s <= 0:
|
|
150
|
+
return "CCTV_CLEANUP_RUN_INTERVAL_S must be positive."
|
|
151
|
+
if self.batch_size < 1:
|
|
152
|
+
return "CCTV_CLEANUP_BATCH_SIZE must be at least 1."
|
|
153
|
+
if self.retire_grace_days < 0:
|
|
154
|
+
return "CCTV_CLEANUP_RETIRE_GRACE_DAYS cannot be negative."
|
|
155
|
+
if self.health_port < 0:
|
|
156
|
+
return "CCTV_CLEANUP_HEALTH_PORT cannot be negative."
|
|
157
|
+
if self.clips_overdue_critical < self.clips_overdue_warn:
|
|
158
|
+
return "CCTV_CLEANUP_OVERDUE_CRITICAL cannot be below CCTV_CLEANUP_OVERDUE_WARN."
|
|
159
|
+
if self.disk_critical_pct >= self.disk_warn_pct:
|
|
160
|
+
return "CCTV_HEALTH_DISK_CRITICAL_PCT must be below CCTV_HEALTH_DISK_WARN_PCT."
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_environ(cls, environ: Mapping[str, str] | None = None) -> CleanupSettings:
|
|
165
|
+
env = os.environ if environ is None else environ
|
|
166
|
+
|
|
167
|
+
def number(name: str, default: float) -> float:
|
|
168
|
+
raw = (env.get(name) or "").strip()
|
|
169
|
+
if not raw:
|
|
170
|
+
return default
|
|
171
|
+
try:
|
|
172
|
+
return float(raw)
|
|
173
|
+
except ValueError as exc:
|
|
174
|
+
raise ConfigError(f"{name}={raw!r} is not a number.") from exc
|
|
175
|
+
|
|
176
|
+
config_dir = Path(env.get("CCTV_CONFIG_DIR", "/config"))
|
|
177
|
+
data_dir = Path(env.get("CCTV_DATA_DIR", "/data"))
|
|
178
|
+
|
|
179
|
+
return cls(
|
|
180
|
+
data_dir=data_dir,
|
|
181
|
+
cameras_path=Path(env.get("CCTV_CAMERAS", str(config_dir / "cameras.yml"))),
|
|
182
|
+
db_path=Path(env.get("CCTV_DB", str(data_dir / "cctv.db"))),
|
|
183
|
+
run_interval_s=number("CCTV_CLEANUP_RUN_INTERVAL_S", DEFAULT_RUN_INTERVAL_S),
|
|
184
|
+
batch_size=int(number("CCTV_CLEANUP_BATCH_SIZE", DEFAULT_BATCH_SIZE)),
|
|
185
|
+
dry_run=(env.get("DRY_RUN") or "").strip().lower() in {"1", "true", "yes"},
|
|
186
|
+
retire_grace_days=int(
|
|
187
|
+
number("CCTV_CLEANUP_RETIRE_GRACE_DAYS", DEFAULT_RETIRE_GRACE_DAYS)
|
|
188
|
+
),
|
|
189
|
+
health_host=(env.get("CCTV_HEALTH_HOST") or "0.0.0.0").strip(),
|
|
190
|
+
health_port=int(number("CCTV_CLEANUP_HEALTH_PORT", DEFAULT_HEALTH_PORT)),
|
|
191
|
+
scheduler_tick_s=number("CCTV_CLEANUP_TICK_S", DEFAULT_SCHEDULER_TICK_S),
|
|
192
|
+
clips_overdue_warn=int(number("CCTV_CLEANUP_OVERDUE_WARN", DEFAULT_CLIPS_OVERDUE_WARN)),
|
|
193
|
+
clips_overdue_critical=int(
|
|
194
|
+
number("CCTV_CLEANUP_OVERDUE_CRITICAL", DEFAULT_CLIPS_OVERDUE_CRITICAL)
|
|
195
|
+
),
|
|
196
|
+
overdue_age_critical_days=int(
|
|
197
|
+
number(
|
|
198
|
+
"CCTV_CLEANUP_OVERDUE_AGE_CRITICAL_DAYS",
|
|
199
|
+
DEFAULT_OVERDUE_AGE_CRITICAL_DAYS,
|
|
200
|
+
)
|
|
201
|
+
),
|
|
202
|
+
disk_warn_pct=number("CCTV_HEALTH_DISK_WARN_PCT", DEFAULT_DISK_WARN_PCT),
|
|
203
|
+
disk_critical_pct=number("CCTV_HEALTH_DISK_CRITICAL_PCT", DEFAULT_DISK_CRITICAL_PCT),
|
|
204
|
+
log_level=env.get("CCTV_LOG_LEVEL") or None,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_within_clips_dir(clips_dir: Path, relpath: str) -> Path:
|
|
209
|
+
"""Resolve a clip's path and refuse anything outside `clips/` (guard 4).
|
|
210
|
+
|
|
211
|
+
The path stored in the database is relative to the data directory — `clips/<camera>/…` —
|
|
212
|
+
so it is joined to the clips directory's parent, then resolved. Resolution follows
|
|
213
|
+
symlinks, so a symlink pointing outside the tree is refused too, which a purely lexical
|
|
214
|
+
check would miss.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
CleanupPathError: the path is empty, absolute, or resolves outside `clips/`.
|
|
218
|
+
"""
|
|
219
|
+
if not relpath or not str(relpath).strip():
|
|
220
|
+
raise CleanupPathError("A clip row has an empty path.")
|
|
221
|
+
|
|
222
|
+
candidate = Path(relpath)
|
|
223
|
+
if candidate.is_absolute():
|
|
224
|
+
raise CleanupPathError(
|
|
225
|
+
f"Clip path {relpath!r} is absolute. Paths are stored relative to the data "
|
|
226
|
+
f"directory so a restore to a different mount point still works."
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
root = clips_dir.resolve()
|
|
230
|
+
resolved = (clips_dir.parent / candidate).resolve()
|
|
231
|
+
if resolved != root and root not in resolved.parents:
|
|
232
|
+
raise CleanupPathError(
|
|
233
|
+
f"Clip path {relpath!r} resolves to {resolved}, which is outside {root}. "
|
|
234
|
+
f"Nothing was deleted."
|
|
235
|
+
)
|
|
236
|
+
return resolved
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True, slots=True)
|
|
240
|
+
class RetentionRun:
|
|
241
|
+
"""What one J1 pass did, or would have done under `DRY_RUN`."""
|
|
242
|
+
|
|
243
|
+
cameras: int = 0
|
|
244
|
+
considered: int = 0
|
|
245
|
+
deleted: int = 0
|
|
246
|
+
missing: int = 0
|
|
247
|
+
refused: int = 0
|
|
248
|
+
would_delete: int = 0
|
|
249
|
+
bytes_freed: int = 0
|
|
250
|
+
dry_run: bool = False
|
|
251
|
+
started_at: Instant = 0.0
|
|
252
|
+
finished_at: Instant = 0.0
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def duration_s(self) -> float:
|
|
256
|
+
return max(0.0, self.finished_at - self.started_at)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class LocalRetention:
|
|
260
|
+
"""J1: delete local clip files past `local_days`, and only when it is safe to.
|
|
261
|
+
|
|
262
|
+
Instantiated once and called from one place, because cleanup is single-instance by
|
|
263
|
+
construction (LLD 05 §8).
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
def __init__(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
settings: CleanupSettings,
|
|
270
|
+
logger: Any | None = None,
|
|
271
|
+
clock: Any = timeutil.now,
|
|
272
|
+
) -> None:
|
|
273
|
+
self._settings = settings
|
|
274
|
+
self._clock = clock
|
|
275
|
+
self._logger = logger
|
|
276
|
+
|
|
277
|
+
# -- retention windows ---------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def cutoff_for(
|
|
280
|
+
self, camera: str, *, config: CamerasConfig, retired: bool, now: Instant
|
|
281
|
+
) -> Instant:
|
|
282
|
+
"""The oldest `start_time` that survives, for one camera.
|
|
283
|
+
|
|
284
|
+
A camera that is still in `cameras.yml` uses its configured `local_days`. A **retired**
|
|
285
|
+
camera — removed from the config — has no window of its own, so it falls back to the
|
|
286
|
+
default plus `retire_grace_days`. That is what stops a camera's footage from becoming
|
|
287
|
+
immortal by virtue of the camera no longer being configured ([INV-13]).
|
|
288
|
+
|
|
289
|
+
Note that this depends only on the camera's *configuration*, never on whether it is
|
|
290
|
+
currently `enabled`: a camera switched off for the season keeps its own retention.
|
|
291
|
+
"""
|
|
292
|
+
configured = config.cameras.get(camera)
|
|
293
|
+
if configured is not None:
|
|
294
|
+
days = float(configured.retention.local_days)
|
|
295
|
+
else:
|
|
296
|
+
grace = self._settings.retire_grace_days if retired else 0
|
|
297
|
+
days = float(DEFAULT_LOCAL_DAYS + grace)
|
|
298
|
+
return now - days * SECONDS_PER_DAY
|
|
299
|
+
|
|
300
|
+
# -- the run -------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
def run_once(self, conn: Any, config: CamerasConfig) -> RetentionRun:
|
|
303
|
+
"""One J1 pass over every known camera, in batches, committing per batch."""
|
|
304
|
+
started = self._clock()
|
|
305
|
+
totals = _Counters()
|
|
306
|
+
cameras = db.known_cameras(conn)
|
|
307
|
+
|
|
308
|
+
for row in cameras:
|
|
309
|
+
camera = str(row["name"])
|
|
310
|
+
retired = row["retired_at"] is not None
|
|
311
|
+
cutoff = self.cutoff_for(camera, config=config, retired=retired, now=started)
|
|
312
|
+
self._expire_camera(conn, camera=camera, cutoff=cutoff, totals=totals)
|
|
313
|
+
|
|
314
|
+
finished = self._clock()
|
|
315
|
+
run = RetentionRun(
|
|
316
|
+
cameras=len(cameras),
|
|
317
|
+
considered=totals.considered,
|
|
318
|
+
deleted=totals.deleted,
|
|
319
|
+
missing=totals.missing,
|
|
320
|
+
refused=totals.refused,
|
|
321
|
+
would_delete=totals.dry_run_deleted,
|
|
322
|
+
bytes_freed=totals.bytes_freed,
|
|
323
|
+
dry_run=self._settings.dry_run,
|
|
324
|
+
started_at=started,
|
|
325
|
+
finished_at=finished,
|
|
326
|
+
)
|
|
327
|
+
# No summary line here: `CleanupService` logs one per pass whether or not anything
|
|
328
|
+
# happened, which is a heartbeat as well as a report. Logging here too would double
|
|
329
|
+
# every line on the runs that matter.
|
|
330
|
+
return run
|
|
331
|
+
|
|
332
|
+
def _expire_camera(self, conn: Any, *, camera: str, cutoff: Instant, totals: _Counters) -> None:
|
|
333
|
+
cursor: tuple[float, int] | None = None
|
|
334
|
+
while True:
|
|
335
|
+
batch = db.local_expiry_batch(
|
|
336
|
+
conn,
|
|
337
|
+
camera=camera,
|
|
338
|
+
cutoff=cutoff,
|
|
339
|
+
limit=self._settings.batch_size,
|
|
340
|
+
after=cursor,
|
|
341
|
+
)
|
|
342
|
+
if not batch:
|
|
343
|
+
return
|
|
344
|
+
totals.considered += len(batch)
|
|
345
|
+
# One transaction per batch, not per clip and not for the whole run: bounded
|
|
346
|
+
# rollback, bounded lock, observable progress. The asymmetry is deliberate — it is
|
|
347
|
+
# always safer for the database to under-report a deletion (the file is gone, the
|
|
348
|
+
# row still says it exists) than to over-report one. J3 finds the former.
|
|
349
|
+
with db.write_txn(conn):
|
|
350
|
+
for clip in batch:
|
|
351
|
+
self._expire_one(conn, clip, totals=totals)
|
|
352
|
+
# Advance the cursor past this page. Deliberately **not** "the set shrank": a
|
|
353
|
+
# refused clip, or every clip at all under DRY_RUN, keeps its row eligible, so a
|
|
354
|
+
# shrink-based loop would return this page forever. See `db.local_expiry_batch`.
|
|
355
|
+
last = batch[-1]
|
|
356
|
+
cursor = (float(last["start_time"]), int(last["id"]))
|
|
357
|
+
|
|
358
|
+
def _expire_one(self, conn: Any, clip: Any, *, totals: _Counters) -> None:
|
|
359
|
+
clip_id = int(clip["id"])
|
|
360
|
+
now = self._clock()
|
|
361
|
+
|
|
362
|
+
# Guard 2: re-read and re-check inside the write transaction. The page query ran
|
|
363
|
+
# outside it, so an uploader could have moved the clip since.
|
|
364
|
+
row = conn.execute(
|
|
365
|
+
"SELECT id, upload_state, cloud_key, uploaded_at, path, sidecar_path, size_bytes "
|
|
366
|
+
"FROM clips WHERE id = ?",
|
|
367
|
+
(clip_id,),
|
|
368
|
+
).fetchone()
|
|
369
|
+
if row is None:
|
|
370
|
+
return
|
|
371
|
+
state = str(row["upload_state"])
|
|
372
|
+
if state not in db.LOCAL_EXPIRY_STATES:
|
|
373
|
+
self._log(
|
|
374
|
+
"CLEANUP_CLIP_NO_LONGER_DELETABLE",
|
|
375
|
+
clip_id=clip_id,
|
|
376
|
+
state=state,
|
|
377
|
+
)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# Guard 3: `UPLOADED` means "an object exists at the deterministic key". Without both
|
|
381
|
+
# of these the row is claiming a backup we cannot point at, so the file stays.
|
|
382
|
+
if state == "UPLOADED" and (not row["cloud_key"] or row["uploaded_at"] is None):
|
|
383
|
+
totals.refused += 1
|
|
384
|
+
self._log(
|
|
385
|
+
"CLEANUP_REFUSED_UNPROVABLE_BACKUP",
|
|
386
|
+
clip_id=clip_id,
|
|
387
|
+
state=state,
|
|
388
|
+
reason="UPLOADED without a cloud_key and uploaded_at",
|
|
389
|
+
)
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
target = resolve_within_clips_dir(self._settings.clips_dir, str(row["path"]))
|
|
394
|
+
except CleanupPathError as exc:
|
|
395
|
+
# Guard 4. A poisoned path is a serious finding, not a crash: refuse this clip,
|
|
396
|
+
# report it, and keep the rest of the run moving.
|
|
397
|
+
totals.refused += 1
|
|
398
|
+
self._log(
|
|
399
|
+
"CLEANUP_REFUSED_PATH_ESCAPE",
|
|
400
|
+
clip_id=clip_id,
|
|
401
|
+
path=str(row["path"]),
|
|
402
|
+
reason=str(exc),
|
|
403
|
+
)
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
if not target.exists():
|
|
407
|
+
# Already gone — a crash between unlink and commit, or a manual removal. Record it
|
|
408
|
+
# and move on; this is not an error, it is the desired state being reached.
|
|
409
|
+
totals.missing += 1
|
|
410
|
+
db.mark_local_deleted(conn, clip_id=clip_id, now=now)
|
|
411
|
+
db.record_finding(
|
|
412
|
+
conn,
|
|
413
|
+
kind="missing_local_file",
|
|
414
|
+
subject=str(row["path"]),
|
|
415
|
+
detail={"clip_id": clip_id, "camera": str(clip["camera"])},
|
|
416
|
+
now=now,
|
|
417
|
+
)
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
size = int(row["size_bytes"] or 0)
|
|
421
|
+
|
|
422
|
+
if self._settings.dry_run:
|
|
423
|
+
# Guard 5. Log the intention with everything an operator needs to judge it, and
|
|
424
|
+
# change nothing — not even the row.
|
|
425
|
+
totals.dry_run_deleted += 1
|
|
426
|
+
self._log(
|
|
427
|
+
"CLEANUP_WOULD_DELETE",
|
|
428
|
+
clip_id=clip_id,
|
|
429
|
+
path=str(row["path"]),
|
|
430
|
+
state=state,
|
|
431
|
+
count=size,
|
|
432
|
+
)
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
# Order matters (§3.2): clip, then sidecar, then the row. A crash after the clip
|
|
436
|
+
# leaves an orphan sidecar that J4 reaps; a crash after the sidecar leaves a row
|
|
437
|
+
# pointing at nothing, which J3 reports. The reverse order would leave a clip with no
|
|
438
|
+
# metadata, and the sidecar is what makes a future recovery possible.
|
|
439
|
+
_unlink(target)
|
|
440
|
+
sidecar = self._sidecar_path(row, target=target)
|
|
441
|
+
if sidecar.exists():
|
|
442
|
+
_unlink(sidecar)
|
|
443
|
+
|
|
444
|
+
db.mark_local_deleted(conn, clip_id=clip_id, now=now)
|
|
445
|
+
totals.deleted += 1
|
|
446
|
+
totals.bytes_freed += size
|
|
447
|
+
self._log(
|
|
448
|
+
"CLIP_LOCAL_EXPIRED",
|
|
449
|
+
clip_id=clip_id,
|
|
450
|
+
camera=str(clip["camera"]),
|
|
451
|
+
path=str(row["path"]),
|
|
452
|
+
state=state,
|
|
453
|
+
count=size,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def _sidecar_path(self, row: Any, *, target: Path) -> Path:
|
|
457
|
+
"""The sidecar to remove alongside the clip.
|
|
458
|
+
|
|
459
|
+
The recorded `sidecar_path` is preferred; otherwise it is derived with the one path
|
|
460
|
+
function ([INV-5](../../../docs/architecture/invariants.md)) rather than by string
|
|
461
|
+
surgery, so the two can never disagree about where a sidecar lives. Deriving it from
|
|
462
|
+
the already-resolved target means it cannot escape `clips/` either.
|
|
463
|
+
"""
|
|
464
|
+
recorded = row["sidecar_path"]
|
|
465
|
+
if recorded:
|
|
466
|
+
try:
|
|
467
|
+
return resolve_within_clips_dir(self._settings.clips_dir, str(recorded))
|
|
468
|
+
except CleanupPathError:
|
|
469
|
+
# A poisoned sidecar path is not worth failing the clip over: the clip is the
|
|
470
|
+
# only irreplaceable artefact, and an orphan sidecar is reaped by J4.
|
|
471
|
+
pass
|
|
472
|
+
sibling = paths.sidecar_relpath(PurePosixPath(target.name))
|
|
473
|
+
return target.with_name(sibling.name)
|
|
474
|
+
|
|
475
|
+
def _log(self, message: str, **extra: Any) -> None:
|
|
476
|
+
if self._logger is None:
|
|
477
|
+
return
|
|
478
|
+
self._logger.info(message, extra=extra)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class _Counters:
|
|
482
|
+
"""Mutable tallies for one run. A dataclass would only add ceremony."""
|
|
483
|
+
|
|
484
|
+
__slots__ = (
|
|
485
|
+
"bytes_freed",
|
|
486
|
+
"considered",
|
|
487
|
+
"deleted",
|
|
488
|
+
"dry_run_deleted",
|
|
489
|
+
"missing",
|
|
490
|
+
"refused",
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
def __init__(self) -> None:
|
|
494
|
+
self.considered = 0
|
|
495
|
+
self.deleted = 0
|
|
496
|
+
self.missing = 0
|
|
497
|
+
self.refused = 0
|
|
498
|
+
self.bytes_freed = 0
|
|
499
|
+
self.dry_run_deleted = 0
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _unlink(path: Path) -> None:
|
|
503
|
+
"""Remove a file, tolerating a race with another deleter.
|
|
504
|
+
|
|
505
|
+
Two cleanups should never run at once (LLD 05 §8), and J1 already checked `exists()` —
|
|
506
|
+
but a missing file at this point still means the desired state holds, so it is not an
|
|
507
|
+
error worth failing a batch over.
|
|
508
|
+
"""
|
|
509
|
+
try:
|
|
510
|
+
path.unlink()
|
|
511
|
+
except FileNotFoundError:
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ---------------------------------------------------------------------------------------
|
|
516
|
+
# Health (INV-6, lld/05 §9)
|
|
517
|
+
# ---------------------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class CleanupHealth:
|
|
521
|
+
"""The component's answer to "are you actually working?".
|
|
522
|
+
|
|
523
|
+
[INV-6](../../../docs/architecture/invariants.md) requires every component to expose a
|
|
524
|
+
health state, and this is the surface that makes J1's trade honest: the clips being
|
|
525
|
+
*held* are reported with their count, their size and their age, so "retention is running"
|
|
526
|
+
can never quietly mean "retention is running and the disk is filling" (LLD 05 §7).
|
|
527
|
+
|
|
528
|
+
Every non-healthy check names a code from `health.CODES`. A check that would need a new
|
|
529
|
+
code is a check worth discussing rather than inventing a string for.
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
def __init__(
|
|
533
|
+
self,
|
|
534
|
+
*,
|
|
535
|
+
settings: CleanupSettings,
|
|
536
|
+
config: CamerasConfig,
|
|
537
|
+
started_at: Instant | None = None,
|
|
538
|
+
clock: Any = timeutil.now,
|
|
539
|
+
) -> None:
|
|
540
|
+
self._settings = settings
|
|
541
|
+
self._config = config
|
|
542
|
+
self._clock = clock
|
|
543
|
+
self._started_at = self._clock() if started_at is None else started_at
|
|
544
|
+
# Built here rather than injected, so the retention windows this reports on cannot
|
|
545
|
+
# come from a different set of settings than the ones it was configured with. Two
|
|
546
|
+
# objects that *should* agree but are passed separately eventually do not.
|
|
547
|
+
self._retention = LocalRetention(settings=settings, clock=clock)
|
|
548
|
+
|
|
549
|
+
# -- the report ---------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
def report(self) -> health.HealthReport:
|
|
552
|
+
checks = (
|
|
553
|
+
self._state_db_check(),
|
|
554
|
+
self._clips_dir_check(),
|
|
555
|
+
self._last_run_check(),
|
|
556
|
+
self._overdue_check(),
|
|
557
|
+
self._disk_check(),
|
|
558
|
+
self._storage_check(),
|
|
559
|
+
)
|
|
560
|
+
return health.HealthReport(
|
|
561
|
+
component=COMPONENT,
|
|
562
|
+
checks=checks,
|
|
563
|
+
summary=self._summary(checks),
|
|
564
|
+
since=self._started_at,
|
|
565
|
+
uptime_s=max(0.0, self._clock() - self._started_at),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
def _readonly(self) -> Any:
|
|
569
|
+
return db.open_db(self._settings.db_path, readonly=True)
|
|
570
|
+
|
|
571
|
+
def _state_db_check(self) -> health.Check:
|
|
572
|
+
try:
|
|
573
|
+
conn = self._readonly()
|
|
574
|
+
try:
|
|
575
|
+
conn.execute("SELECT COUNT(*) FROM clips").fetchone()
|
|
576
|
+
finally:
|
|
577
|
+
conn.close()
|
|
578
|
+
except (sqlite3.Error, db.DbError, OSError) as exc:
|
|
579
|
+
return health.Check(
|
|
580
|
+
name="state_db",
|
|
581
|
+
state=health.UNHEALTHY,
|
|
582
|
+
code="DB_UNAVAILABLE",
|
|
583
|
+
detail=f"cannot read the state store: {exc}",
|
|
584
|
+
)
|
|
585
|
+
return health.Check(name="state_db", state=health.HEALTHY)
|
|
586
|
+
|
|
587
|
+
def _clips_dir_check(self) -> health.Check:
|
|
588
|
+
clips = self._settings.clips_dir
|
|
589
|
+
if not clips.is_dir():
|
|
590
|
+
return health.Check(
|
|
591
|
+
name="clips_dir",
|
|
592
|
+
state=health.UNHEALTHY,
|
|
593
|
+
code="DISK_CRITICAL",
|
|
594
|
+
detail=f"{clips} does not exist, so nothing can be expired",
|
|
595
|
+
)
|
|
596
|
+
if not os.access(clips, os.W_OK):
|
|
597
|
+
return health.Check(
|
|
598
|
+
name="clips_dir",
|
|
599
|
+
state=health.UNHEALTHY,
|
|
600
|
+
code="DISK_CRITICAL",
|
|
601
|
+
detail=f"{clips} is not writable, so retention cannot delete anything",
|
|
602
|
+
)
|
|
603
|
+
return health.Check(name="clips_dir", state=health.HEALTHY)
|
|
604
|
+
|
|
605
|
+
def _last_run_check(self) -> health.Check:
|
|
606
|
+
interval = self._settings.run_interval_s
|
|
607
|
+
try:
|
|
608
|
+
conn = self._readonly()
|
|
609
|
+
try:
|
|
610
|
+
row = db.get_watermark(conn, LAST_RUN_NAME)
|
|
611
|
+
finally:
|
|
612
|
+
conn.close()
|
|
613
|
+
except (sqlite3.Error, db.DbError, OSError) as exc:
|
|
614
|
+
return health.Check(
|
|
615
|
+
name="last_run",
|
|
616
|
+
state=health.UNKNOWN,
|
|
617
|
+
code="CLEANUP_STALE",
|
|
618
|
+
detail=f"cannot read when cleanup last ran: {exc}",
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
if row is None or row["last_success_at"] is None:
|
|
622
|
+
# A service that has never completed a run is not healthy, however recently it
|
|
623
|
+
# started: INV-6 forbids "no information" rendering as good news.
|
|
624
|
+
return health.Check(
|
|
625
|
+
name="last_run",
|
|
626
|
+
state=health.DEGRADED,
|
|
627
|
+
code="CLEANUP_STALE",
|
|
628
|
+
detail="cleanup has not completed a run yet",
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
age = max(0.0, self._clock() - float(row["last_success_at"]))
|
|
632
|
+
detail = f"last successful run {_human(age)} ago"
|
|
633
|
+
if age < 2 * interval:
|
|
634
|
+
return health.Check(name="last_run", state=health.HEALTHY, detail=detail)
|
|
635
|
+
if age < 6 * interval:
|
|
636
|
+
return health.Check(
|
|
637
|
+
name="last_run",
|
|
638
|
+
state=health.DEGRADED,
|
|
639
|
+
code="CLEANUP_STALE",
|
|
640
|
+
detail=detail,
|
|
641
|
+
)
|
|
642
|
+
failures = int(row["consecutive_failures"] or 0)
|
|
643
|
+
return health.Check(
|
|
644
|
+
name="last_run",
|
|
645
|
+
state=health.UNHEALTHY,
|
|
646
|
+
code="CLEANUP_STALE",
|
|
647
|
+
detail=f"{detail}; {failures} consecutive failure(s)",
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def overdue(self) -> db.OverdueSummary:
|
|
651
|
+
"""Every camera's held clips, summed. Reported per scrape rather than stored, because
|
|
652
|
+
a count is cheap and an indexed query cannot go stale."""
|
|
653
|
+
count = 0
|
|
654
|
+
held = 0
|
|
655
|
+
oldest_start: Instant | None = None
|
|
656
|
+
oldest_overdue: float | None = None
|
|
657
|
+
try:
|
|
658
|
+
conn = self._readonly()
|
|
659
|
+
try:
|
|
660
|
+
now = self._clock()
|
|
661
|
+
for camera in db.known_cameras(conn):
|
|
662
|
+
name = str(camera["name"])
|
|
663
|
+
cutoff = self._retention.cutoff_for(
|
|
664
|
+
name,
|
|
665
|
+
config=self._config,
|
|
666
|
+
retired=camera["retired_at"] is not None,
|
|
667
|
+
now=now,
|
|
668
|
+
)
|
|
669
|
+
summary = db.overdue_local(conn, camera=name, cutoff=cutoff)
|
|
670
|
+
count += summary.count
|
|
671
|
+
held += summary.bytes_held
|
|
672
|
+
if summary.oldest_start_time is not None and (
|
|
673
|
+
oldest_start is None or summary.oldest_start_time < oldest_start
|
|
674
|
+
):
|
|
675
|
+
oldest_start = summary.oldest_start_time
|
|
676
|
+
if summary.oldest_overdue_s is not None and (
|
|
677
|
+
oldest_overdue is None or summary.oldest_overdue_s > oldest_overdue
|
|
678
|
+
):
|
|
679
|
+
oldest_overdue = summary.oldest_overdue_s
|
|
680
|
+
finally:
|
|
681
|
+
conn.close()
|
|
682
|
+
except (sqlite3.Error, db.DbError, OSError):
|
|
683
|
+
return db.OverdueSummary()
|
|
684
|
+
return db.OverdueSummary(
|
|
685
|
+
count=count,
|
|
686
|
+
bytes_held=held,
|
|
687
|
+
oldest_start_time=oldest_start,
|
|
688
|
+
oldest_overdue_s=oldest_overdue,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
def _overdue_check(self) -> health.Check:
|
|
692
|
+
summary = self.overdue()
|
|
693
|
+
detail = f"{summary.count} clip(s) held, {_human_bytes(summary.bytes_held)}"
|
|
694
|
+
if summary.oldest_overdue_s is not None:
|
|
695
|
+
detail = f"{detail}, oldest overdue by {_human(summary.oldest_overdue_s)}"
|
|
696
|
+
|
|
697
|
+
if summary.count == 0:
|
|
698
|
+
return health.Check(name="clips_overdue_retention", state=health.HEALTHY, detail=detail)
|
|
699
|
+
|
|
700
|
+
too_old = summary.oldest_overdue_s is not None and summary.oldest_overdue_s > (
|
|
701
|
+
self._settings.overdue_age_critical_days * SECONDS_PER_DAY
|
|
702
|
+
)
|
|
703
|
+
return health.Check(
|
|
704
|
+
name="clips_overdue_retention",
|
|
705
|
+
state=(
|
|
706
|
+
health.UNHEALTHY
|
|
707
|
+
if summary.count > self._settings.clips_overdue_critical or too_old
|
|
708
|
+
else health.DEGRADED
|
|
709
|
+
),
|
|
710
|
+
code="CLIPS_OVERDUE_RETENTION",
|
|
711
|
+
detail=detail,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def _disk_check(self) -> health.Check:
|
|
715
|
+
try:
|
|
716
|
+
usage = shutil.disk_usage(self._settings.data_dir)
|
|
717
|
+
except OSError as exc:
|
|
718
|
+
return health.Check(
|
|
719
|
+
name="disk_free",
|
|
720
|
+
state=health.UNKNOWN,
|
|
721
|
+
code="DISK_PRESSURE",
|
|
722
|
+
detail=f"cannot measure free space: {exc}",
|
|
723
|
+
)
|
|
724
|
+
if usage.total <= 0:
|
|
725
|
+
return health.Check(
|
|
726
|
+
name="disk_free",
|
|
727
|
+
state=health.UNKNOWN,
|
|
728
|
+
code="DISK_PRESSURE",
|
|
729
|
+
detail="the filesystem reports no size",
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
free_pct = usage.free / usage.total * 100.0
|
|
733
|
+
detail = f"{free_pct:.1f}% free ({_human_bytes(usage.free)} of {_human_bytes(usage.total)})"
|
|
734
|
+
if free_pct < self._settings.disk_critical_pct:
|
|
735
|
+
return health.Check(
|
|
736
|
+
name="disk_free",
|
|
737
|
+
state=health.UNHEALTHY,
|
|
738
|
+
code="DISK_CRITICAL",
|
|
739
|
+
detail=detail,
|
|
740
|
+
)
|
|
741
|
+
if free_pct < self._settings.disk_warn_pct:
|
|
742
|
+
return health.Check(
|
|
743
|
+
name="disk_free",
|
|
744
|
+
state=health.DEGRADED,
|
|
745
|
+
code="DISK_PRESSURE",
|
|
746
|
+
detail=detail,
|
|
747
|
+
)
|
|
748
|
+
return health.Check(name="disk_free", state=health.HEALTHY, detail=detail)
|
|
749
|
+
|
|
750
|
+
def _storage_check(self) -> health.Check:
|
|
751
|
+
"""Cloud retention (J2) does not exist yet, and says so.
|
|
752
|
+
|
|
753
|
+
`DISABLED` rather than `HEALTHY`: an unimplemented check must never look like a check
|
|
754
|
+
that passed. `DISABLED` is neutral in `derive_state`, so it is honest without dragging
|
|
755
|
+
the component's state down over a capability that has not been claimed.
|
|
756
|
+
"""
|
|
757
|
+
return health.Check(
|
|
758
|
+
name="storage_reachable",
|
|
759
|
+
state=health.DISABLED,
|
|
760
|
+
detail="cloud retention (J2) is not implemented yet",
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
def _summary(self, checks: Sequence[health.Check]) -> str:
|
|
764
|
+
by_name = {check.name: check for check in checks}
|
|
765
|
+
return (
|
|
766
|
+
f"{by_name['clips_overdue_retention'].detail}; "
|
|
767
|
+
f"{by_name['last_run'].detail}; {by_name['disk_free'].detail}"
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
def gauges(self) -> dict[str, float | int]:
|
|
771
|
+
summary = self.overdue()
|
|
772
|
+
free_pct = 0.0
|
|
773
|
+
try:
|
|
774
|
+
usage = shutil.disk_usage(self._settings.data_dir)
|
|
775
|
+
if usage.total > 0:
|
|
776
|
+
free_pct = round(usage.free / usage.total * 100.0, 2)
|
|
777
|
+
except OSError:
|
|
778
|
+
free_pct = 0.0
|
|
779
|
+
return {
|
|
780
|
+
"cctv_clips_overdue_retention": summary.count,
|
|
781
|
+
"cctv_clips_overdue_bytes": summary.bytes_held,
|
|
782
|
+
"cctv_disk_free_pct": free_pct,
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# ---------------------------------------------------------------------------------------
|
|
787
|
+
# The service
|
|
788
|
+
# ---------------------------------------------------------------------------------------
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
class CleanupService:
|
|
792
|
+
"""Runs J1 on its cadence, and records that it did."""
|
|
793
|
+
|
|
794
|
+
def __init__(
|
|
795
|
+
self,
|
|
796
|
+
*,
|
|
797
|
+
settings: CleanupSettings,
|
|
798
|
+
config: CamerasConfig,
|
|
799
|
+
clock: Any = timeutil.now,
|
|
800
|
+
) -> None:
|
|
801
|
+
self._settings = settings
|
|
802
|
+
self._config = config
|
|
803
|
+
self._clock = clock
|
|
804
|
+
# Constructed from the same settings the service runs with. Injecting a ready-made
|
|
805
|
+
# `LocalRetention` would let a caller hand in a dry-run setting and a live runner —
|
|
806
|
+
# a combination that deletes files while claiming to be a rehearsal.
|
|
807
|
+
self._retention = LocalRetention(settings=settings, logger=logger, clock=clock)
|
|
808
|
+
|
|
809
|
+
def run_once(self) -> RetentionRun:
|
|
810
|
+
"""One pass, on its own connection, with the outcome recorded either way."""
|
|
811
|
+
conn = db.open_db(self._settings.db_path)
|
|
812
|
+
try:
|
|
813
|
+
run = self._retention.run_once(conn, self._config)
|
|
814
|
+
except Exception as exc:
|
|
815
|
+
# Recorded, then re-raised: the caller decides whether to keep going, and the
|
|
816
|
+
# staleness check must see that a pass *failed* rather than never happened.
|
|
817
|
+
db.set_watermark(
|
|
818
|
+
conn,
|
|
819
|
+
name=LAST_RUN_NAME,
|
|
820
|
+
watermark=self._clock(),
|
|
821
|
+
succeeded=False,
|
|
822
|
+
note=f"{type(exc).__name__}: {exc}"[:200],
|
|
823
|
+
now=self._clock(),
|
|
824
|
+
)
|
|
825
|
+
raise
|
|
826
|
+
db.set_watermark(
|
|
827
|
+
conn,
|
|
828
|
+
name=LAST_RUN_NAME,
|
|
829
|
+
watermark=run.finished_at,
|
|
830
|
+
succeeded=True,
|
|
831
|
+
note=(
|
|
832
|
+
f"deleted={run.deleted} missing={run.missing} refused={run.refused} "
|
|
833
|
+
f"would_delete={run.would_delete}"
|
|
834
|
+
),
|
|
835
|
+
now=self._clock(),
|
|
836
|
+
)
|
|
837
|
+
return run
|
|
838
|
+
|
|
839
|
+
def run(self, stop: threading.Event) -> None:
|
|
840
|
+
"""Pass immediately, then on the interval, until asked to stop.
|
|
841
|
+
|
|
842
|
+
Immediately, because a service that waited a full interval before its first pass would
|
|
843
|
+
leave a freshly started install holding expired clips for an hour for no reason.
|
|
844
|
+
"""
|
|
845
|
+
while not stop.is_set():
|
|
846
|
+
started = self._clock()
|
|
847
|
+
try:
|
|
848
|
+
run = self.run_once()
|
|
849
|
+
except Exception:
|
|
850
|
+
# The outermost loop of a process meant to run for years: log and keep going.
|
|
851
|
+
# A failed pass leaves the watermark untouched, so nothing is skipped.
|
|
852
|
+
logger.exception("cleanup pass failed; the next one will retry")
|
|
853
|
+
else:
|
|
854
|
+
self._log_run(run)
|
|
855
|
+
self._wait(stop, spent=max(0.0, self._clock() - started))
|
|
856
|
+
|
|
857
|
+
def _wait(self, stop: threading.Event, *, spent: float) -> None:
|
|
858
|
+
"""Sleep out the rest of the interval, waking promptly on shutdown.
|
|
859
|
+
|
|
860
|
+
In small ticks rather than one long wait, so a `SIGTERM` during a quiet hour is acted
|
|
861
|
+
on within a second: a service that will not exit is one Docker eventually `SIGKILL`s.
|
|
862
|
+
"""
|
|
863
|
+
remaining = max(0.0, self._settings.run_interval_s - spent)
|
|
864
|
+
tick = max(0.01, self._settings.scheduler_tick_s)
|
|
865
|
+
while remaining > 0 and not stop.is_set():
|
|
866
|
+
nap = min(tick, remaining)
|
|
867
|
+
stop.wait(nap)
|
|
868
|
+
remaining -= nap
|
|
869
|
+
|
|
870
|
+
def _log_run(self, run: RetentionRun) -> None:
|
|
871
|
+
logger.info(
|
|
872
|
+
"cleanup pass",
|
|
873
|
+
extra={
|
|
874
|
+
"count": run.deleted + run.missing,
|
|
875
|
+
"duration_s": round(run.duration_s, 3),
|
|
876
|
+
"state": (
|
|
877
|
+
f"cameras={run.cameras} considered={run.considered} "
|
|
878
|
+
f"deleted={run.deleted} missing={run.missing} "
|
|
879
|
+
f"refused={run.refused} would_delete={run.would_delete} "
|
|
880
|
+
f"freed={run.bytes_freed}"
|
|
881
|
+
),
|
|
882
|
+
},
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _human(seconds: float) -> str:
|
|
887
|
+
if seconds < 90:
|
|
888
|
+
return f"{seconds:.0f}s"
|
|
889
|
+
if seconds < 5400:
|
|
890
|
+
return f"{seconds / 60:.0f}m"
|
|
891
|
+
if seconds < 172800:
|
|
892
|
+
return f"{seconds / 3600:.1f}h"
|
|
893
|
+
return f"{seconds / SECONDS_PER_DAY:.1f}d"
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _human_bytes(count: float) -> str:
|
|
897
|
+
value = float(count)
|
|
898
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
899
|
+
if value < 1024:
|
|
900
|
+
return f"{value:.1f} {unit}"
|
|
901
|
+
value /= 1024
|
|
902
|
+
return f"{value:.1f} TB"
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _install_signal_handlers(stop: threading.Event) -> None:
|
|
906
|
+
"""Turn SIGTERM/SIGINT into a cooperative shutdown.
|
|
907
|
+
|
|
908
|
+
Deliberately duplicated from the uploader rather than extracted: a shared module for
|
|
909
|
+
fifteen lines would be indirection without insight, and the two services want different
|
|
910
|
+
things from their loops. A third service needing it is the moment to extract it.
|
|
911
|
+
"""
|
|
912
|
+
|
|
913
|
+
def request_stop(signum: int, _frame: object) -> None:
|
|
914
|
+
if not stop.is_set():
|
|
915
|
+
logger.info("shutdown requested", extra={"reason": signal.Signals(signum).name})
|
|
916
|
+
stop.set()
|
|
917
|
+
|
|
918
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
919
|
+
signal.signal(signum, request_stop)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
923
|
+
"""LLD 00 §3's service skeleton: configure, validate, serve health, then work."""
|
|
924
|
+
del argv # every input is an environment variable; see CleanupSettings
|
|
925
|
+
|
|
926
|
+
try:
|
|
927
|
+
settings = CleanupSettings.from_environ()
|
|
928
|
+
except ConfigError as exc:
|
|
929
|
+
print(f"cctv-cleanup: {exc}", file=sys.stderr)
|
|
930
|
+
return EXIT_CONFIG
|
|
931
|
+
|
|
932
|
+
try:
|
|
933
|
+
configure_logging(settings.log_level)
|
|
934
|
+
except ValueError as exc:
|
|
935
|
+
print(f"cctv-cleanup: {exc}", file=sys.stderr)
|
|
936
|
+
return EXIT_CONFIG
|
|
937
|
+
|
|
938
|
+
problem = settings.configuration_problem()
|
|
939
|
+
if problem is not None:
|
|
940
|
+
logger.error("configuration is unusable", extra={"reason": problem})
|
|
941
|
+
return EXIT_CONFIG
|
|
942
|
+
|
|
943
|
+
if settings.dry_run:
|
|
944
|
+
logger.warning(
|
|
945
|
+
"DRY_RUN is set: every pass reports what it would delete and deletes nothing"
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
logger.info(
|
|
949
|
+
"starting",
|
|
950
|
+
extra={
|
|
951
|
+
"host": socket.gethostname(),
|
|
952
|
+
"state": "dry-run" if settings.dry_run else "live",
|
|
953
|
+
},
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
try:
|
|
957
|
+
config = load_cameras_config(settings.cameras_path)
|
|
958
|
+
except (ConfigError, OSError) as exc:
|
|
959
|
+
logger.error(
|
|
960
|
+
"configuration is unusable",
|
|
961
|
+
extra={"path": str(settings.cameras_path), "reason": str(exc)},
|
|
962
|
+
)
|
|
963
|
+
return EXIT_CONFIG
|
|
964
|
+
|
|
965
|
+
for warning in config.warnings:
|
|
966
|
+
logger.warning(warning)
|
|
967
|
+
|
|
968
|
+
conn = None
|
|
969
|
+
try:
|
|
970
|
+
conn = db.open_db(settings.db_path)
|
|
971
|
+
version = db.migrate(conn)
|
|
972
|
+
db.integrity_check(conn)
|
|
973
|
+
except (db.DbError, sqlite3.Error) as exc:
|
|
974
|
+
logger.error(
|
|
975
|
+
"state store is unusable",
|
|
976
|
+
extra={"path": str(settings.db_path), "reason": str(exc)},
|
|
977
|
+
)
|
|
978
|
+
return EXIT_UNAVAILABLE
|
|
979
|
+
finally:
|
|
980
|
+
if conn is not None:
|
|
981
|
+
conn.close()
|
|
982
|
+
|
|
983
|
+
logger.info("state store ready", extra={"state": f"schema-{version}"})
|
|
984
|
+
|
|
985
|
+
reporter = CleanupHealth(settings=settings, config=config)
|
|
986
|
+
|
|
987
|
+
stop = threading.Event()
|
|
988
|
+
_install_signal_handlers(stop)
|
|
989
|
+
|
|
990
|
+
server = None
|
|
991
|
+
if settings.health_port > 0:
|
|
992
|
+
server, _thread = health.start_health_server(
|
|
993
|
+
port=settings.health_port,
|
|
994
|
+
host=settings.health_host,
|
|
995
|
+
snapshot=reporter.report,
|
|
996
|
+
gauges=reporter.gauges,
|
|
997
|
+
)
|
|
998
|
+
logger.info(
|
|
999
|
+
"health endpoint listening",
|
|
1000
|
+
extra={"host": settings.health_host, "count": settings.health_port},
|
|
1001
|
+
)
|
|
1002
|
+
else:
|
|
1003
|
+
logger.warning("no health endpoint: CCTV_CLEANUP_HEALTH_PORT is 0")
|
|
1004
|
+
|
|
1005
|
+
service = CleanupService(settings=settings, config=config)
|
|
1006
|
+
try:
|
|
1007
|
+
service.run(stop)
|
|
1008
|
+
finally:
|
|
1009
|
+
if server is not None:
|
|
1010
|
+
server.shutdown()
|
|
1011
|
+
server.server_close()
|
|
1012
|
+
logger.info("stopped")
|
|
1013
|
+
|
|
1014
|
+
return EXIT_OK
|