@camstack/addon-pipeline 1.2.76 → 1.2.78
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/{addon-utils-CTpQfjSR.js → addon-utils-C-XbUkiG.js} +1 -1
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +2 -2
- package/dist/{dist-CZLjObZZ.js → dist-BdVCXl5n.js} +429 -44
- package/dist/{dist-zksfWnEA.mjs → dist-CsP_DikG.mjs} +429 -44
- package/dist/{event-loop-stall-monitor-CcBQAI28.js → event-loop-stall-monitor-BOu8lGee.js} +1 -1
- package/dist/{event-loop-stall-monitor-BuZA3loB.mjs → event-loop-stall-monitor-C3cvE_Xk.mjs} +1 -1
- package/dist/{lazy-sharp-SWR_D1Um.js → lazy-sharp-1LkmyWqV.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +4 -4
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/recorder/index.js +655 -46
- package/dist/recorder/index.mjs +654 -45
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dd6XOtVn.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DUi-lR4R.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-1QMyGZMB.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dg08SxUW.mjs +26 -0
- package/dist/stream-broker/{hostInit-COc_Q_xX.mjs → hostInit-DEtsjgBO.mjs} +3 -3
- package/dist/stream-broker/index.js +411 -93
- package/dist/stream-broker/index.mjs +411 -93
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DAQ1iZFK.js → worker-protocol-B4fPjmXk.js} +1 -1
- package/dist/{worker-protocol-DyX_HbaJ.mjs → worker-protocol-BRwzX3f_.mjs} +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-RYTOS6B-.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DfsLsn2e.mjs +0 -26
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { At as string, Dt as number, J as recordingExportCapability, Mt as EventCategory, N as deriveRecordingMode, Ot as object, St as array, Z as storageEvictableCapability, _ as OpsLogEntrySchema, b as RECORDING_EXPORT_MAX_READ_BYTES, c as DEFAULT_EVENTS_BAND_BUFFER_SEC, d as EVENT_PAD_MS, ft as hydrateSchema, gt as nodePin, kt as record, p as ExportRecordSchema, q as recordingCapability, st as BaseAddon, tt as errMsg, ut as DeviceType, x as RecordingConfigSchema, yt as selectAssignedProfileSlots } from "../dist-
|
|
1
|
+
import { At as string, Dt as number, J as recordingExportCapability, Mt as EventCategory, N as deriveRecordingMode, Ot as object, St as array, Z as storageEvictableCapability, _ as OpsLogEntrySchema, b as RECORDING_EXPORT_MAX_READ_BYTES, c as DEFAULT_EVENTS_BAND_BUFFER_SEC, d as EVENT_PAD_MS, ft as hydrateSchema, gt as nodePin, kt as record, p as ExportRecordSchema, q as recordingCapability, st as BaseAddon, tt as errMsg, ut as DeviceType, x as RecordingConfigSchema, yt as selectAssignedProfileSlots } from "../dist-CsP_DikG.mjs";
|
|
2
2
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
3
3
|
import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-A2S9D7pu.mjs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -389,7 +389,21 @@ async function readMfraTable(absPath, fileBytes) {
|
|
|
389
389
|
/** How long a table stays served. Directory asks are for recent footage far
|
|
390
390
|
* more often than not, and a bounded window keeps this store O(day), not
|
|
391
391
|
* O(uptime): ~30 bytes a segment ⇒ a day across 20 cameras is a few MB. */
|
|
392
|
-
var
|
|
392
|
+
var MFRA_RETAIN_MS = 26 * 36e5;
|
|
393
|
+
/**
|
|
394
|
+
* Whether a segment is recent enough for a captured table to ever be served.
|
|
395
|
+
*
|
|
396
|
+
* The gate the FINALIZE path must ask BEFORE reading the file. A live segment
|
|
397
|
+
* always passes; a recovered one almost never does, and capturing it is pure
|
|
398
|
+
* loss twice over — the table is unservable, and the read is a real `open` +
|
|
399
|
+
* two `read`s on the libuv threadpool. The D148 boot reconcile fired 122 400 of
|
|
400
|
+
* them, unawaited and unbounded, behind a 4-thread pool that the live
|
|
401
|
+
* SegmentWriters' own finalize I/O has to share: that is the "segment file
|
|
402
|
+
* never landed" (>30 s) on four cameras at once.
|
|
403
|
+
*/
|
|
404
|
+
function isMfraTableServable(startMs, nowMs) {
|
|
405
|
+
return startMs >= nowMs - MFRA_RETAIN_MS;
|
|
406
|
+
}
|
|
393
407
|
/**
|
|
394
408
|
* The RAM store the directory reads. Keys are `(deviceId, profile, startMs)`
|
|
395
409
|
* — exactly how a directory row identifies a segment. Insertion is
|
|
@@ -405,7 +419,16 @@ var MfraTableStore = class {
|
|
|
405
419
|
key(deviceId, profile, startMs) {
|
|
406
420
|
return `${deviceId}:${profile}:${startMs}`;
|
|
407
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Keep one table, unless it is already past the serve window.
|
|
424
|
+
*
|
|
425
|
+
* The out-of-window REJECT is not belt-and-braces: `prune` walks the
|
|
426
|
+
* insertion-ordered prefix and stops at the first live entry, so an expired
|
|
427
|
+
* entry appended AFTER a live one is never reachable again. A bulk recovery
|
|
428
|
+
* appends 122 400 of exactly that shape and the store grows without bound.
|
|
429
|
+
*/
|
|
408
430
|
record(deviceId, profile, startMs, table) {
|
|
431
|
+
if (!isMfraTableServable(startMs, this.now())) return;
|
|
409
432
|
this.tables.set(this.key(deviceId, profile, startMs), {
|
|
410
433
|
startMs,
|
|
411
434
|
table
|
|
@@ -426,7 +449,7 @@ var MfraTableStore = class {
|
|
|
426
449
|
}
|
|
427
450
|
/** Insertion order is time order, so the expired prefix is contiguous. */
|
|
428
451
|
prune() {
|
|
429
|
-
const cutoff = this.now() -
|
|
452
|
+
const cutoff = this.now() - MFRA_RETAIN_MS;
|
|
430
453
|
for (const [key, entry] of this.tables) {
|
|
431
454
|
if (entry.startMs >= cutoff) break;
|
|
432
455
|
this.tables.delete(key);
|
|
@@ -625,6 +648,38 @@ var RecordingIndex = class {
|
|
|
625
648
|
segments(deviceId, profile) {
|
|
626
649
|
return [...this.sortedSegments(deviceId, profile)];
|
|
627
650
|
}
|
|
651
|
+
/** True when `relPath` is currently indexed for `deviceId`. O(1). */
|
|
652
|
+
hasSegment(deviceId, relPath) {
|
|
653
|
+
return this.byDevice.get(deviceId)?.has(relPath) === true;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Indexed-segment count per HOUR-BUCKET directory (`<dev>/<profile>/Y/M/D/H`)
|
|
657
|
+
* for one (device, profile), in ONE pass over the device's rows.
|
|
658
|
+
*
|
|
659
|
+
* Exists because `SegmentStore.evict` has to answer "does this eviction cover
|
|
660
|
+
* every segment the bucket holds?" for every bucket in the round, and it used
|
|
661
|
+
* to answer it with `segments(deviceId, profile)` — a full COPY of the
|
|
662
|
+
* device-profile archive plus a full `startsWith` scan, PER BUCKET. That is
|
|
663
|
+
* O(buckets x archive), and D148 is what made it bite: before it, a retention
|
|
664
|
+
* round touched a handful of buckets at the retention edge; after it, the
|
|
665
|
+
* recovered backlog made ~8 900 sparse buckets across six weeks evictable in
|
|
666
|
+
* ONE round. Measured on that shape (1.63 M live rows + 122 400 victims):
|
|
667
|
+
* 182 596 ms of uninterrupted main-thread CPU, with no log line, which is the
|
|
668
|
+
* silent 100%-CPU phase the operator saw. One pass answers all of them.
|
|
669
|
+
*/
|
|
670
|
+
bucketCounts(deviceId, profile) {
|
|
671
|
+
const out = /* @__PURE__ */ new Map();
|
|
672
|
+
const m = this.byDevice.get(deviceId);
|
|
673
|
+
if (!m) return out;
|
|
674
|
+
for (const s of m.values()) {
|
|
675
|
+
if (s.profile !== profile) continue;
|
|
676
|
+
const cut = s.path.lastIndexOf("/");
|
|
677
|
+
if (cut <= 0) continue;
|
|
678
|
+
const bucketDir = s.path.slice(0, cut);
|
|
679
|
+
out.set(bucketDir, (out.get(bucketDir) ?? 0) + 1);
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
628
683
|
/** Aggregate accounting for a device across ALL its locations (use `accountingForLocation` for one location). */
|
|
629
684
|
accounting(deviceId) {
|
|
630
685
|
const m = this.byDevice.get(deviceId);
|
|
@@ -1257,6 +1312,7 @@ var SegmentStore = class SegmentStore {
|
|
|
1257
1312
|
async evict(location, rows) {
|
|
1258
1313
|
let reclaimed = 0;
|
|
1259
1314
|
const removed = [];
|
|
1315
|
+
const startedMs = this.deps.now?.() ?? Date.now();
|
|
1260
1316
|
const byBucket = /* @__PURE__ */ new Map();
|
|
1261
1317
|
for (const r of rows) {
|
|
1262
1318
|
const cut = r.path.lastIndexOf("/");
|
|
@@ -1265,13 +1321,29 @@ var SegmentStore = class SegmentStore {
|
|
|
1265
1321
|
if (group) group.push(r);
|
|
1266
1322
|
else byBucket.set(bucketDir, [r]);
|
|
1267
1323
|
}
|
|
1324
|
+
const census = /* @__PURE__ */ new Map();
|
|
1325
|
+
const countsFor = (deviceId, profile) => {
|
|
1326
|
+
const key = `${deviceId}:${profile}`;
|
|
1327
|
+
const cached = census.get(key);
|
|
1328
|
+
if (cached) return cached;
|
|
1329
|
+
const counts = this.deps.index.bucketCounts(deviceId, profile);
|
|
1330
|
+
census.set(key, counts);
|
|
1331
|
+
return counts;
|
|
1332
|
+
};
|
|
1333
|
+
const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
|
|
1334
|
+
const yieldBetween = this.deps.yieldBetween ?? macrotask$2;
|
|
1335
|
+
let bucketsRemoved = 0;
|
|
1336
|
+
let handled = 0;
|
|
1268
1337
|
for (const [bucketDir, bucketRows] of byBucket) {
|
|
1269
|
-
if (bucketDir !== "" && this.bucketFullyEvicted(bucketDir, bucketRows)) try {
|
|
1338
|
+
if (bucketDir !== "" && this.bucketFullyEvicted(bucketDir, bucketRows, countsFor)) try {
|
|
1270
1339
|
await this.deps.removeDir(location, bucketDir);
|
|
1340
|
+
bucketsRemoved += 1;
|
|
1271
1341
|
for (const r of bucketRows) {
|
|
1272
1342
|
removed.push(r.path);
|
|
1273
1343
|
reclaimed += r.bytes;
|
|
1274
1344
|
}
|
|
1345
|
+
handled += 1;
|
|
1346
|
+
if (handled % chunkSize === 0) await yieldBetween();
|
|
1275
1347
|
continue;
|
|
1276
1348
|
} catch (err) {
|
|
1277
1349
|
this.deps.logger.warn("bucket removeDir failed — falling back to per-file deletes", {
|
|
@@ -1292,15 +1364,47 @@ var SegmentStore = class SegmentStore {
|
|
|
1292
1364
|
error: err
|
|
1293
1365
|
});
|
|
1294
1366
|
}
|
|
1367
|
+
handled += 1;
|
|
1368
|
+
if (handled % chunkSize === 0) await yieldBetween();
|
|
1295
1369
|
}
|
|
1296
1370
|
if (removed.length > 0) {
|
|
1297
1371
|
this.deps.index.removeSegments(removed);
|
|
1298
|
-
|
|
1372
|
+
this.notifyEvicted(rows);
|
|
1299
1373
|
}
|
|
1300
1374
|
await this.pruneEmptyAncestors(location, [...byBucket.keys()]);
|
|
1375
|
+
this.deps.logger.info("recorder: eviction round complete", { meta: {
|
|
1376
|
+
location,
|
|
1377
|
+
rows: rows.length,
|
|
1378
|
+
buckets: byBucket.size,
|
|
1379
|
+
bucketsRemoved,
|
|
1380
|
+
removed: removed.length,
|
|
1381
|
+
reclaimed,
|
|
1382
|
+
ms: (this.deps.now?.() ?? Date.now()) - startedMs
|
|
1383
|
+
} });
|
|
1301
1384
|
return reclaimed;
|
|
1302
1385
|
}
|
|
1303
1386
|
/**
|
|
1387
|
+
* Tell the calendar cache what went, ONCE PER (device, UTC day).
|
|
1388
|
+
*
|
|
1389
|
+
* `CalendarIndex.invalidate` drops a whole UTC day, so a per-ROW call repeats
|
|
1390
|
+
* the same work for every segment of that day — and its hour-list sweep is
|
|
1391
|
+
* O(cached hours), in one uninterrupted synchronous block with no await in
|
|
1392
|
+
* it. Measured over a 122 400-row round with 5 000 cached hours: 15 285 ms of
|
|
1393
|
+
* frozen event loop, scaling linearly with how long the session has been
|
|
1394
|
+
* scrubbing. Deduped it is ~190 calls and unmeasurable.
|
|
1395
|
+
*/
|
|
1396
|
+
notifyEvicted(rows) {
|
|
1397
|
+
const notify = this.deps.onEvicted;
|
|
1398
|
+
if (!notify) return;
|
|
1399
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1400
|
+
for (const r of rows) {
|
|
1401
|
+
const key = `${r.deviceId}:${Math.floor(r.startMs / SegmentStore.DAY_MS)}`;
|
|
1402
|
+
if (seen.has(key)) continue;
|
|
1403
|
+
seen.add(key);
|
|
1404
|
+
notify(r.deviceId, r.startMs);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1304
1408
|
* Walk up from each emptied hour directory removing ancestors that are now
|
|
1305
1409
|
* empty, so that **a directory's existence means footage**.
|
|
1306
1410
|
*
|
|
@@ -1335,22 +1439,37 @@ var SegmentStore = class SegmentStore {
|
|
|
1335
1439
|
}
|
|
1336
1440
|
/** Hour of one UTC hour-bucket in ms. */
|
|
1337
1441
|
static HOUR_MS = 36e5;
|
|
1442
|
+
static DAY_MS = 864e5;
|
|
1443
|
+
/** Buckets between yields. Small enough that one chunk is milliseconds of
|
|
1444
|
+
* work now the per-bucket archive scan is gone. */
|
|
1445
|
+
static DEFAULT_EVICT_CHUNK = 25;
|
|
1338
1446
|
/**
|
|
1339
1447
|
* True when `victims` cover EVERY indexed segment of `bucketDir` AND the
|
|
1340
1448
|
* bucket's hour has fully elapsed. The current-hour guard matters because
|
|
1341
1449
|
* `onFinalized` relocates new segments INTO the current bucket concurrently —
|
|
1342
1450
|
* an rm racing that rename would orphan a just-indexed segment.
|
|
1451
|
+
*
|
|
1452
|
+
* The cover test is a COUNT against the round's one-pass census plus an O(1)
|
|
1453
|
+
* indexed-ness check per victim, never a scan of the device archive: a
|
|
1454
|
+
* bucketDir is `<dev>/<profile>/Y/M/D/H`, so it names exactly one
|
|
1455
|
+
* (device, profile), and the victims of a bucket are unique paths. Equal
|
|
1456
|
+
* counts with every victim indexed therefore means the same set.
|
|
1343
1457
|
*/
|
|
1344
|
-
bucketFullyEvicted(bucketDir, victims) {
|
|
1458
|
+
bucketFullyEvicted(bucketDir, victims, countsFor) {
|
|
1345
1459
|
const first = victims[0];
|
|
1346
1460
|
if (!first) return false;
|
|
1347
1461
|
const bucketStartMs = Math.floor(first.startMs / SegmentStore.HOUR_MS) * SegmentStore.HOUR_MS;
|
|
1348
1462
|
if ((this.deps.now?.() ?? Date.now()) < bucketStartMs + SegmentStore.HOUR_MS) return false;
|
|
1349
|
-
const
|
|
1350
|
-
|
|
1351
|
-
|
|
1463
|
+
const indexedInBucket = countsFor(first.deviceId, first.profile).get(bucketDir) ?? 0;
|
|
1464
|
+
if (indexedInBucket === 0) return false;
|
|
1465
|
+
const indexedVictims = /* @__PURE__ */ new Set();
|
|
1466
|
+
for (const v of victims) if (this.deps.index.hasSegment(v.deviceId, v.path)) indexedVictims.add(v.path);
|
|
1467
|
+
return indexedInBucket === indexedVictims.size;
|
|
1352
1468
|
}
|
|
1353
1469
|
};
|
|
1470
|
+
var macrotask$2 = () => new Promise((resolve) => {
|
|
1471
|
+
setImmediate(resolve);
|
|
1472
|
+
});
|
|
1354
1473
|
//#endregion
|
|
1355
1474
|
//#region src/recorder/still/still-source.ts
|
|
1356
1475
|
/**
|
|
@@ -1709,7 +1828,7 @@ async function availabilityProfile(root, deviceId) {
|
|
|
1709
1828
|
* NOT profiles (`events/`, plus the `strips/` trees the retired filmstrip tier
|
|
1710
1829
|
* left on disk); a walker that trusts readdir would offer them as playable
|
|
1711
1830
|
* profiles. */
|
|
1712
|
-
var KNOWN_PROFILES = new Set([
|
|
1831
|
+
var KNOWN_PROFILES$1 = new Set([
|
|
1713
1832
|
"high",
|
|
1714
1833
|
"mid",
|
|
1715
1834
|
"low"
|
|
@@ -1717,7 +1836,7 @@ var KNOWN_PROFILES = new Set([
|
|
|
1717
1836
|
/** `<root>/<deviceId>/` PROFILE subdirectories. Empty when absent. */
|
|
1718
1837
|
async function profilesOf(root, deviceId) {
|
|
1719
1838
|
try {
|
|
1720
|
-
return (await promises.readdir(path.join(root, String(deviceId)))).filter((e) => KNOWN_PROFILES.has(e));
|
|
1839
|
+
return (await promises.readdir(path.join(root, String(deviceId)))).filter((e) => KNOWN_PROFILES$1.has(e));
|
|
1721
1840
|
} catch {
|
|
1722
1841
|
return [];
|
|
1723
1842
|
}
|
|
@@ -2011,7 +2130,7 @@ var CalendarIndex = class {
|
|
|
2011
2130
|
const day = utcDayOf(atMs);
|
|
2012
2131
|
this.byDevice.get(deviceId)?.delete(day);
|
|
2013
2132
|
const prefix = `${deviceId}:`;
|
|
2014
|
-
for (const key of
|
|
2133
|
+
for (const key of this.hourSegs.keys()) {
|
|
2015
2134
|
if (!key.startsWith(prefix)) continue;
|
|
2016
2135
|
if (utcDayOf(Number(key.slice(key.lastIndexOf(":") + 1))) === day) this.hourSegs.delete(key);
|
|
2017
2136
|
}
|
|
@@ -4838,9 +4957,12 @@ async function resolveRecordingsLocations(api, logger) {
|
|
|
4838
4957
|
* (no footage yet) and any per-location failure are warned + skipped
|
|
4839
4958
|
* (best-effort), so one bad volume never blocks the rest of the hydrate.
|
|
4840
4959
|
*/
|
|
4841
|
-
async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger) {
|
|
4960
|
+
async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
|
|
4961
|
+
const yieldBetween = options.yieldBetween ?? macrotask$1;
|
|
4962
|
+
const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
|
|
4842
4963
|
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
4843
4964
|
const deviceDir = path.join(root, String(deviceId));
|
|
4965
|
+
const startedMs = Date.now();
|
|
4844
4966
|
let entries;
|
|
4845
4967
|
try {
|
|
4846
4968
|
entries = await promises.readdir(deviceDir, { recursive: true });
|
|
@@ -4854,17 +4976,48 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
|
|
|
4854
4976
|
});
|
|
4855
4977
|
continue;
|
|
4856
4978
|
}
|
|
4979
|
+
const walkedMs = Date.now();
|
|
4980
|
+
const locationForProfile = /* @__PURE__ */ new Map();
|
|
4857
4981
|
const pathsByLocation = /* @__PURE__ */ new Map();
|
|
4858
|
-
|
|
4982
|
+
let handled = 0;
|
|
4983
|
+
for (const entry of entries) {
|
|
4984
|
+
if (!entry.endsWith(".m4s")) continue;
|
|
4859
4985
|
const relative = `${deviceId}/${entry.split(path.sep).join("/")}`;
|
|
4860
|
-
const
|
|
4861
|
-
|
|
4986
|
+
const profile = relative.split("/")[1] ?? "";
|
|
4987
|
+
let locationId = locationForProfile.get(profile);
|
|
4988
|
+
if (locationId === void 0) {
|
|
4989
|
+
locationId = locationForHydratedProfile(aliases, profile).id;
|
|
4990
|
+
locationForProfile.set(profile, locationId);
|
|
4991
|
+
}
|
|
4992
|
+
const paths = pathsByLocation.get(locationId);
|
|
4862
4993
|
if (paths) paths.push(relative);
|
|
4863
|
-
else pathsByLocation.set(
|
|
4994
|
+
else pathsByLocation.set(locationId, [relative]);
|
|
4995
|
+
handled += 1;
|
|
4996
|
+
if (handled % chunkSize === 0) await yieldBetween();
|
|
4997
|
+
}
|
|
4998
|
+
let indexed = 0;
|
|
4999
|
+
for (const [locationId, relPaths] of pathsByLocation) {
|
|
5000
|
+
index.hydrateDevice(deviceId, locationId, relPaths);
|
|
5001
|
+
indexed += relPaths.length;
|
|
4864
5002
|
}
|
|
4865
|
-
|
|
5003
|
+
logger.info("recorder: hydrated a device from one recordings root", {
|
|
5004
|
+
tags: { deviceId },
|
|
5005
|
+
meta: {
|
|
5006
|
+
root,
|
|
5007
|
+
entries: entries.length,
|
|
5008
|
+
indexed,
|
|
5009
|
+
locationIds: [...pathsByLocation.keys()],
|
|
5010
|
+
walkMs: walkedMs - startedMs,
|
|
5011
|
+
ms: Date.now() - startedMs
|
|
5012
|
+
}
|
|
5013
|
+
});
|
|
4866
5014
|
}
|
|
4867
5015
|
}
|
|
5016
|
+
/** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
|
|
5017
|
+
var HYDRATE_CHUNK = 2e4;
|
|
5018
|
+
var macrotask$1 = () => new Promise((resolve) => {
|
|
5019
|
+
setImmediate(resolve);
|
|
5020
|
+
});
|
|
4868
5021
|
/** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
|
|
4869
5022
|
function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
4870
5023
|
const d = new Date(hourStartMs);
|
|
@@ -5664,26 +5817,29 @@ var PENDING_GRACE_MS = 3e4;
|
|
|
5664
5817
|
* segment of every recording would be classified as a previous run's leftover
|
|
5665
5818
|
* and dropped.
|
|
5666
5819
|
*/
|
|
5667
|
-
var PREVIOUS_RUN_SKEW_MS = 1e3;
|
|
5820
|
+
var PREVIOUS_RUN_SKEW_MS$1 = 1e3;
|
|
5668
5821
|
/**
|
|
5669
5822
|
* Decide whether a missing flat file is a late flush or a previous run's entry.
|
|
5670
5823
|
*
|
|
5671
|
-
* ffmpeg
|
|
5672
|
-
* starts at `processed = 0
|
|
5673
|
-
*
|
|
5674
|
-
*
|
|
5675
|
-
* 83% of all retries (median
|
|
5676
|
-
*
|
|
5677
|
-
* that was correctly filed hours
|
|
5824
|
+
* ffmpeg opens `-segment_list` in TRUNCATE mode when it spawns, but not
|
|
5825
|
+
* instantly: a fresh watcher starts at `processed = 0` and, in the window
|
|
5826
|
+
* between spawn and that truncation, reads the PREVIOUS run's playlist body.
|
|
5827
|
+
* Those entries were relocated into the bucket tree long ago; their absence is
|
|
5828
|
+
* the system working. Over 2026-08-10..13 they were 83% of all retries (median
|
|
5829
|
+
* age 1 615 s, max 17.4 h) and produced 68 of the 122 `never landed` warnings —
|
|
5830
|
+
* a warning that reads like data loss for a file that was correctly filed hours
|
|
5831
|
+
* earlier.
|
|
5678
5832
|
*
|
|
5679
|
-
*
|
|
5680
|
-
*
|
|
5681
|
-
*
|
|
5682
|
-
*
|
|
5683
|
-
*
|
|
5833
|
+
* What this is NOT is crash recovery, and an earlier version of this comment
|
|
5834
|
+
* claimed it was. Once ffmpeg truncates, the previous run's un-relocated
|
|
5835
|
+
* segments are named by NOTHING, so no amount of care in this function can
|
|
5836
|
+
* reach them — on 2026-08-14 that had stranded 179 GB across the fleet. They
|
|
5837
|
+
* are recovered from the DIRECTORY instead, by `staging-reconcile.ts`, which
|
|
5838
|
+
* the addon runs once at boot. This function only has to be right about the
|
|
5839
|
+
* entries a playlist does name.
|
|
5684
5840
|
*/
|
|
5685
5841
|
function classifyMissingEntry(input) {
|
|
5686
|
-
return input.segmentStartMs < input.writerStartedMs - PREVIOUS_RUN_SKEW_MS ? "stale-previous-run" : "pending";
|
|
5842
|
+
return input.segmentStartMs < input.writerStartedMs - PREVIOUS_RUN_SKEW_MS$1 ? "stale-previous-run" : "pending";
|
|
5687
5843
|
}
|
|
5688
5844
|
/** Whether a pending entry has outlived its grace. Pure — the caller clocks it. */
|
|
5689
5845
|
function shouldGiveUpOnPending(input) {
|
|
@@ -6945,20 +7101,30 @@ var RetentionSweeper = class {
|
|
|
6945
7101
|
this.deps.logger.warn("recorder: retention sweep could not read device configs", { meta: { error: errMsg(err) } });
|
|
6946
7102
|
return;
|
|
6947
7103
|
}
|
|
7104
|
+
const withPolicy = [...configs].filter(([, config]) => hasRetentionPolicy(config));
|
|
7105
|
+
const devicesSkipped = configs.size - withPolicy.length;
|
|
7106
|
+
this.deps.logger.info("recorder: retention sweep starting", { meta: {
|
|
7107
|
+
devices: withPolicy.map(([deviceId]) => deviceId),
|
|
7108
|
+
skipped: devicesSkipped
|
|
7109
|
+
} });
|
|
6948
7110
|
let devicesSwept = 0;
|
|
6949
|
-
let devicesSkipped = 0;
|
|
6950
7111
|
let bucketsPruned = 0;
|
|
6951
7112
|
let bytesReclaimed = 0;
|
|
6952
|
-
for (const [deviceId
|
|
6953
|
-
|
|
6954
|
-
devicesSkipped += 1;
|
|
6955
|
-
continue;
|
|
6956
|
-
}
|
|
7113
|
+
for (const [deviceId] of withPolicy) {
|
|
7114
|
+
const startedMs = Date.now();
|
|
6957
7115
|
try {
|
|
6958
7116
|
const result = await this.deps.prune(deviceId);
|
|
6959
7117
|
devicesSwept += 1;
|
|
6960
7118
|
bucketsPruned += result.deletedBuckets;
|
|
6961
7119
|
bytesReclaimed += result.reclaimedBytes;
|
|
7120
|
+
this.deps.logger.info("recorder: retention prune complete for device", {
|
|
7121
|
+
tags: { deviceId },
|
|
7122
|
+
meta: {
|
|
7123
|
+
deletedBuckets: result.deletedBuckets,
|
|
7124
|
+
reclaimedBytes: result.reclaimedBytes,
|
|
7125
|
+
ms: Date.now() - startedMs
|
|
7126
|
+
}
|
|
7127
|
+
});
|
|
6962
7128
|
} catch (err) {
|
|
6963
7129
|
this.deps.logger.warn("recorder: retention prune failed for device", {
|
|
6964
7130
|
tags: { deviceId },
|
|
@@ -6978,6 +7144,402 @@ var RetentionSweeper = class {
|
|
|
6978
7144
|
}
|
|
6979
7145
|
};
|
|
6980
7146
|
//#endregion
|
|
7147
|
+
//#region src/recorder/loop-pacer.ts
|
|
7148
|
+
var DEFAULT_BUSY_LAG_MS = 25;
|
|
7149
|
+
var DEFAULT_BACKOFF_STEP_MS = 25;
|
|
7150
|
+
var DEFAULT_MAX_BACKOFF_MS = 250;
|
|
7151
|
+
var LoopPacer = class {
|
|
7152
|
+
deps;
|
|
7153
|
+
consecutive = 0;
|
|
7154
|
+
hops = 0;
|
|
7155
|
+
contendedHops = 0;
|
|
7156
|
+
pausedMs = 0;
|
|
7157
|
+
maxLagMs = 0;
|
|
7158
|
+
constructor(deps) {
|
|
7159
|
+
this.deps = deps;
|
|
7160
|
+
}
|
|
7161
|
+
/**
|
|
7162
|
+
* One paced yield. Always hands the loop back at least once; sleeps longer
|
|
7163
|
+
* while the previous hop shows the loop is contended, and forgets the
|
|
7164
|
+
* back-off as soon as one hop comes back clean.
|
|
7165
|
+
*/
|
|
7166
|
+
async hop() {
|
|
7167
|
+
const busyLagMs = this.deps.busyLagMs ?? DEFAULT_BUSY_LAG_MS;
|
|
7168
|
+
const stepMs = this.deps.backoffStepMs ?? DEFAULT_BACKOFF_STEP_MS;
|
|
7169
|
+
const maxMs = this.deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
7170
|
+
const before = this.deps.now();
|
|
7171
|
+
await this.deps.sleep(0);
|
|
7172
|
+
const lagMs = this.deps.now() - before;
|
|
7173
|
+
this.hops += 1;
|
|
7174
|
+
if (lagMs > this.maxLagMs) this.maxLagMs = lagMs;
|
|
7175
|
+
if (lagMs < busyLagMs) {
|
|
7176
|
+
this.consecutive = 0;
|
|
7177
|
+
return;
|
|
7178
|
+
}
|
|
7179
|
+
this.consecutive += 1;
|
|
7180
|
+
this.contendedHops += 1;
|
|
7181
|
+
const pauseMs = Math.min(stepMs * this.consecutive, maxMs);
|
|
7182
|
+
this.pausedMs += pauseMs;
|
|
7183
|
+
await this.deps.sleep(pauseMs);
|
|
7184
|
+
}
|
|
7185
|
+
stats() {
|
|
7186
|
+
return {
|
|
7187
|
+
hops: this.hops,
|
|
7188
|
+
contendedHops: this.contendedHops,
|
|
7189
|
+
pausedMs: this.pausedMs,
|
|
7190
|
+
maxLagMs: this.maxLagMs
|
|
7191
|
+
};
|
|
7192
|
+
}
|
|
7193
|
+
};
|
|
7194
|
+
/** The production pacer: real clock, real timers. */
|
|
7195
|
+
function nodeLoopPacer() {
|
|
7196
|
+
return new LoopPacer({
|
|
7197
|
+
now: () => Date.now(),
|
|
7198
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
7199
|
+
setTimeout(resolve, ms);
|
|
7200
|
+
})
|
|
7201
|
+
});
|
|
7202
|
+
}
|
|
7203
|
+
//#endregion
|
|
7204
|
+
//#region src/recorder/addon/staging-reconcile.ts
|
|
7205
|
+
/**
|
|
7206
|
+
* Recover `.rec-tmp` segments that a writer restart orphaned.
|
|
7207
|
+
*
|
|
7208
|
+
* WHY THIS EXISTS
|
|
7209
|
+
* ───────────────
|
|
7210
|
+
* The segment watcher's ONLY record of what it still owes is ffmpeg's
|
|
7211
|
+
* `-segment_list live.m3u8`. ffmpeg opens that file in TRUNCATE mode on every
|
|
7212
|
+
* spawn, so a writer respawn destroys it. Every segment written but not yet
|
|
7213
|
+
* relocated at that instant becomes permanently unreachable:
|
|
7214
|
+
*
|
|
7215
|
+
* - no playlist names it, so no watcher will ever finalize it;
|
|
7216
|
+
* - `parseSegmentPath` cannot see it — staging is flat and dot-prefixed, kept
|
|
7217
|
+
* deliberately out of the hydrate walk — so retention never counts it;
|
|
7218
|
+
* - the one `.rec-tmp` sweep that exists (`sweepUnassignedStaging`) touches
|
|
7219
|
+
* only locations with ZERO placement assignments, never the live one.
|
|
7220
|
+
*
|
|
7221
|
+
* So the footage is not deleted and not kept: it is stranded, and it accrues
|
|
7222
|
+
* for as long as the install runs.
|
|
7223
|
+
*
|
|
7224
|
+
* `segment-watcher.ts` carries a comment asserting the opposite — that the
|
|
7225
|
+
* playlist "is NOT cleared between runs", which is what its `stale-previous-run`
|
|
7226
|
+
* classification and its documented "that is the crash recovery" path are built
|
|
7227
|
+
* on. The live filesystem refutes it, and a leftover that describes the right
|
|
7228
|
+
* design reads as verification: on 2026-08-14 `/recordings/.rec-tmp/590/high/`
|
|
7229
|
+
* held a `live.m3u8` of 50 entries, every one named at or after that writer's
|
|
7230
|
+
* 20:28:31 start, beside **5 863 older `.m4s` files referenced by nobody** —
|
|
7231
|
+
* the oldest from 2026-07-04. Fleet total: 179 GB, on an array at 90% full.
|
|
7232
|
+
*
|
|
7233
|
+
* WHAT THIS DOES NOT DO
|
|
7234
|
+
* ─────────────────────
|
|
7235
|
+
* It never deletes. Recovery is the same `SegmentStore.onFinalized` the live
|
|
7236
|
+
* path uses — stat, relocate into the UTC bucket, index — so a recovered
|
|
7237
|
+
* segment is indistinguishable from one that was never orphaned, and a segment
|
|
7238
|
+
* it cannot place stays exactly where it is for the next attempt.
|
|
7239
|
+
*/
|
|
7240
|
+
/**
|
|
7241
|
+
* How far before the writer's own start a segment may be named and still count
|
|
7242
|
+
* as belonging to THIS run.
|
|
7243
|
+
*
|
|
7244
|
+
* Mirrors `segment-watcher.PREVIOUS_RUN_SKEW_MS`, and for the same reason:
|
|
7245
|
+
* ffmpeg names segments with whole-second `%s`, so the first segment of a run
|
|
7246
|
+
* carries the second already in progress when the process spawned. Without the
|
|
7247
|
+
* tolerance a recovery pass would reach for the live writer's opening segment —
|
|
7248
|
+
* the one file in the directory that is still being written.
|
|
7249
|
+
*/
|
|
7250
|
+
var PREVIOUS_RUN_SKEW_MS = 1e3;
|
|
7251
|
+
/**
|
|
7252
|
+
* How much longer than nominal an inter-segment gap may be and still be read as
|
|
7253
|
+
* one segment's duration.
|
|
7254
|
+
*
|
|
7255
|
+
* ffmpeg cuts on keyframes, so a real segment overshoots `-segment_time` by up
|
|
7256
|
+
* to a GOP; a gap of twice nominal is still plausibly one segment. Anything
|
|
7257
|
+
* wider is a RUN BOUNDARY — the writer was down in between — and the honest
|
|
7258
|
+
* duration is the nominal one. Trusting the gap there would paint a solid
|
|
7259
|
+
* timeline bar across hours of footage that does not exist, which is a worse
|
|
7260
|
+
* failure than under-reporting by a few seconds.
|
|
7261
|
+
*/
|
|
7262
|
+
var MAX_GAP_FACTOR = 2;
|
|
7263
|
+
/**
|
|
7264
|
+
* Decide which staged files belong to a previous run, and how long each ran.
|
|
7265
|
+
*
|
|
7266
|
+
* Pure — the caller does the I/O. The gate is the file's own name against the
|
|
7267
|
+
* writer's start: a segment named before this ffmpeg spawned cannot be one this
|
|
7268
|
+
* ffmpeg is writing, whatever the playlist says.
|
|
7269
|
+
*/
|
|
7270
|
+
function planStagingRecovery(input) {
|
|
7271
|
+
const nominalMs = input.segmentSeconds * 1e3;
|
|
7272
|
+
const cutoffMs = input.writerStartedMs - PREVIOUS_RUN_SKEW_MS;
|
|
7273
|
+
const owned = input.playlistNames;
|
|
7274
|
+
const candidates = [];
|
|
7275
|
+
for (const name of input.names) {
|
|
7276
|
+
const startMs = parseEpochStartMs(name);
|
|
7277
|
+
if (startMs === null) continue;
|
|
7278
|
+
if (startMs >= cutoffMs) continue;
|
|
7279
|
+
if (owned?.has(name) === true) continue;
|
|
7280
|
+
candidates.push({
|
|
7281
|
+
name,
|
|
7282
|
+
startMs
|
|
7283
|
+
});
|
|
7284
|
+
}
|
|
7285
|
+
candidates.sort((a, b) => a.startMs - b.startMs);
|
|
7286
|
+
const maxGapMs = nominalMs * MAX_GAP_FACTOR;
|
|
7287
|
+
return candidates.map((c, i) => {
|
|
7288
|
+
const next = candidates[i + 1];
|
|
7289
|
+
const gapMs = next === void 0 ? 0 : next.startMs - c.startMs;
|
|
7290
|
+
const durMs = gapMs > 0 && gapMs <= maxGapMs ? gapMs : nominalMs;
|
|
7291
|
+
return {
|
|
7292
|
+
name: c.name,
|
|
7293
|
+
startMs: c.startMs,
|
|
7294
|
+
durMs
|
|
7295
|
+
};
|
|
7296
|
+
});
|
|
7297
|
+
}
|
|
7298
|
+
/** The real filesystem. The only production value of {@link StagingIo}. */
|
|
7299
|
+
var nodeStagingIo = {
|
|
7300
|
+
listDir: (dir) => promises.readdir(dir),
|
|
7301
|
+
readPlaylist: (dir) => promises.readFile(path.join(dir, "live.m3u8"), "utf8").catch(() => null)
|
|
7302
|
+
};
|
|
7303
|
+
/** The only `<deviceId>/` subdirectories of staging that hold segments. */
|
|
7304
|
+
var KNOWN_PROFILES = new Set([
|
|
7305
|
+
"high",
|
|
7306
|
+
"mid",
|
|
7307
|
+
"low"
|
|
7308
|
+
]);
|
|
7309
|
+
/** Default orphans per chunk. 590/high alone had 5 863 of them. */
|
|
7310
|
+
var DEFAULT_CHUNK_SIZE = 100;
|
|
7311
|
+
/**
|
|
7312
|
+
* Orphans between progress lines.
|
|
7313
|
+
*
|
|
7314
|
+
* A directory of 5 863 files takes minutes over shfs and used to report only
|
|
7315
|
+
* when it finished, so "is it still going, and how far in" had no answer at
|
|
7316
|
+
* all — the exact shape of the blackout this pass was written to end.
|
|
7317
|
+
*/
|
|
7318
|
+
var PROGRESS_EVERY = 1e3;
|
|
7319
|
+
var macrotask = () => new Promise((resolve) => {
|
|
7320
|
+
setTimeout(resolve, 0);
|
|
7321
|
+
});
|
|
7322
|
+
/**
|
|
7323
|
+
* Relocate every previous-run segment left in one staging dir.
|
|
7324
|
+
*
|
|
7325
|
+
* Chunked with a yield between batches: this runs alongside a live recorder and
|
|
7326
|
+
* a backlog is measured in thousands of files over shfs/FUSE — a single
|
|
7327
|
+
* uninterrupted loop is exactly the event-loop stall the recorder has already
|
|
7328
|
+
* paid for once (D128).
|
|
7329
|
+
*
|
|
7330
|
+
* A failure is per-segment and never aborts the pass: the file stays in staging
|
|
7331
|
+
* and the next attach retries it. Every drop is logged with the segment name —
|
|
7332
|
+
* silence reads as "never happened", and this whole class of loss went
|
|
7333
|
+
* unnoticed for six weeks precisely because nothing said anything.
|
|
7334
|
+
*/
|
|
7335
|
+
async function recoverStagingOrphans(dir, input, deps) {
|
|
7336
|
+
let names;
|
|
7337
|
+
try {
|
|
7338
|
+
names = await deps.listDir(dir);
|
|
7339
|
+
} catch (err) {
|
|
7340
|
+
deps.logger.warn("recorder: staging recovery could not list the staging dir", { meta: {
|
|
7341
|
+
dir,
|
|
7342
|
+
error: err instanceof Error ? err.message : String(err)
|
|
7343
|
+
} });
|
|
7344
|
+
return {
|
|
7345
|
+
found: 0,
|
|
7346
|
+
recovered: 0,
|
|
7347
|
+
failed: 0,
|
|
7348
|
+
aborted: false
|
|
7349
|
+
};
|
|
7350
|
+
}
|
|
7351
|
+
let playlistNames;
|
|
7352
|
+
const body = await deps.readPlaylist(dir).catch(() => null);
|
|
7353
|
+
if (body !== null) playlistNames = new Set(parseLivePlaylist(body).map((e) => e.segPath.replace(/^.*\//, "")));
|
|
7354
|
+
const plan = planStagingRecovery({
|
|
7355
|
+
...input,
|
|
7356
|
+
names,
|
|
7357
|
+
playlistNames
|
|
7358
|
+
});
|
|
7359
|
+
if (plan.length === 0) return {
|
|
7360
|
+
found: 0,
|
|
7361
|
+
recovered: 0,
|
|
7362
|
+
failed: 0,
|
|
7363
|
+
aborted: false
|
|
7364
|
+
};
|
|
7365
|
+
deps.logger.info("recorder: staging recovery starting on a directory", { meta: {
|
|
7366
|
+
dir,
|
|
7367
|
+
found: plan.length,
|
|
7368
|
+
staged: names.length,
|
|
7369
|
+
oldestMs: plan[0]?.startMs,
|
|
7370
|
+
newestMs: plan[plan.length - 1]?.startMs
|
|
7371
|
+
} });
|
|
7372
|
+
const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
7373
|
+
const yieldBetween = deps.yieldBetween ?? macrotask;
|
|
7374
|
+
let recovered = 0;
|
|
7375
|
+
let failed = 0;
|
|
7376
|
+
let aborted = false;
|
|
7377
|
+
for (let i = 0; i < plan.length; i++) {
|
|
7378
|
+
const orphan = plan[i];
|
|
7379
|
+
if (deps.shouldStop?.() === true) {
|
|
7380
|
+
aborted = true;
|
|
7381
|
+
deps.logger.warn("recorder: staging recovery stopped short by shutdown", { meta: {
|
|
7382
|
+
dir,
|
|
7383
|
+
recovered,
|
|
7384
|
+
failed,
|
|
7385
|
+
remaining: plan.length - i
|
|
7386
|
+
} });
|
|
7387
|
+
break;
|
|
7388
|
+
}
|
|
7389
|
+
try {
|
|
7390
|
+
await deps.finalize(orphan, `${dir}/${orphan.name}`);
|
|
7391
|
+
recovered += 1;
|
|
7392
|
+
} catch (err) {
|
|
7393
|
+
failed += 1;
|
|
7394
|
+
deps.logger.warn("recorder: staging recovery could not relocate an orphaned segment", { meta: {
|
|
7395
|
+
dir,
|
|
7396
|
+
seg: orphan.name,
|
|
7397
|
+
startMs: orphan.startMs,
|
|
7398
|
+
durMs: orphan.durMs,
|
|
7399
|
+
error: err instanceof Error ? err.message : String(err)
|
|
7400
|
+
} });
|
|
7401
|
+
}
|
|
7402
|
+
if ((i + 1) % PROGRESS_EVERY === 0 && i + 1 < plan.length) deps.logger.info("recorder: staging recovery progress", { meta: {
|
|
7403
|
+
dir,
|
|
7404
|
+
done: i + 1,
|
|
7405
|
+
found: plan.length,
|
|
7406
|
+
recovered,
|
|
7407
|
+
failed
|
|
7408
|
+
} });
|
|
7409
|
+
if ((i + 1) % chunkSize === 0 && i + 1 < plan.length) await yieldBetween();
|
|
7410
|
+
}
|
|
7411
|
+
deps.logger.info("recorder: recovered segments a writer restart had orphaned in staging", { meta: {
|
|
7412
|
+
dir,
|
|
7413
|
+
found: plan.length,
|
|
7414
|
+
recovered,
|
|
7415
|
+
failed,
|
|
7416
|
+
aborted,
|
|
7417
|
+
oldestMs: plan[0]?.startMs,
|
|
7418
|
+
newestMs: plan[plan.length - 1]?.startMs
|
|
7419
|
+
} });
|
|
7420
|
+
return {
|
|
7421
|
+
found: plan.length,
|
|
7422
|
+
recovered,
|
|
7423
|
+
failed,
|
|
7424
|
+
aborted
|
|
7425
|
+
};
|
|
7426
|
+
}
|
|
7427
|
+
/**
|
|
7428
|
+
* Boot reconcile: relocate every previous-run segment stranded in staging, on
|
|
7429
|
+
* every location, for every camera — attached or not.
|
|
7430
|
+
*
|
|
7431
|
+
* WHY BOOT, AND NOT ATTACH
|
|
7432
|
+
* ────────────────────────
|
|
7433
|
+
* Orphans are created by a writer restart, so by construction they all predate
|
|
7434
|
+
* the runner that finds them; boot is when the whole backlog is visible at
|
|
7435
|
+
* once. Attach looked like the natural hook and is the wrong one twice over: it
|
|
7436
|
+
* only ever reaches cameras that are currently recording (device 1438 had a
|
|
7437
|
+
* staging backlog while attached to nothing), and it puts unbounded filesystem
|
|
7438
|
+
* work on the path that must start recording NOW — measured, it kept microtasks
|
|
7439
|
+
* in flight across `RecordingController.stop()` and made the runner's 5 s
|
|
7440
|
+
* SIGTERM-grace test fail under load.
|
|
7441
|
+
*
|
|
7442
|
+
* `bootMs` is the cutoff, and it is what makes this safe to run while writers
|
|
7443
|
+
* are spawning: a live writer's segments are all named after boot, so the plan
|
|
7444
|
+
* cannot select one. Nothing is deleted, and a segment that cannot be placed is
|
|
7445
|
+
* left exactly where it is for the next boot.
|
|
7446
|
+
*/
|
|
7447
|
+
async function recoverAllStagedOrphans(deps) {
|
|
7448
|
+
let dirs = 0;
|
|
7449
|
+
let found = 0;
|
|
7450
|
+
let recovered = 0;
|
|
7451
|
+
let failed = 0;
|
|
7452
|
+
let aborted = false;
|
|
7453
|
+
const startedMs = Date.now();
|
|
7454
|
+
const injectedYield = deps.yieldBetween;
|
|
7455
|
+
const pacer = injectedYield === void 0 ? nodeLoopPacer() : null;
|
|
7456
|
+
const yieldBetween = injectedYield ?? (() => pacer === null ? macrotask() : pacer.hop());
|
|
7457
|
+
const locations = deps.locations();
|
|
7458
|
+
deps.logger.info("recorder: staging boot reconcile starting", { meta: {
|
|
7459
|
+
locations: locations.map((l) => l.id),
|
|
7460
|
+
bootMs: deps.bootMs
|
|
7461
|
+
} });
|
|
7462
|
+
for (const location of locations) {
|
|
7463
|
+
const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
|
|
7464
|
+
let deviceDirs;
|
|
7465
|
+
try {
|
|
7466
|
+
deviceDirs = await deps.listDir(stagingRoot);
|
|
7467
|
+
} catch {
|
|
7468
|
+
continue;
|
|
7469
|
+
}
|
|
7470
|
+
for (const deviceDir of deviceDirs) {
|
|
7471
|
+
if (aborted) break;
|
|
7472
|
+
const deviceId = Number(deviceDir);
|
|
7473
|
+
if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
|
|
7474
|
+
let profileDirs;
|
|
7475
|
+
try {
|
|
7476
|
+
profileDirs = await deps.listDir(`${stagingRoot}/${deviceDir}`);
|
|
7477
|
+
} catch {
|
|
7478
|
+
continue;
|
|
7479
|
+
}
|
|
7480
|
+
const segmentSeconds = await deps.segmentSecondsFor(deviceId);
|
|
7481
|
+
for (const profile of profileDirs) {
|
|
7482
|
+
if (aborted) break;
|
|
7483
|
+
if (!KNOWN_PROFILES.has(profile)) continue;
|
|
7484
|
+
const dir = `${stagingRoot}/${deviceDir}/${profile}`;
|
|
7485
|
+
dirs += 1;
|
|
7486
|
+
const report = await recoverStagingOrphans(dir, {
|
|
7487
|
+
writerStartedMs: deps.bootMs,
|
|
7488
|
+
segmentSeconds
|
|
7489
|
+
}, {
|
|
7490
|
+
listDir: deps.listDir,
|
|
7491
|
+
readPlaylist: deps.readPlaylist,
|
|
7492
|
+
finalize: (orphan, flatAbsPath) => deps.finalize({
|
|
7493
|
+
deviceId,
|
|
7494
|
+
profile,
|
|
7495
|
+
locationId: location.id,
|
|
7496
|
+
locationRoot: location.root,
|
|
7497
|
+
flatAbsPath,
|
|
7498
|
+
startMs: orphan.startMs,
|
|
7499
|
+
durMs: orphan.durMs
|
|
7500
|
+
}),
|
|
7501
|
+
logger: withDeviceTag(deps.logger, deviceId),
|
|
7502
|
+
chunkSize: deps.chunkSize,
|
|
7503
|
+
yieldBetween,
|
|
7504
|
+
shouldStop: deps.shouldStop
|
|
7505
|
+
});
|
|
7506
|
+
found += report.found;
|
|
7507
|
+
recovered += report.recovered;
|
|
7508
|
+
failed += report.failed;
|
|
7509
|
+
if (report.aborted) aborted = true;
|
|
7510
|
+
}
|
|
7511
|
+
}
|
|
7512
|
+
}
|
|
7513
|
+
deps.logger.info("recorder: staging boot reconcile complete", { meta: {
|
|
7514
|
+
dirs,
|
|
7515
|
+
found,
|
|
7516
|
+
recovered,
|
|
7517
|
+
failed,
|
|
7518
|
+
aborted,
|
|
7519
|
+
ms: Date.now() - startedMs,
|
|
7520
|
+
...pacer === null ? {} : { pacing: pacer.stats() }
|
|
7521
|
+
} });
|
|
7522
|
+
return {
|
|
7523
|
+
dirs,
|
|
7524
|
+
found,
|
|
7525
|
+
recovered,
|
|
7526
|
+
failed,
|
|
7527
|
+
aborted
|
|
7528
|
+
};
|
|
7529
|
+
}
|
|
7530
|
+
/** Bind `tags: { deviceId }` onto every line a per-dir pass writes. */
|
|
7531
|
+
function withDeviceTag(logger, deviceId) {
|
|
7532
|
+
const tag = (extras) => ({
|
|
7533
|
+
...typeof extras === "object" && extras !== null ? extras : {},
|
|
7534
|
+
tags: { deviceId }
|
|
7535
|
+
});
|
|
7536
|
+
return {
|
|
7537
|
+
info: (message, extras) => logger.info(message, tag(extras)),
|
|
7538
|
+
warn: (message, extras) => logger.warn(message, tag(extras)),
|
|
7539
|
+
debug: (message, extras) => logger.debug(message, tag(extras))
|
|
7540
|
+
};
|
|
7541
|
+
}
|
|
7542
|
+
//#endregion
|
|
6981
7543
|
//#region src/recorder/addon/index.ts
|
|
6982
7544
|
/**
|
|
6983
7545
|
* recorder addon SHELL — recording + storage-evictable providers on the
|
|
@@ -7165,6 +7727,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
7165
7727
|
this.calendar.invalidate(deviceId, atMs);
|
|
7166
7728
|
},
|
|
7167
7729
|
onIndexed: (row, absPath) => {
|
|
7730
|
+
if (!isMfraTableServable(row.startMs, Date.now())) return;
|
|
7168
7731
|
readMfraTable(absPath, row.bytes).then((table) => {
|
|
7169
7732
|
this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
|
|
7170
7733
|
}).catch((err) => {
|
|
@@ -7182,13 +7745,18 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
7182
7745
|
if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
|
|
7183
7746
|
await promises.rmdir(path.join(loc.root, relDir));
|
|
7184
7747
|
},
|
|
7185
|
-
logger: {
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7748
|
+
logger: {
|
|
7749
|
+
warn: (message, extras) => {
|
|
7750
|
+
if (isSegmentStoreLogExtras(extras)) this.ctx.logger.warn(message, {
|
|
7751
|
+
tags: extras.tags,
|
|
7752
|
+
meta: { error: extras.error }
|
|
7753
|
+
});
|
|
7754
|
+
else this.ctx.logger.warn(message, { meta: { extras } });
|
|
7755
|
+
},
|
|
7756
|
+
info: (message, extras) => {
|
|
7757
|
+
this.ctx.logger.info(message, { meta: extras.meta });
|
|
7758
|
+
}
|
|
7759
|
+
}
|
|
7192
7760
|
};
|
|
7193
7761
|
this.segmentStore = new SegmentStore(segmentStoreDeps);
|
|
7194
7762
|
const brokerHandle = this.ctx.useCapability("stream-broker", brokerScope(ingestOwner.ownerNodeId));
|
|
@@ -7515,11 +8083,13 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
7515
8083
|
force: true
|
|
7516
8084
|
}).catch(() => {});
|
|
7517
8085
|
await this.sweepUnassignedStaging();
|
|
8086
|
+
const stagingBootMs = Date.now();
|
|
7518
8087
|
try {
|
|
7519
8088
|
await this.controller?.start();
|
|
7520
8089
|
} catch (err) {
|
|
7521
8090
|
this.ctx.logger.warn("recorder: controller start failed", { meta: { error: errMsg(err) } });
|
|
7522
8091
|
}
|
|
8092
|
+
this.recoverStagedOrphans(stagingBootMs);
|
|
7523
8093
|
if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
|
|
7524
8094
|
eventBus: this.ctx.eventBus,
|
|
7525
8095
|
map: this.eventMap,
|
|
@@ -7824,6 +8394,45 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
7824
8394
|
}
|
|
7825
8395
|
}
|
|
7826
8396
|
/**
|
|
8397
|
+
* Boot reconcile for `.rec-tmp`: relocate every segment a previous runner's
|
|
8398
|
+
* writers left stranded, on every location, for every camera.
|
|
8399
|
+
*
|
|
8400
|
+
* The counterpart to {@link sweepUnassignedStaging} above, which only ever
|
|
8401
|
+
* looks at locations nothing is assigned to — so the LIVE root, the one that
|
|
8402
|
+
* actually accumulates, had no pass at all (D148). Nothing here deletes: each
|
|
8403
|
+
* orphan goes through the same `SegmentStore.onFinalized` a live segment
|
|
8404
|
+
* does, and one it cannot place is left on disk for the next boot.
|
|
8405
|
+
*/
|
|
8406
|
+
async recoverStagedOrphans(bootMs) {
|
|
8407
|
+
try {
|
|
8408
|
+
await recoverAllStagedOrphans({
|
|
8409
|
+
locations: () => this.resolvedLocations.map((l) => ({
|
|
8410
|
+
id: l.id,
|
|
8411
|
+
root: l.root
|
|
8412
|
+
})),
|
|
8413
|
+
listDir: nodeStagingIo.listDir,
|
|
8414
|
+
readPlaylist: nodeStagingIo.readPlaylist,
|
|
8415
|
+
finalize: async (input) => {
|
|
8416
|
+
const store = this.segmentStore;
|
|
8417
|
+
if (store === null) throw new Error("recorder is shutting down");
|
|
8418
|
+
await store.onFinalized(input);
|
|
8419
|
+
},
|
|
8420
|
+
logger: this.ctx.logger,
|
|
8421
|
+
bootMs,
|
|
8422
|
+
segmentSecondsFor: async (deviceId) => {
|
|
8423
|
+
try {
|
|
8424
|
+
return (await loadDeviceConfig(this.configStore(), deviceId)).segmentSeconds ?? this.config.segmentSeconds;
|
|
8425
|
+
} catch {
|
|
8426
|
+
return this.config.segmentSeconds;
|
|
8427
|
+
}
|
|
8428
|
+
},
|
|
8429
|
+
shouldStop: () => this.segmentStore === null
|
|
8430
|
+
});
|
|
8431
|
+
} catch (err) {
|
|
8432
|
+
this.ctx.logger.warn("recorder: staging boot reconcile failed", { meta: { error: errMsg(err) } });
|
|
8433
|
+
}
|
|
8434
|
+
}
|
|
8435
|
+
/**
|
|
7827
8436
|
* Surface a storage-placement fault on the liveness/alert surface. STRICTLY
|
|
7828
8437
|
* best-effort: the alerts cap is a nice-to-have and an unmounted disk must
|
|
7829
8438
|
* still be shouted about in the log even when it is unreachable.
|