@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.
Files changed (31) hide show
  1. package/dist/{addon-utils-CTpQfjSR.js → addon-utils-C-XbUkiG.js} +1 -1
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +1 -1
  4. package/dist/detection-pipeline/index.js +4 -4
  5. package/dist/detection-pipeline/index.mjs +2 -2
  6. package/dist/{dist-CZLjObZZ.js → dist-BdVCXl5n.js} +429 -44
  7. package/dist/{dist-zksfWnEA.mjs → dist-CsP_DikG.mjs} +429 -44
  8. package/dist/{event-loop-stall-monitor-CcBQAI28.js → event-loop-stall-monitor-BOu8lGee.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-BuZA3loB.mjs → event-loop-stall-monitor-C3cvE_Xk.mjs} +1 -1
  10. package/dist/{lazy-sharp-SWR_D1Um.js → lazy-sharp-1LkmyWqV.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/recorder/index.js +655 -46
  16. package/dist/recorder/index.mjs +654 -45
  17. package/dist/session-decode/decode-worker-child.js +2 -2
  18. package/dist/session-decode/decode-worker-child.mjs +1 -1
  19. package/dist/stream-broker/_stub.js +2 -2
  20. 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
  21. 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
  22. 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
  23. package/dist/stream-broker/{hostInit-COc_Q_xX.mjs → hostInit-DEtsjgBO.mjs} +3 -3
  24. package/dist/stream-broker/index.js +411 -93
  25. package/dist/stream-broker/index.mjs +411 -93
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-DAQ1iZFK.js → worker-protocol-B4fPjmXk.js} +1 -1
  28. package/dist/{worker-protocol-DyX_HbaJ.mjs → worker-protocol-BRwzX3f_.mjs} +1 -1
  29. package/package.json +1 -1
  30. 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
  31. 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
@@ -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-zksfWnEA.mjs";
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 RETAIN_MS = 26 * 36e5;
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() - RETAIN_MS;
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
- for (const r of rows) this.deps.onEvicted?.(r.deviceId, r.startMs);
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 victimPaths = new Set(victims.map((v) => v.path));
1350
- const indexed = this.deps.index.segments(first.deviceId, first.profile).filter((s) => s.path.startsWith(`${bucketDir}/`));
1351
- return indexed.length === victimPaths.size && indexed.every((s) => victimPaths.has(s.path));
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 [...this.hourSegs.keys()]) {
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
- for (const entry of entries.filter((entry) => entry.endsWith(".m4s"))) {
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 location = locationForHydratedProfile(aliases, relative.split("/")[1] ?? "");
4861
- const paths = pathsByLocation.get(location.id);
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(location.id, [relative]);
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
- for (const [locationId, relPaths] of pathsByLocation) index.hydrateDevice(deviceId, locationId, relPaths);
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's `-segment_list` file is not cleared between runs and a fresh watcher
5672
- * starts at `processed = 0`, so the first passes after every attach replay the
5673
- * PREVIOUS run's playlist. Those entries were relocated into the bucket tree
5674
- * long ago; their absence is the system working. Over 2026-08-10..13 they were
5675
- * 83% of all retries (median age 1 615 s, max 17.4 h) and produced 68 of the
5676
- * 122 `never landed` warnings a warning that reads like data loss for a file
5677
- * that was correctly filed hours earlier.
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
- * Note what this deliberately does NOT do: it does not skip a previous run's
5680
- * entry whose file is STILL THERE. That case is real crash recovery — an
5681
- * unclean stop leaves un-relocated segments in `.rec-tmp` and the playlist is
5682
- * the only record of their durations so the caller stats first and only asks
5683
- * this question once the file is known to be absent.
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, config] of configs) {
6953
- if (!hasRetentionPolicy(config)) {
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: { warn: (message, extras) => {
7186
- if (isSegmentStoreLogExtras(extras)) this.ctx.logger.warn(message, {
7187
- tags: extras.tags,
7188
- meta: { error: extras.error }
7189
- });
7190
- else this.ctx.logger.warn(message, { meta: { extras } });
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.