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 ADDED
@@ -0,0 +1,19 @@
1
+ """The shared library for the CCTV stack.
2
+
3
+ Four services import this one package — clip-worker, uploader, cleanup, and
4
+ status-collector — and its job is to make the invariants *structural* rather than
5
+ aspirational. If there is literally one implementation of a rule, the rule cannot
6
+ drift between components.
7
+
8
+ See `docs/architecture/lld/00-overview.md` §2 for the module map and the invariants
9
+ each module enforces.
10
+
11
+ Nothing in this package reaches for the network at import time. It is tested without
12
+ Frigate, without Docker, and without B2.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __all__ = ["__version__"]
18
+
19
+ __version__ = "0.3.0"
cctv/atomic.py ADDED
@@ -0,0 +1,184 @@
1
+ """Writing files so that a crash never leaves a half-written clip.
2
+
3
+ A clip appears in the canonical tree only by `rename`, which is atomic within a
4
+ filesystem. Everything else — the download, the remux — happens at a temporary path that
5
+ no other component looks at. This is what makes a killed clip-worker leave a `.tmp` file
6
+ to be reaped rather than a truncated `.mp4` that every later check will believe.
7
+
8
+ The ordering matters and is easy to get subtly wrong:
9
+
10
+ ```text
11
+ open tmp → write → flush → fsync(file) → close → rename → fsync(dir)
12
+ ```
13
+
14
+ `fsync` on the file is not enough on its own. The rename itself lives in the directory
15
+ entry, so without the final `fsync` on the directory a power cut can leave the file
16
+ present with its old name — or absent entirely — even though its contents were durable.
17
+
18
+ [INV-4](../../../docs/architecture/invariants.md) puts all state under the host data
19
+ directory, and [INV-1](../../../docs/architecture/invariants.md) means an interrupted
20
+ materialisation is a retry rather than a loss, which only holds if a partial file cannot
21
+ be mistaken for a complete one.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from collections.abc import Iterator
28
+ from contextlib import contextmanager
29
+ from pathlib import Path
30
+ from typing import BinaryIO
31
+
32
+ from .timeutil import Instant
33
+ from .timeutil import now as _now
34
+
35
+ __all__ = [
36
+ "TMP_MARKER",
37
+ "atomic_writer",
38
+ "discard",
39
+ "fsync_directory",
40
+ "is_tmp",
41
+ "promote",
42
+ "reap_tmp",
43
+ "tmp_path_for",
44
+ "write_atomic",
45
+ ]
46
+
47
+ #: The documented temporary suffix. The pid is appended so that two workers fetching the
48
+ #: same clip — which the lease should prevent, but should is not cannot — do not write to
49
+ #: the same scratch file.
50
+ TMP_MARKER = ".tmp"
51
+
52
+
53
+ def tmp_path_for(path: Path) -> Path:
54
+ return path.with_name(f"{path.name}{TMP_MARKER}-{os.getpid()}")
55
+
56
+
57
+ def is_tmp(path: Path) -> bool:
58
+ return TMP_MARKER in path.name
59
+
60
+
61
+ def fsync_directory(directory: Path) -> None:
62
+ """Flush a directory entry, so a rename survives a power cut.
63
+
64
+ Best-effort by design: some filesystems and platforms refuse `O_DIRECTORY`, and
65
+ failing here would turn a working system into a broken one for a durability
66
+ improvement rather than a correctness requirement.
67
+ """
68
+ try:
69
+ fd = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
70
+ except OSError:
71
+ return
72
+ try:
73
+ os.fsync(fd)
74
+ except OSError:
75
+ pass
76
+ finally:
77
+ os.close(fd)
78
+
79
+
80
+ @contextmanager
81
+ def atomic_writer(path: Path, *, mode: int = 0o644) -> Iterator[BinaryIO]:
82
+ """Yield a temporary file that becomes `path` on a clean exit.
83
+
84
+ Streaming rather than buffering in memory, because a long review can be hundreds of
85
+ megabytes and the fetch should never have to be held whole.
86
+
87
+ On any exception the temporary file is removed, so a failed fetch leaves nothing
88
+ behind for the reaper to find.
89
+ """
90
+ path = Path(path)
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ tmp = tmp_path_for(path)
93
+
94
+ try:
95
+ # A `with` block rather than a bare handle. The file must be closed *before* the
96
+ # rename, and a bare handle turns that ordering into something a reader has to
97
+ # verify rather than something the code states.
98
+ with open(tmp, "wb") as handle:
99
+ yield handle
100
+ handle.flush()
101
+ os.fsync(handle.fileno())
102
+ except BaseException:
103
+ tmp.unlink(missing_ok=True)
104
+ raise
105
+
106
+ os.chmod(tmp, mode)
107
+ os.replace(tmp, path)
108
+ fsync_directory(path.parent)
109
+
110
+
111
+ def write_atomic(path: Path, data: bytes, *, mode: int = 0o644) -> None:
112
+ """The whole-buffer form, for sidecars and other small files."""
113
+ with atomic_writer(path, mode=mode) as handle:
114
+ handle.write(data)
115
+
116
+
117
+ def promote(scratch: Path, target: Path, *, mode: int = 0o644) -> None:
118
+ """Publish a finished scratch file as `target`.
119
+
120
+ Exists because a clip may be fetched more than once and the *best* attempt wins, not
121
+ the last one ([INV-14](../../../docs/architecture/invariants.md)). Retrying straight
122
+ onto the canonical path would let a worse second attempt overwrite a better first one,
123
+ which is the opposite of retry-then-keep-best. So each attempt lands on its own scratch
124
+ path and only the winner is promoted — and it still appears in the tree solely via a
125
+ rename, never by being written there.
126
+ """
127
+ scratch, target = Path(scratch), Path(target)
128
+ target.parent.mkdir(parents=True, exist_ok=True)
129
+ os.chmod(scratch, mode)
130
+ os.replace(scratch, target)
131
+ fsync_directory(target.parent)
132
+
133
+
134
+ def discard(path: Path) -> bool:
135
+ """Remove a scratch file. Returns whether it is now gone.
136
+
137
+ Deliberately forgiving: a scratch file that another process already removed is a
138
+ success, not an error.
139
+ """
140
+ try:
141
+ Path(path).unlink()
142
+ except FileNotFoundError:
143
+ return True
144
+ except OSError:
145
+ return False
146
+ return True
147
+
148
+
149
+ def reap_tmp(directory: Path, *, max_age_s: float, now: Instant | None = None) -> list[Path]:
150
+ """Delete stale temporary files, and return what was removed.
151
+
152
+ **Age-gated, and that is the point.** A temporary file that appeared seconds ago may
153
+ belong to a fetch happening right now; deleting on sight would corrupt work in
154
+ progress. `max_age_s` must therefore exceed the longest plausible fetch — which for a
155
+ long review is minutes, not seconds, so the reaper's default is generous rather than
156
+ tidy.
157
+
158
+ Unlink failures are swallowed: on a shared filesystem another worker may have got there
159
+ first, and that is a success, not an error.
160
+ """
161
+ now = _now() if now is None else now
162
+ if max_age_s <= 0:
163
+ raise ValueError("max_age_s must be positive, or the reaper races live fetches.")
164
+
165
+ removed: list[Path] = []
166
+ if not directory.is_dir():
167
+ return removed
168
+
169
+ for candidate in directory.rglob(f"*{TMP_MARKER}-*"):
170
+ if not candidate.is_file():
171
+ continue
172
+ try:
173
+ age = now - candidate.stat().st_mtime
174
+ except OSError:
175
+ continue
176
+ if age < max_age_s:
177
+ continue
178
+ try:
179
+ candidate.unlink()
180
+ except OSError:
181
+ continue
182
+ removed.append(candidate)
183
+
184
+ return removed