@camstack/addon-pipeline 1.2.140 → 1.2.142
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.
- package/dist/recorder/index.js +280 -60
- package/dist/recorder/index.mjs +280 -60
- package/dist/stream-broker/index.js +222 -83
- package/dist/stream-broker/index.mjs +222 -83
- package/package.json +1 -1
package/dist/recorder/index.mjs
CHANGED
|
@@ -847,6 +847,23 @@ function upperBoundByStart(rows, startMs) {
|
|
|
847
847
|
}
|
|
848
848
|
return lo;
|
|
849
849
|
}
|
|
850
|
+
/**
|
|
851
|
+
* Index of the FIRST element whose `startMs` is at-or-after `startMs` (lower
|
|
852
|
+
* bound). On a start-sorted view this is exactly where a linear
|
|
853
|
+
* `find(s => s.startMs === x)` and a linear `find(s => s.startMs >= x)` would
|
|
854
|
+
* have stopped — which is what makes the binary-search replacements of those
|
|
855
|
+
* two scans row-for-row identical, duplicate starts included.
|
|
856
|
+
*/
|
|
857
|
+
function lowerBoundByStart(rows, startMs) {
|
|
858
|
+
let lo = 0;
|
|
859
|
+
let hi = rows.length;
|
|
860
|
+
while (lo < hi) {
|
|
861
|
+
const mid = lo + hi >> 1;
|
|
862
|
+
if (rows[mid].startMs < startMs) lo = mid + 1;
|
|
863
|
+
else hi = mid;
|
|
864
|
+
}
|
|
865
|
+
return lo;
|
|
866
|
+
}
|
|
850
867
|
var RecordingIndex = class {
|
|
851
868
|
byDevice = /* @__PURE__ */ new Map();
|
|
852
869
|
/** Start-sorted views. Built lazily on first read (one copy+sort), then kept
|
|
@@ -865,6 +882,27 @@ var RecordingIndex = class {
|
|
|
865
882
|
* hydrate. Empty = nothing has been looked at, which is NOT the same as
|
|
866
883
|
* "there is nothing" — see {@link hydrationOf}. */
|
|
867
884
|
walked = /* @__PURE__ */ new Map();
|
|
885
|
+
/**
|
|
886
|
+
* Upper bound on the `durMs` of any row the index holds — the ONLY thing that
|
|
887
|
+
* turns "walk the whole sorted view" into "walk backwards a bounded number of
|
|
888
|
+
* rows".
|
|
889
|
+
*
|
|
890
|
+
* A start-sorted view answers "which row STARTS at/before X" by binary
|
|
891
|
+
* search, but every backward question here ("which row still COVERS X",
|
|
892
|
+
* "which footage ENDS at/before X") depends on `startMs + durMs`, which is
|
|
893
|
+
* not sorted. This scalar closes that: a row at `startMs` can never end later
|
|
894
|
+
* than `startMs + maxDurMs`, so once a candidate's ceiling can no longer beat
|
|
895
|
+
* the best answer found, no earlier row can either — and the walk stops.
|
|
896
|
+
*
|
|
897
|
+
* Monotonically non-decreasing on purpose. It is only ever an OVER-estimate
|
|
898
|
+
* (a longer walk, never a wrong answer); shrinking it on eviction would buy
|
|
899
|
+
* nothing and could only make it wrong.
|
|
900
|
+
*/
|
|
901
|
+
maxDurMs = 0;
|
|
902
|
+
/** Widen {@link maxDurMs} for a row entering the index. */
|
|
903
|
+
noteDuration(durMs) {
|
|
904
|
+
if (durMs > this.maxDurMs) this.maxDurMs = durMs;
|
|
905
|
+
}
|
|
868
906
|
mapFor(deviceId) {
|
|
869
907
|
let m = this.byDevice.get(deviceId);
|
|
870
908
|
if (!m) {
|
|
@@ -953,11 +991,13 @@ var RecordingIndex = class {
|
|
|
953
991
|
const prev = held.get(path);
|
|
954
992
|
if (prev !== void 0 && prev.locationId === locationId) {
|
|
955
993
|
this.rowsReused += 1;
|
|
994
|
+
this.noteDuration(prev.durMs);
|
|
956
995
|
return prev;
|
|
957
996
|
}
|
|
958
997
|
const p = parseSegmentPath(path);
|
|
959
998
|
if (!p || p.deviceId !== deviceId) return null;
|
|
960
999
|
this.rowsAllocated += 1;
|
|
1000
|
+
this.noteDuration(p.durMs);
|
|
961
1001
|
return {
|
|
962
1002
|
deviceId: p.deviceId,
|
|
963
1003
|
profile: p.profile,
|
|
@@ -1055,6 +1095,7 @@ var RecordingIndex = class {
|
|
|
1055
1095
|
*/
|
|
1056
1096
|
addSegment(s) {
|
|
1057
1097
|
const m = this.mapFor(s.deviceId);
|
|
1098
|
+
this.noteDuration(s.durMs);
|
|
1058
1099
|
const replacing = m.has(s.path);
|
|
1059
1100
|
m.set(s.path, s);
|
|
1060
1101
|
if (replacing) {
|
|
@@ -1092,6 +1133,55 @@ var RecordingIndex = class {
|
|
|
1092
1133
|
while (end < segs.length && segs[end].startMs < toMs) end += 1;
|
|
1093
1134
|
return lo === 0 && end === segs.length ? segs : segs.slice(lo, end);
|
|
1094
1135
|
}
|
|
1136
|
+
/**
|
|
1137
|
+
* The row whose `startMs` is EXACTLY `startMs`, or null.
|
|
1138
|
+
*
|
|
1139
|
+
* The point lookup every byte read is: `readSegmentBytes` / `readGopBytes` /
|
|
1140
|
+
* `readWindowBytes` all address a segment by its exact start, which is what
|
|
1141
|
+
* the playback manifest and the locate answer handed the client. It used to
|
|
1142
|
+
* be `segments(deviceId, profile).find(...)` — a full COPY of the
|
|
1143
|
+
* device-profile archive plus a linear scan, per scrub tick and per playback
|
|
1144
|
+
* fetch (~10 k rows copied to reach one of them on the live hub).
|
|
1145
|
+
*
|
|
1146
|
+
* Returns the FIRST row with that start, which is what `.find` returned on
|
|
1147
|
+
* the same start-sorted view: a restart can leave two rows sharing a start,
|
|
1148
|
+
* and the byte reader must keep resolving to the same one it always did.
|
|
1149
|
+
*/
|
|
1150
|
+
segmentStartingAt(deviceId, profile, startMs) {
|
|
1151
|
+
const segs = this.sortedSegments(deviceId, profile);
|
|
1152
|
+
const at = segs[lowerBoundByStart(segs, startMs)];
|
|
1153
|
+
return at !== void 0 && at.startMs === startMs ? at : null;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Start-sorted rows whose SPAN `[startMs, startMs+durMs)` overlaps
|
|
1157
|
+
* `[fromMs, toMs)` — i.e. `startMs < toMs && startMs + durMs > fromMs`.
|
|
1158
|
+
*
|
|
1159
|
+
* The overlap-inclusive twin of {@link segmentsStartingIn}, and the reason it
|
|
1160
|
+
* cannot just slice from the `fromMs` lower bound: the segment an operator is
|
|
1161
|
+
* actually watching at `fromMs` STARTED before it. Dropping it truncates the
|
|
1162
|
+
* first segment of every playback window — silently, because the window still
|
|
1163
|
+
* renders, just short. The boundary convention is the caller's, preserved
|
|
1164
|
+
* from the whole-archive filter this replaces: a segment ending EXACTLY at
|
|
1165
|
+
* `fromMs` is excluded (`end > fromMs`), a segment starting exactly at `toMs`
|
|
1166
|
+
* is excluded (`start < toMs`), and a segment straddling `fromMs` is IN.
|
|
1167
|
+
*
|
|
1168
|
+
* The backward reach is {@link maxDurMs}, not "one element": with overlapping
|
|
1169
|
+
* rows (a restart re-recording an hour) more than one predecessor can still
|
|
1170
|
+
* cover `fromMs`, and the ceiling is the only bound that cannot miss one.
|
|
1171
|
+
*/
|
|
1172
|
+
segmentsOverlapping(deviceId, profile, fromMs, toMs) {
|
|
1173
|
+
const segs = this.sortedSegments(deviceId, profile);
|
|
1174
|
+
const end = lowerBoundByStart(segs, toMs);
|
|
1175
|
+
let head = lowerBoundByStart(segs, fromMs);
|
|
1176
|
+
const reach = fromMs - this.maxDurMs;
|
|
1177
|
+
while (head > 0 && segs[head - 1].startMs > reach) head -= 1;
|
|
1178
|
+
const out = [];
|
|
1179
|
+
for (let i = head; i < end; i++) {
|
|
1180
|
+
const s = segs[i];
|
|
1181
|
+
if (s.startMs + s.durMs > fromMs) out.push(s);
|
|
1182
|
+
}
|
|
1183
|
+
return out;
|
|
1184
|
+
}
|
|
1095
1185
|
/** True when `relPath` is currently indexed for `deviceId`. O(1). */
|
|
1096
1186
|
hasSegment(deviceId, relPath) {
|
|
1097
1187
|
return this.byDevice.get(deviceId)?.has(relPath) === true;
|
|
@@ -1210,6 +1300,26 @@ var RecordingIndex = class {
|
|
|
1210
1300
|
}
|
|
1211
1301
|
return [...out.values()];
|
|
1212
1302
|
}
|
|
1303
|
+
/**
|
|
1304
|
+
* WHICH devices hold footage on any of `locationIds` — the census, without
|
|
1305
|
+
* the archive.
|
|
1306
|
+
*
|
|
1307
|
+
* `getStorageUsage` used to answer this with `segmentsOnLocation(id)` per
|
|
1308
|
+
* location: a full multi-device COPY of every row on the disk plus a sort of
|
|
1309
|
+
* it (294 k rows on the live hub), to read one number off each row and throw
|
|
1310
|
+
* the array away. This visits each device only until its first matching row.
|
|
1311
|
+
* Same membership rule as before — a device is listed when at least one of
|
|
1312
|
+
* its rows sits on one of the given locations, so footage stranded on a
|
|
1313
|
+
* location id that no longer resolves stays invisible here exactly as it was.
|
|
1314
|
+
*/
|
|
1315
|
+
deviceIdsOnLocations(locationIds) {
|
|
1316
|
+
const out = /* @__PURE__ */ new Set();
|
|
1317
|
+
for (const [deviceId, m] of this.byDevice) for (const s of m.values()) if (locationIds.has(s.locationId)) {
|
|
1318
|
+
out.add(deviceId);
|
|
1319
|
+
break;
|
|
1320
|
+
}
|
|
1321
|
+
return out;
|
|
1322
|
+
}
|
|
1213
1323
|
/** All segments on a storage location across every device, oldest-first. */
|
|
1214
1324
|
segmentsOnLocation(locationId) {
|
|
1215
1325
|
const out = [];
|
|
@@ -1246,20 +1356,59 @@ var RecordingIndex = class {
|
|
|
1246
1356
|
const segs = this.sortedSegments(deviceId, profile);
|
|
1247
1357
|
if (segs.length === 0) return null;
|
|
1248
1358
|
if (this.segmentAtIn(segs, epochMs) !== null) return epochMs;
|
|
1249
|
-
const next = segs
|
|
1359
|
+
const next = segs[lowerBoundByStart(segs, epochMs)];
|
|
1250
1360
|
return next ? next.startMs : null;
|
|
1251
1361
|
}
|
|
1252
1362
|
/**
|
|
1363
|
+
* The end of the newest footage for a (device, profile), or null when there
|
|
1364
|
+
* is none — "how far can an export reach?", asked once per render.
|
|
1365
|
+
*
|
|
1366
|
+
* NOT simply the last row's end: the view is sorted by START, and an earlier,
|
|
1367
|
+
* longer segment can end later. Walks backwards from the newest row only
|
|
1368
|
+
* while an earlier start could still, at {@link maxDurMs}, beat the best end
|
|
1369
|
+
* seen — which on a uniform-length archive is one comparison.
|
|
1370
|
+
*/
|
|
1371
|
+
latestEnd(deviceId, profile) {
|
|
1372
|
+
const segs = this.sortedSegments(deviceId, profile);
|
|
1373
|
+
let best = null;
|
|
1374
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
1375
|
+
const s = segs[i];
|
|
1376
|
+
if (best !== null && s.startMs + this.maxDurMs <= best) break;
|
|
1377
|
+
const end = s.startMs + s.durMs;
|
|
1378
|
+
if (best === null || end > best) best = end;
|
|
1379
|
+
}
|
|
1380
|
+
return best;
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1253
1383
|
* The exclusive END of the nearest footage ending at-or-before `epochMs`
|
|
1254
1384
|
* (the backward covered edge), or null when no footage ends at or before it.
|
|
1255
1385
|
* Backward counterpart of `nearestCoveredEdge`: consecutive segments leave
|
|
1256
1386
|
* small cracks (`startMs + durMs` < next `startMs` by ~11-17 ms), so a
|
|
1257
1387
|
* backward frame-step probing `startMs − 1` lands in a crack — this edge
|
|
1258
1388
|
* lets the caller hop to the true previous segment.
|
|
1389
|
+
*
|
|
1390
|
+
* ## Why this is a search and not a scan
|
|
1391
|
+
*
|
|
1392
|
+
* Called on EVERY locate that lands in a gap — during a drag across sparse
|
|
1393
|
+
* footage, every tick — and it used to walk the device-profile's whole sorted
|
|
1394
|
+
* view to do it. Two bounds replace the walk, and the second is the one that
|
|
1395
|
+
* has to be right:
|
|
1396
|
+
*
|
|
1397
|
+
* 1. A row starting AFTER `epochMs` ends after it too (`durMs >= 0`), so
|
|
1398
|
+
* nothing at-or-past the upper bound of `epochMs` can qualify.
|
|
1399
|
+
* 2. Below that bound the answer is NOT the predecessor. A row starting
|
|
1400
|
+
* before the cursor can still COVER it (disqualified: its end is past
|
|
1401
|
+
* `epochMs`), while an even earlier row ends cleanly before it and is the
|
|
1402
|
+
* true edge. So the walk continues backwards, and only {@link maxDurMs}
|
|
1403
|
+
* says when to stop: earlier rows start no later than this one, so none of
|
|
1404
|
+
* them can end past `startMs + maxDurMs`.
|
|
1259
1405
|
*/
|
|
1260
1406
|
coveredEdgeBefore(deviceId, profile, epochMs) {
|
|
1407
|
+
const segs = this.sortedSegments(deviceId, profile);
|
|
1261
1408
|
let best = null;
|
|
1262
|
-
for (
|
|
1409
|
+
for (let i = upperBoundByStart(segs, epochMs) - 1; i >= 0; i--) {
|
|
1410
|
+
const s = segs[i];
|
|
1411
|
+
if (best !== null && s.startMs + this.maxDurMs <= best) break;
|
|
1263
1412
|
const end = s.startMs + s.durMs;
|
|
1264
1413
|
if (end <= epochMs && (best === null || end > best)) best = end;
|
|
1265
1414
|
}
|
|
@@ -1901,6 +2050,11 @@ var macrotask$2 = () => new Promise((resolve) => {
|
|
|
1901
2050
|
* segment's start. Returns the LAST segment whose half-open span
|
|
1902
2051
|
* `[startMs, startMs+durMs)` contains the instant (segments can overlap after a
|
|
1903
2052
|
* restart; the newest wins). Null when no segment covers the instant.
|
|
2053
|
+
*
|
|
2054
|
+
* `segments` may be a CANDIDATE WINDOW rather than the device's whole archive —
|
|
2055
|
+
* the caller narrows it with an index search. The membership test below stays
|
|
2056
|
+
* regardless: narrowing may only remove rows that could not have won, never
|
|
2057
|
+
* decide which of the survivors does.
|
|
1904
2058
|
*/
|
|
1905
2059
|
function resolveStillSource(segments, epochMs) {
|
|
1906
2060
|
let match = null;
|
|
@@ -1968,16 +2122,22 @@ var StillFrameService = class {
|
|
|
1968
2122
|
* caller replies 204 → the dialog shows its placeholder).
|
|
1969
2123
|
*/
|
|
1970
2124
|
async getStill(deviceId, epochMs) {
|
|
1971
|
-
const source = resolveStillSource(this.deps.
|
|
1972
|
-
if (source === null)
|
|
2125
|
+
const source = resolveStillSource(this.deps.lowSegmentsAt(deviceId, epochMs), epochMs);
|
|
2126
|
+
if (source === null) {
|
|
2127
|
+
this.deps.logger.debug("still: no low segment covers the instant", {
|
|
2128
|
+
tags: { deviceId },
|
|
2129
|
+
meta: { epochMs }
|
|
2130
|
+
});
|
|
2131
|
+
return null;
|
|
2132
|
+
}
|
|
1973
2133
|
const abs = this.deps.resolveAbsPath(source.row);
|
|
1974
2134
|
const now = (this.deps.now ?? Date.now)();
|
|
1975
2135
|
const immutable = source.row.startMs + source.row.durMs < now;
|
|
1976
2136
|
if (!await this.acquireSlot()) {
|
|
1977
|
-
this.deps.logger.debug("still: decode queue saturated, dropping request", {
|
|
1978
|
-
deviceId,
|
|
1979
|
-
epochMs
|
|
1980
|
-
}
|
|
2137
|
+
this.deps.logger.debug("still: decode queue saturated, dropping request", {
|
|
2138
|
+
tags: { deviceId },
|
|
2139
|
+
meta: { epochMs }
|
|
2140
|
+
});
|
|
1981
2141
|
return null;
|
|
1982
2142
|
}
|
|
1983
2143
|
try {
|
|
@@ -5077,7 +5237,7 @@ async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMo
|
|
|
5077
5237
|
if (deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs);
|
|
5078
5238
|
const hydration = deps.index.hydrationOf(deviceId, fromMs, toMs);
|
|
5079
5239
|
const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
|
|
5080
|
-
const candidates = deps.index.
|
|
5240
|
+
const candidates = deps.index.segmentsOverlapping(deviceId, profile, fromMs, toMs).flatMap((s) => {
|
|
5081
5241
|
const root = rootById.get(s.locationId);
|
|
5082
5242
|
if (root === void 0) return [];
|
|
5083
5243
|
const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
|
|
@@ -5243,7 +5403,7 @@ async function evictVictims(deps, deviceId, victims) {
|
|
|
5243
5403
|
* and GOP byte-range reads so the two paths cannot drift on resolution.
|
|
5244
5404
|
*/
|
|
5245
5405
|
function resolveSegmentFile(deps, deviceId, profile, startMs) {
|
|
5246
|
-
const seg = deps.index.
|
|
5406
|
+
const seg = deps.index.segmentStartingAt(deviceId, profile, startMs);
|
|
5247
5407
|
if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
|
|
5248
5408
|
const loc = deps.locations().find((l) => l.id === seg.locationId);
|
|
5249
5409
|
if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
|
|
@@ -5412,8 +5572,7 @@ function buildRecordingProvider(deps) {
|
|
|
5412
5572
|
getStorageUsage: async () => {
|
|
5413
5573
|
const locations = deps.locations();
|
|
5414
5574
|
const devices = [];
|
|
5415
|
-
const seenDevices =
|
|
5416
|
-
for (const loc of locations) for (const row of deps.index.segmentsOnLocation(loc.id)) seenDevices.add(row.deviceId);
|
|
5575
|
+
const seenDevices = deps.index.deviceIdsOnLocations(new Set(locations.map((l) => l.id)));
|
|
5417
5576
|
let totalUsedBytes = 0;
|
|
5418
5577
|
for (const deviceId of seenDevices) {
|
|
5419
5578
|
const accounting = deps.index.accounting(deviceId);
|
|
@@ -5917,15 +6076,14 @@ async function renderFootage(renderDeps, input, ext, buildArgs) {
|
|
|
5917
6076
|
await promises.rm(outPath, { force: true }).catch(() => {});
|
|
5918
6077
|
}
|
|
5919
6078
|
}
|
|
5920
|
-
/**
|
|
6079
|
+
/**
|
|
6080
|
+
* The end of the newest FINALIZED `low` segment, or null when none exists.
|
|
6081
|
+
*
|
|
6082
|
+
* One scalar off the tail of the live sorted view — it used to COPY the
|
|
6083
|
+
* device's whole `low` archive to fold a maximum out of it, per render.
|
|
6084
|
+
*/
|
|
5921
6085
|
function latestFinalizedEnd(renderDeps, deviceId) {
|
|
5922
|
-
|
|
5923
|
-
let end = null;
|
|
5924
|
-
for (const s of rows) {
|
|
5925
|
-
const candidate = s.startMs + s.durMs;
|
|
5926
|
-
if (end === null || candidate > end) end = candidate;
|
|
5927
|
-
}
|
|
5928
|
-
return end;
|
|
6086
|
+
return renderDeps.deps.index.latestEnd(deviceId, "low");
|
|
5929
6087
|
}
|
|
5930
6088
|
/**
|
|
5931
6089
|
* Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
|
|
@@ -11232,10 +11390,12 @@ var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
|
|
|
11232
11390
|
/**
|
|
11233
11391
|
* The four aggregate columns exist so "how much footage is on this disk" is a
|
|
11234
11392
|
* `SUM`/`MIN`/`MAX` over ~19 k hour rows instead of a walk over ~7.1 M in-RAM
|
|
11235
|
-
* segment rows. They are DERIVED from `paths` — never a second authority —
|
|
11236
|
-
* {@link
|
|
11237
|
-
*
|
|
11238
|
-
*
|
|
11393
|
+
* segment rows. They are DERIVED from `paths` — never a second authority — by
|
|
11394
|
+
* {@link withSegmentPath} and nothing else: {@link hourAggregate} is that step
|
|
11395
|
+
* over every path and {@link appendSegmentPath} is that step over one, so there
|
|
11396
|
+
* is no second derivation to drift. A row loaded from a database that predates
|
|
11397
|
+
* these columns is corrected in the mirror the moment it is read and repaired
|
|
11398
|
+
* on disk by {@link SegmentHourLedger.repairAggregates}.
|
|
11239
11399
|
*/
|
|
11240
11400
|
var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
11241
11401
|
{
|
|
@@ -11312,33 +11472,57 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
|
|
|
11312
11472
|
columns: ["locationId", "hourStartMs"]
|
|
11313
11473
|
})
|
|
11314
11474
|
];
|
|
11475
|
+
/** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
|
|
11476
|
+
var EMPTY_HOUR_AGGREGATE = {
|
|
11477
|
+
bytes: 0,
|
|
11478
|
+
segments: 0,
|
|
11479
|
+
minStartMs: 0,
|
|
11480
|
+
maxStartMs: 0
|
|
11481
|
+
};
|
|
11315
11482
|
/**
|
|
11316
|
-
*
|
|
11483
|
+
* ONE path folded into an aggregate — the whole derivation, expressed as its
|
|
11484
|
+
* single step.
|
|
11485
|
+
*
|
|
11486
|
+
* {@link hourAggregate} is this step over every path, and {@link
|
|
11487
|
+
* SegmentHourLedger.recordSegment} is this step over the one path that just
|
|
11488
|
+
* arrived. That is not two implementations that happen to agree: the full
|
|
11489
|
+
* derivation is *defined* as this function repeated, so an append is the fold
|
|
11490
|
+
* resumed from the state the held row already carries — a `SegmentHourRow`'s
|
|
11491
|
+
* aggregate half IS the fold state over its `paths`.
|
|
11492
|
+
*
|
|
11493
|
+
* Why it had to become a step: `recordSegment` re-derived the whole hour on
|
|
11494
|
+
* every finalize. Σ1..360 ≈ 65 k path parses per camera-profile-hour at 10 s
|
|
11495
|
+
* segments, Σ1..1708 ≈ 1.46 M in the restart-storm hours of 2026-08-29, at
|
|
11496
|
+
* ~3.4 finalizes/s fleet-wide, forever.
|
|
11497
|
+
*
|
|
11498
|
+
* A path the parser refuses contributes nothing — it is not a segment, and
|
|
11499
|
+
* counting it would inflate a disk report. `segments === 0` is the first-path
|
|
11500
|
+
* sentinel for min/max, so an aggregate with no segments carries 0/0 rather
|
|
11501
|
+
* than a sentinel nobody can compare against.
|
|
11502
|
+
*/
|
|
11503
|
+
function withSegmentPath(agg, path) {
|
|
11504
|
+
const p = parseSegmentPath(path);
|
|
11505
|
+
if (p === null) return agg;
|
|
11506
|
+
return {
|
|
11507
|
+
bytes: agg.bytes + p.bytes,
|
|
11508
|
+
segments: agg.segments + 1,
|
|
11509
|
+
minStartMs: agg.segments === 0 || p.startMs < agg.minStartMs ? p.startMs : agg.minStartMs,
|
|
11510
|
+
maxStartMs: agg.segments === 0 || p.startMs > agg.maxStartMs ? p.startMs : agg.maxStartMs
|
|
11511
|
+
};
|
|
11512
|
+
}
|
|
11513
|
+
/**
|
|
11514
|
+
* Bytes / count / oldest / newest for one hour's paths — the sole FULL
|
|
11515
|
+
* derivation, used by rebuilds ({@link recordToRow}, `dropSegments`) and never
|
|
11516
|
+
* by the append path.
|
|
11317
11517
|
*
|
|
11318
11518
|
* Every field of a segment path encodes its own facts (`parseSegmentPath`), so
|
|
11319
11519
|
* this is a derivation, not a measurement: it can never disagree with the paths
|
|
11320
|
-
* it was computed from.
|
|
11321
|
-
* not a segment, and counting it would inflate a disk report.
|
|
11520
|
+
* it was computed from.
|
|
11322
11521
|
*/
|
|
11323
11522
|
function hourAggregate(paths) {
|
|
11324
|
-
let
|
|
11325
|
-
|
|
11326
|
-
|
|
11327
|
-
let maxStartMs = 0;
|
|
11328
|
-
for (const path of paths) {
|
|
11329
|
-
const p = parseSegmentPath(path);
|
|
11330
|
-
if (p === null) continue;
|
|
11331
|
-
bytes += p.bytes;
|
|
11332
|
-
if (segments === 0 || p.startMs < minStartMs) minStartMs = p.startMs;
|
|
11333
|
-
if (segments === 0 || p.startMs > maxStartMs) maxStartMs = p.startMs;
|
|
11334
|
-
segments += 1;
|
|
11335
|
-
}
|
|
11336
|
-
return {
|
|
11337
|
-
bytes,
|
|
11338
|
-
segments,
|
|
11339
|
-
minStartMs,
|
|
11340
|
-
maxStartMs
|
|
11341
|
-
};
|
|
11523
|
+
let agg = EMPTY_HOUR_AGGREGATE;
|
|
11524
|
+
for (const path of paths) agg = withSegmentPath(agg, path);
|
|
11525
|
+
return agg;
|
|
11342
11526
|
}
|
|
11343
11527
|
/** Build the persisted row for an hour from its paths — the ONE constructor. */
|
|
11344
11528
|
function segmentHourRow(base) {
|
|
@@ -11348,6 +11532,33 @@ function segmentHourRow(base) {
|
|
|
11348
11532
|
};
|
|
11349
11533
|
}
|
|
11350
11534
|
/**
|
|
11535
|
+
* The row an hour becomes when ONE more path joins it.
|
|
11536
|
+
*
|
|
11537
|
+
* Identical to `segmentHourRow({ ...held, paths: [...held.paths, path] })` and
|
|
11538
|
+
* strictly cheaper: the aggregate half of `held` is already the fold over
|
|
11539
|
+
* `held.paths`, so this resumes that fold instead of restarting it. The
|
|
11540
|
+
* equality is not an assumption — `segment-hour-append.spec.ts` pins it on
|
|
11541
|
+
* generated hours including out-of-order arrival, duplicate paths, unparseable
|
|
11542
|
+
* paths and storm-sized hours.
|
|
11543
|
+
*
|
|
11544
|
+
* The four fields are named rather than spread on purpose. {@link
|
|
11545
|
+
* withSegmentPath} hands its argument straight back for a path the parser
|
|
11546
|
+
* refuses, and `held` is a whole row — spreading that return would put the
|
|
11547
|
+
* PREVIOUS `paths` array back over the appended one. Which is exactly what it
|
|
11548
|
+
* did until the generated-hours test caught it.
|
|
11549
|
+
*/
|
|
11550
|
+
function appendSegmentPath(held, path) {
|
|
11551
|
+
const { bytes, segments, minStartMs, maxStartMs } = withSegmentPath(held, path);
|
|
11552
|
+
return {
|
|
11553
|
+
...held,
|
|
11554
|
+
paths: [...held.paths, path],
|
|
11555
|
+
bytes,
|
|
11556
|
+
segments,
|
|
11557
|
+
minStartMs,
|
|
11558
|
+
maxStartMs
|
|
11559
|
+
};
|
|
11560
|
+
}
|
|
11561
|
+
/**
|
|
11351
11562
|
* The largest share of a device's held hours a single walk may delete.
|
|
11352
11563
|
*
|
|
11353
11564
|
* On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
|
|
@@ -11680,19 +11891,31 @@ var SegmentHourLedger = class {
|
|
|
11680
11891
|
} });
|
|
11681
11892
|
return repaired;
|
|
11682
11893
|
}
|
|
11683
|
-
/**
|
|
11894
|
+
/**
|
|
11895
|
+
* Write-behind: a just-finalized segment joins its hour row.
|
|
11896
|
+
*
|
|
11897
|
+
* THE hot path — every profile-writer lands here every `segmentSeconds`,
|
|
11898
|
+
* ~3.4 times a second fleet-wide. The append is {@link appendSegmentPath}:
|
|
11899
|
+
* one `parseSegmentPath` and four scalar updates, whatever the hour already
|
|
11900
|
+
* holds. It used to re-derive the whole hour, which made one hour
|
|
11901
|
+
* O(N²) — 65 k parses at 360 segments, 1.46 M in a restart-storm hour.
|
|
11902
|
+
*/
|
|
11684
11903
|
async recordSegment(row) {
|
|
11685
11904
|
const { key, hourStartMs: hour } = hourOf(row);
|
|
11686
11905
|
const held = this.ledger.get(key);
|
|
11687
|
-
if (held
|
|
11688
|
-
|
|
11689
|
-
|
|
11690
|
-
|
|
11691
|
-
|
|
11692
|
-
|
|
11693
|
-
|
|
11694
|
-
|
|
11695
|
-
|
|
11906
|
+
if (held === void 0) {
|
|
11907
|
+
await this.ledger.put(segmentHourRow({
|
|
11908
|
+
key,
|
|
11909
|
+
deviceId: row.deviceId,
|
|
11910
|
+
profile: row.profile,
|
|
11911
|
+
locationId: row.locationId,
|
|
11912
|
+
hourStartMs: hour,
|
|
11913
|
+
paths: [row.path]
|
|
11914
|
+
}));
|
|
11915
|
+
return;
|
|
11916
|
+
}
|
|
11917
|
+
if (held.paths.includes(row.path)) return;
|
|
11918
|
+
await this.ledger.put(appendSegmentPath(held, row.path));
|
|
11696
11919
|
}
|
|
11697
11920
|
/**
|
|
11698
11921
|
* Eviction confirmed these paths are gone from disk. Drop them; forget the
|
|
@@ -11756,10 +11979,7 @@ var SegmentHourLedger = class {
|
|
|
11756
11979
|
hourStartMs: hour,
|
|
11757
11980
|
paths: [seg.path]
|
|
11758
11981
|
}));
|
|
11759
|
-
else fromIndex.set(key,
|
|
11760
|
-
...existing,
|
|
11761
|
-
paths: [...existing.paths, seg.path]
|
|
11762
|
-
}));
|
|
11982
|
+
else fromIndex.set(key, appendSegmentPath(existing, seg.path));
|
|
11763
11983
|
}
|
|
11764
11984
|
const refused = this.refusedPrunes(walked, fromIndex);
|
|
11765
11985
|
for (const row of this.ledger.snapshot()) {
|
|
@@ -12989,7 +13209,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12989
13209
|
}
|
|
12990
13210
|
try {
|
|
12991
13211
|
const stillService = new StillFrameService({
|
|
12992
|
-
|
|
13212
|
+
lowSegmentsAt: (deviceId, epochMs) => this.index.segmentsOverlapping(deviceId, "low", epochMs, epochMs + 1),
|
|
12993
13213
|
resolveAbsPath: (row) => this.resolveSegmentAbsPath(row),
|
|
12994
13214
|
logger: this.ctx.logger.child("Still")
|
|
12995
13215
|
});
|