@camstack/addon-pipeline 1.2.77 → 1.2.79

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-bZcwLaVW.js → addon-utils-BZQxAJ1G.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-DQZzL0Ri.js → dist-BUIpqu3E.js} +125 -7
  7. package/dist/{dist-HhcoV-Ky.mjs → dist-BfTf6doa.mjs} +125 -7
  8. package/dist/{event-loop-stall-monitor-CBOo1oh_.js → event-loop-stall-monitor-CGLQP0by.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-CqBEWGSZ.mjs → event-loop-stall-monitor-CmrHj0i9.mjs} +1 -1
  10. package/dist/{lazy-sharp-DIC1rpea.js → lazy-sharp-BZVhL9Lw.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 +293 -34
  16. package/dist/recorder/index.mjs +292 -33
  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-De_xzvjU.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BohtrVou.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-y8ehJPoY.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-Dxr14lIu.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-CDS5frmk.mjs → hostInit-pDANS0x0.mjs} +2 -2
  24. package/dist/stream-broker/index.js +347 -42
  25. package/dist/stream-broker/index.mjs +347 -42
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-DZVGVAPU.js → worker-protocol-BaLcYcOo.js} +1 -1
  28. package/dist/{worker-protocol-BNlLGLBV.mjs → worker-protocol-BdOv9Ujl.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-DHJSxHH1.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-Dg08SxUW.mjs +0 -26
@@ -1,6 +1,6 @@
1
- const require_dist = require("../dist-DQZzL0Ri.js");
1
+ const require_dist = require("../dist-BUIpqu3E.js");
2
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
- const require_addon_utils = require("../addon-utils-bZcwLaVW.js");
3
+ const require_addon_utils = require("../addon-utils-BZQxAJ1G.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_child_process = require("node:child_process");
6
6
  let node_path = require("node:path");
@@ -390,7 +390,21 @@ async function readMfraTable(absPath, fileBytes) {
390
390
  /** How long a table stays served. Directory asks are for recent footage far
391
391
  * more often than not, and a bounded window keeps this store O(day), not
392
392
  * O(uptime): ~30 bytes a segment ⇒ a day across 20 cameras is a few MB. */
393
- var RETAIN_MS = 26 * 36e5;
393
+ var MFRA_RETAIN_MS = 26 * 36e5;
394
+ /**
395
+ * Whether a segment is recent enough for a captured table to ever be served.
396
+ *
397
+ * The gate the FINALIZE path must ask BEFORE reading the file. A live segment
398
+ * always passes; a recovered one almost never does, and capturing it is pure
399
+ * loss twice over — the table is unservable, and the read is a real `open` +
400
+ * two `read`s on the libuv threadpool. The D148 boot reconcile fired 122 400 of
401
+ * them, unawaited and unbounded, behind a 4-thread pool that the live
402
+ * SegmentWriters' own finalize I/O has to share: that is the "segment file
403
+ * never landed" (>30 s) on four cameras at once.
404
+ */
405
+ function isMfraTableServable(startMs, nowMs) {
406
+ return startMs >= nowMs - MFRA_RETAIN_MS;
407
+ }
394
408
  /**
395
409
  * The RAM store the directory reads. Keys are `(deviceId, profile, startMs)`
396
410
  * — exactly how a directory row identifies a segment. Insertion is
@@ -406,7 +420,16 @@ var MfraTableStore = class {
406
420
  key(deviceId, profile, startMs) {
407
421
  return `${deviceId}:${profile}:${startMs}`;
408
422
  }
423
+ /**
424
+ * Keep one table, unless it is already past the serve window.
425
+ *
426
+ * The out-of-window REJECT is not belt-and-braces: `prune` walks the
427
+ * insertion-ordered prefix and stops at the first live entry, so an expired
428
+ * entry appended AFTER a live one is never reachable again. A bulk recovery
429
+ * appends 122 400 of exactly that shape and the store grows without bound.
430
+ */
409
431
  record(deviceId, profile, startMs, table) {
432
+ if (!isMfraTableServable(startMs, this.now())) return;
410
433
  this.tables.set(this.key(deviceId, profile, startMs), {
411
434
  startMs,
412
435
  table
@@ -427,7 +450,7 @@ var MfraTableStore = class {
427
450
  }
428
451
  /** Insertion order is time order, so the expired prefix is contiguous. */
429
452
  prune() {
430
- const cutoff = this.now() - RETAIN_MS;
453
+ const cutoff = this.now() - MFRA_RETAIN_MS;
431
454
  for (const [key, entry] of this.tables) {
432
455
  if (entry.startMs >= cutoff) break;
433
456
  this.tables.delete(key);
@@ -626,6 +649,38 @@ var RecordingIndex = class {
626
649
  segments(deviceId, profile) {
627
650
  return [...this.sortedSegments(deviceId, profile)];
628
651
  }
652
+ /** True when `relPath` is currently indexed for `deviceId`. O(1). */
653
+ hasSegment(deviceId, relPath) {
654
+ return this.byDevice.get(deviceId)?.has(relPath) === true;
655
+ }
656
+ /**
657
+ * Indexed-segment count per HOUR-BUCKET directory (`<dev>/<profile>/Y/M/D/H`)
658
+ * for one (device, profile), in ONE pass over the device's rows.
659
+ *
660
+ * Exists because `SegmentStore.evict` has to answer "does this eviction cover
661
+ * every segment the bucket holds?" for every bucket in the round, and it used
662
+ * to answer it with `segments(deviceId, profile)` — a full COPY of the
663
+ * device-profile archive plus a full `startsWith` scan, PER BUCKET. That is
664
+ * O(buckets x archive), and D148 is what made it bite: before it, a retention
665
+ * round touched a handful of buckets at the retention edge; after it, the
666
+ * recovered backlog made ~8 900 sparse buckets across six weeks evictable in
667
+ * ONE round. Measured on that shape (1.63 M live rows + 122 400 victims):
668
+ * 182 596 ms of uninterrupted main-thread CPU, with no log line, which is the
669
+ * silent 100%-CPU phase the operator saw. One pass answers all of them.
670
+ */
671
+ bucketCounts(deviceId, profile) {
672
+ const out = /* @__PURE__ */ new Map();
673
+ const m = this.byDevice.get(deviceId);
674
+ if (!m) return out;
675
+ for (const s of m.values()) {
676
+ if (s.profile !== profile) continue;
677
+ const cut = s.path.lastIndexOf("/");
678
+ if (cut <= 0) continue;
679
+ const bucketDir = s.path.slice(0, cut);
680
+ out.set(bucketDir, (out.get(bucketDir) ?? 0) + 1);
681
+ }
682
+ return out;
683
+ }
629
684
  /** Aggregate accounting for a device across ALL its locations (use `accountingForLocation` for one location). */
630
685
  accounting(deviceId) {
631
686
  const m = this.byDevice.get(deviceId);
@@ -1258,6 +1313,7 @@ var SegmentStore = class SegmentStore {
1258
1313
  async evict(location, rows) {
1259
1314
  let reclaimed = 0;
1260
1315
  const removed = [];
1316
+ const startedMs = this.deps.now?.() ?? Date.now();
1261
1317
  const byBucket = /* @__PURE__ */ new Map();
1262
1318
  for (const r of rows) {
1263
1319
  const cut = r.path.lastIndexOf("/");
@@ -1266,13 +1322,29 @@ var SegmentStore = class SegmentStore {
1266
1322
  if (group) group.push(r);
1267
1323
  else byBucket.set(bucketDir, [r]);
1268
1324
  }
1325
+ const census = /* @__PURE__ */ new Map();
1326
+ const countsFor = (deviceId, profile) => {
1327
+ const key = `${deviceId}:${profile}`;
1328
+ const cached = census.get(key);
1329
+ if (cached) return cached;
1330
+ const counts = this.deps.index.bucketCounts(deviceId, profile);
1331
+ census.set(key, counts);
1332
+ return counts;
1333
+ };
1334
+ const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
1335
+ const yieldBetween = this.deps.yieldBetween ?? macrotask$2;
1336
+ let bucketsRemoved = 0;
1337
+ let handled = 0;
1269
1338
  for (const [bucketDir, bucketRows] of byBucket) {
1270
- if (bucketDir !== "" && this.bucketFullyEvicted(bucketDir, bucketRows)) try {
1339
+ if (bucketDir !== "" && this.bucketFullyEvicted(bucketDir, bucketRows, countsFor)) try {
1271
1340
  await this.deps.removeDir(location, bucketDir);
1341
+ bucketsRemoved += 1;
1272
1342
  for (const r of bucketRows) {
1273
1343
  removed.push(r.path);
1274
1344
  reclaimed += r.bytes;
1275
1345
  }
1346
+ handled += 1;
1347
+ if (handled % chunkSize === 0) await yieldBetween();
1276
1348
  continue;
1277
1349
  } catch (err) {
1278
1350
  this.deps.logger.warn("bucket removeDir failed — falling back to per-file deletes", {
@@ -1293,15 +1365,47 @@ var SegmentStore = class SegmentStore {
1293
1365
  error: err
1294
1366
  });
1295
1367
  }
1368
+ handled += 1;
1369
+ if (handled % chunkSize === 0) await yieldBetween();
1296
1370
  }
1297
1371
  if (removed.length > 0) {
1298
1372
  this.deps.index.removeSegments(removed);
1299
- for (const r of rows) this.deps.onEvicted?.(r.deviceId, r.startMs);
1373
+ this.notifyEvicted(rows);
1300
1374
  }
1301
1375
  await this.pruneEmptyAncestors(location, [...byBucket.keys()]);
1376
+ this.deps.logger.info("recorder: eviction round complete", { meta: {
1377
+ location,
1378
+ rows: rows.length,
1379
+ buckets: byBucket.size,
1380
+ bucketsRemoved,
1381
+ removed: removed.length,
1382
+ reclaimed,
1383
+ ms: (this.deps.now?.() ?? Date.now()) - startedMs
1384
+ } });
1302
1385
  return reclaimed;
1303
1386
  }
1304
1387
  /**
1388
+ * Tell the calendar cache what went, ONCE PER (device, UTC day).
1389
+ *
1390
+ * `CalendarIndex.invalidate` drops a whole UTC day, so a per-ROW call repeats
1391
+ * the same work for every segment of that day — and its hour-list sweep is
1392
+ * O(cached hours), in one uninterrupted synchronous block with no await in
1393
+ * it. Measured over a 122 400-row round with 5 000 cached hours: 15 285 ms of
1394
+ * frozen event loop, scaling linearly with how long the session has been
1395
+ * scrubbing. Deduped it is ~190 calls and unmeasurable.
1396
+ */
1397
+ notifyEvicted(rows) {
1398
+ const notify = this.deps.onEvicted;
1399
+ if (!notify) return;
1400
+ const seen = /* @__PURE__ */ new Set();
1401
+ for (const r of rows) {
1402
+ const key = `${r.deviceId}:${Math.floor(r.startMs / SegmentStore.DAY_MS)}`;
1403
+ if (seen.has(key)) continue;
1404
+ seen.add(key);
1405
+ notify(r.deviceId, r.startMs);
1406
+ }
1407
+ }
1408
+ /**
1305
1409
  * Walk up from each emptied hour directory removing ancestors that are now
1306
1410
  * empty, so that **a directory's existence means footage**.
1307
1411
  *
@@ -1336,22 +1440,37 @@ var SegmentStore = class SegmentStore {
1336
1440
  }
1337
1441
  /** Hour of one UTC hour-bucket in ms. */
1338
1442
  static HOUR_MS = 36e5;
1443
+ static DAY_MS = 864e5;
1444
+ /** Buckets between yields. Small enough that one chunk is milliseconds of
1445
+ * work now the per-bucket archive scan is gone. */
1446
+ static DEFAULT_EVICT_CHUNK = 25;
1339
1447
  /**
1340
1448
  * True when `victims` cover EVERY indexed segment of `bucketDir` AND the
1341
1449
  * bucket's hour has fully elapsed. The current-hour guard matters because
1342
1450
  * `onFinalized` relocates new segments INTO the current bucket concurrently —
1343
1451
  * an rm racing that rename would orphan a just-indexed segment.
1452
+ *
1453
+ * The cover test is a COUNT against the round's one-pass census plus an O(1)
1454
+ * indexed-ness check per victim, never a scan of the device archive: a
1455
+ * bucketDir is `<dev>/<profile>/Y/M/D/H`, so it names exactly one
1456
+ * (device, profile), and the victims of a bucket are unique paths. Equal
1457
+ * counts with every victim indexed therefore means the same set.
1344
1458
  */
1345
- bucketFullyEvicted(bucketDir, victims) {
1459
+ bucketFullyEvicted(bucketDir, victims, countsFor) {
1346
1460
  const first = victims[0];
1347
1461
  if (!first) return false;
1348
1462
  const bucketStartMs = Math.floor(first.startMs / SegmentStore.HOUR_MS) * SegmentStore.HOUR_MS;
1349
1463
  if ((this.deps.now?.() ?? Date.now()) < bucketStartMs + SegmentStore.HOUR_MS) return false;
1350
- const victimPaths = new Set(victims.map((v) => v.path));
1351
- const indexed = this.deps.index.segments(first.deviceId, first.profile).filter((s) => s.path.startsWith(`${bucketDir}/`));
1352
- return indexed.length === victimPaths.size && indexed.every((s) => victimPaths.has(s.path));
1464
+ const indexedInBucket = countsFor(first.deviceId, first.profile).get(bucketDir) ?? 0;
1465
+ if (indexedInBucket === 0) return false;
1466
+ const indexedVictims = /* @__PURE__ */ new Set();
1467
+ for (const v of victims) if (this.deps.index.hasSegment(v.deviceId, v.path)) indexedVictims.add(v.path);
1468
+ return indexedInBucket === indexedVictims.size;
1353
1469
  }
1354
1470
  };
1471
+ var macrotask$2 = () => new Promise((resolve) => {
1472
+ setImmediate(resolve);
1473
+ });
1355
1474
  //#endregion
1356
1475
  //#region src/recorder/still/still-source.ts
1357
1476
  /**
@@ -2012,7 +2131,7 @@ var CalendarIndex = class {
2012
2131
  const day = utcDayOf(atMs);
2013
2132
  this.byDevice.get(deviceId)?.delete(day);
2014
2133
  const prefix = `${deviceId}:`;
2015
- for (const key of [...this.hourSegs.keys()]) {
2134
+ for (const key of this.hourSegs.keys()) {
2016
2135
  if (!key.startsWith(prefix)) continue;
2017
2136
  if (utcDayOf(Number(key.slice(key.lastIndexOf(":") + 1))) === day) this.hourSegs.delete(key);
2018
2137
  }
@@ -4839,9 +4958,12 @@ async function resolveRecordingsLocations(api, logger) {
4839
4958
  * (no footage yet) and any per-location failure are warned + skipped
4840
4959
  * (best-effort), so one bad volume never blocks the rest of the hydrate.
4841
4960
  */
4842
- async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger) {
4961
+ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
4962
+ const yieldBetween = options.yieldBetween ?? macrotask$1;
4963
+ const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
4843
4964
  for (const [root, aliases] of aliasesByRoot(locations)) {
4844
4965
  const deviceDir = node_path.default.join(root, String(deviceId));
4966
+ const startedMs = Date.now();
4845
4967
  let entries;
4846
4968
  try {
4847
4969
  entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
@@ -4855,17 +4977,48 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
4855
4977
  });
4856
4978
  continue;
4857
4979
  }
4980
+ const walkedMs = Date.now();
4981
+ const locationForProfile = /* @__PURE__ */ new Map();
4858
4982
  const pathsByLocation = /* @__PURE__ */ new Map();
4859
- for (const entry of entries.filter((entry) => entry.endsWith(".m4s"))) {
4983
+ let handled = 0;
4984
+ for (const entry of entries) {
4985
+ if (!entry.endsWith(".m4s")) continue;
4860
4986
  const relative = `${deviceId}/${entry.split(node_path.default.sep).join("/")}`;
4861
- const location = locationForHydratedProfile(aliases, relative.split("/")[1] ?? "");
4862
- const paths = pathsByLocation.get(location.id);
4987
+ const profile = relative.split("/")[1] ?? "";
4988
+ let locationId = locationForProfile.get(profile);
4989
+ if (locationId === void 0) {
4990
+ locationId = locationForHydratedProfile(aliases, profile).id;
4991
+ locationForProfile.set(profile, locationId);
4992
+ }
4993
+ const paths = pathsByLocation.get(locationId);
4863
4994
  if (paths) paths.push(relative);
4864
- else pathsByLocation.set(location.id, [relative]);
4995
+ else pathsByLocation.set(locationId, [relative]);
4996
+ handled += 1;
4997
+ if (handled % chunkSize === 0) await yieldBetween();
4998
+ }
4999
+ let indexed = 0;
5000
+ for (const [locationId, relPaths] of pathsByLocation) {
5001
+ index.hydrateDevice(deviceId, locationId, relPaths);
5002
+ indexed += relPaths.length;
4865
5003
  }
4866
- for (const [locationId, relPaths] of pathsByLocation) index.hydrateDevice(deviceId, locationId, relPaths);
5004
+ logger.info("recorder: hydrated a device from one recordings root", {
5005
+ tags: { deviceId },
5006
+ meta: {
5007
+ root,
5008
+ entries: entries.length,
5009
+ indexed,
5010
+ locationIds: [...pathsByLocation.keys()],
5011
+ walkMs: walkedMs - startedMs,
5012
+ ms: Date.now() - startedMs
5013
+ }
5014
+ });
4867
5015
  }
4868
5016
  }
5017
+ /** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
5018
+ var HYDRATE_CHUNK = 2e4;
5019
+ var macrotask$1 = () => new Promise((resolve) => {
5020
+ setImmediate(resolve);
5021
+ });
4869
5022
  /** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
4870
5023
  function hourDirRelPath(deviceId, profile, hourStartMs) {
4871
5024
  const d = new Date(hourStartMs);
@@ -6949,20 +7102,30 @@ var RetentionSweeper = class {
6949
7102
  this.deps.logger.warn("recorder: retention sweep could not read device configs", { meta: { error: require_dist.errMsg(err) } });
6950
7103
  return;
6951
7104
  }
7105
+ const withPolicy = [...configs].filter(([, config]) => hasRetentionPolicy(config));
7106
+ const devicesSkipped = configs.size - withPolicy.length;
7107
+ this.deps.logger.info("recorder: retention sweep starting", { meta: {
7108
+ devices: withPolicy.map(([deviceId]) => deviceId),
7109
+ skipped: devicesSkipped
7110
+ } });
6952
7111
  let devicesSwept = 0;
6953
- let devicesSkipped = 0;
6954
7112
  let bucketsPruned = 0;
6955
7113
  let bytesReclaimed = 0;
6956
- for (const [deviceId, config] of configs) {
6957
- if (!hasRetentionPolicy(config)) {
6958
- devicesSkipped += 1;
6959
- continue;
6960
- }
7114
+ for (const [deviceId] of withPolicy) {
7115
+ const startedMs = Date.now();
6961
7116
  try {
6962
7117
  const result = await this.deps.prune(deviceId);
6963
7118
  devicesSwept += 1;
6964
7119
  bucketsPruned += result.deletedBuckets;
6965
7120
  bytesReclaimed += result.reclaimedBytes;
7121
+ this.deps.logger.info("recorder: retention prune complete for device", {
7122
+ tags: { deviceId },
7123
+ meta: {
7124
+ deletedBuckets: result.deletedBuckets,
7125
+ reclaimedBytes: result.reclaimedBytes,
7126
+ ms: Date.now() - startedMs
7127
+ }
7128
+ });
6966
7129
  } catch (err) {
6967
7130
  this.deps.logger.warn("recorder: retention prune failed for device", {
6968
7131
  tags: { deviceId },
@@ -6982,6 +7145,63 @@ var RetentionSweeper = class {
6982
7145
  }
6983
7146
  };
6984
7147
  //#endregion
7148
+ //#region src/recorder/loop-pacer.ts
7149
+ var DEFAULT_BUSY_LAG_MS = 25;
7150
+ var DEFAULT_BACKOFF_STEP_MS = 25;
7151
+ var DEFAULT_MAX_BACKOFF_MS = 250;
7152
+ var LoopPacer = class {
7153
+ deps;
7154
+ consecutive = 0;
7155
+ hops = 0;
7156
+ contendedHops = 0;
7157
+ pausedMs = 0;
7158
+ maxLagMs = 0;
7159
+ constructor(deps) {
7160
+ this.deps = deps;
7161
+ }
7162
+ /**
7163
+ * One paced yield. Always hands the loop back at least once; sleeps longer
7164
+ * while the previous hop shows the loop is contended, and forgets the
7165
+ * back-off as soon as one hop comes back clean.
7166
+ */
7167
+ async hop() {
7168
+ const busyLagMs = this.deps.busyLagMs ?? DEFAULT_BUSY_LAG_MS;
7169
+ const stepMs = this.deps.backoffStepMs ?? DEFAULT_BACKOFF_STEP_MS;
7170
+ const maxMs = this.deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
7171
+ const before = this.deps.now();
7172
+ await this.deps.sleep(0);
7173
+ const lagMs = this.deps.now() - before;
7174
+ this.hops += 1;
7175
+ if (lagMs > this.maxLagMs) this.maxLagMs = lagMs;
7176
+ if (lagMs < busyLagMs) {
7177
+ this.consecutive = 0;
7178
+ return;
7179
+ }
7180
+ this.consecutive += 1;
7181
+ this.contendedHops += 1;
7182
+ const pauseMs = Math.min(stepMs * this.consecutive, maxMs);
7183
+ this.pausedMs += pauseMs;
7184
+ await this.deps.sleep(pauseMs);
7185
+ }
7186
+ stats() {
7187
+ return {
7188
+ hops: this.hops,
7189
+ contendedHops: this.contendedHops,
7190
+ pausedMs: this.pausedMs,
7191
+ maxLagMs: this.maxLagMs
7192
+ };
7193
+ }
7194
+ };
7195
+ /** The production pacer: real clock, real timers. */
7196
+ function nodeLoopPacer() {
7197
+ return new LoopPacer({
7198
+ now: () => Date.now(),
7199
+ sleep: (ms) => new Promise((resolve) => {
7200
+ setTimeout(resolve, ms);
7201
+ })
7202
+ });
7203
+ }
7204
+ //#endregion
6985
7205
  //#region src/recorder/addon/staging-reconcile.ts
6986
7206
  /**
6987
7207
  * Recover `.rec-tmp` segments that a writer restart orphaned.
@@ -7089,6 +7309,14 @@ var KNOWN_PROFILES = new Set([
7089
7309
  ]);
7090
7310
  /** Default orphans per chunk. 590/high alone had 5 863 of them. */
7091
7311
  var DEFAULT_CHUNK_SIZE = 100;
7312
+ /**
7313
+ * Orphans between progress lines.
7314
+ *
7315
+ * A directory of 5 863 files takes minutes over shfs and used to report only
7316
+ * when it finished, so "is it still going, and how far in" had no answer at
7317
+ * all — the exact shape of the blackout this pass was written to end.
7318
+ */
7319
+ var PROGRESS_EVERY = 1e3;
7092
7320
  var macrotask = () => new Promise((resolve) => {
7093
7321
  setTimeout(resolve, 0);
7094
7322
  });
@@ -7135,6 +7363,13 @@ async function recoverStagingOrphans(dir, input, deps) {
7135
7363
  failed: 0,
7136
7364
  aborted: false
7137
7365
  };
7366
+ deps.logger.info("recorder: staging recovery starting on a directory", { meta: {
7367
+ dir,
7368
+ found: plan.length,
7369
+ staged: names.length,
7370
+ oldestMs: plan[0]?.startMs,
7371
+ newestMs: plan[plan.length - 1]?.startMs
7372
+ } });
7138
7373
  const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
7139
7374
  const yieldBetween = deps.yieldBetween ?? macrotask;
7140
7375
  let recovered = 0;
@@ -7165,6 +7400,13 @@ async function recoverStagingOrphans(dir, input, deps) {
7165
7400
  error: err instanceof Error ? err.message : String(err)
7166
7401
  } });
7167
7402
  }
7403
+ if ((i + 1) % PROGRESS_EVERY === 0 && i + 1 < plan.length) deps.logger.info("recorder: staging recovery progress", { meta: {
7404
+ dir,
7405
+ done: i + 1,
7406
+ found: plan.length,
7407
+ recovered,
7408
+ failed
7409
+ } });
7168
7410
  if ((i + 1) % chunkSize === 0 && i + 1 < plan.length) await yieldBetween();
7169
7411
  }
7170
7412
  deps.logger.info("recorder: recovered segments a writer restart had orphaned in staging", { meta: {
@@ -7209,7 +7451,16 @@ async function recoverAllStagedOrphans(deps) {
7209
7451
  let recovered = 0;
7210
7452
  let failed = 0;
7211
7453
  let aborted = false;
7212
- for (const location of deps.locations()) {
7454
+ const startedMs = Date.now();
7455
+ const injectedYield = deps.yieldBetween;
7456
+ const pacer = injectedYield === void 0 ? nodeLoopPacer() : null;
7457
+ const yieldBetween = injectedYield ?? (() => pacer === null ? macrotask() : pacer.hop());
7458
+ const locations = deps.locations();
7459
+ deps.logger.info("recorder: staging boot reconcile starting", { meta: {
7460
+ locations: locations.map((l) => l.id),
7461
+ bootMs: deps.bootMs
7462
+ } });
7463
+ for (const location of locations) {
7213
7464
  const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
7214
7465
  let deviceDirs;
7215
7466
  try {
@@ -7250,7 +7501,7 @@ async function recoverAllStagedOrphans(deps) {
7250
7501
  }),
7251
7502
  logger: withDeviceTag(deps.logger, deviceId),
7252
7503
  chunkSize: deps.chunkSize,
7253
- yieldBetween: deps.yieldBetween,
7504
+ yieldBetween,
7254
7505
  shouldStop: deps.shouldStop
7255
7506
  });
7256
7507
  found += report.found;
@@ -7260,12 +7511,14 @@ async function recoverAllStagedOrphans(deps) {
7260
7511
  }
7261
7512
  }
7262
7513
  }
7263
- if (found > 0 || failed > 0) deps.logger.info("recorder: staging boot reconcile complete", { meta: {
7514
+ deps.logger.info("recorder: staging boot reconcile complete", { meta: {
7264
7515
  dirs,
7265
7516
  found,
7266
7517
  recovered,
7267
7518
  failed,
7268
- aborted
7519
+ aborted,
7520
+ ms: Date.now() - startedMs,
7521
+ ...pacer === null ? {} : { pacing: pacer.stats() }
7269
7522
  } });
7270
7523
  return {
7271
7524
  dirs,
@@ -7475,6 +7728,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7475
7728
  this.calendar.invalidate(deviceId, atMs);
7476
7729
  },
7477
7730
  onIndexed: (row, absPath) => {
7731
+ if (!isMfraTableServable(row.startMs, Date.now())) return;
7478
7732
  readMfraTable(absPath, row.bytes).then((table) => {
7479
7733
  this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
7480
7734
  }).catch((err) => {
@@ -7492,13 +7746,18 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7492
7746
  if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
7493
7747
  await node_fs.promises.rmdir(node_path.default.join(loc.root, relDir));
7494
7748
  },
7495
- logger: { warn: (message, extras) => {
7496
- if (isSegmentStoreLogExtras(extras)) this.ctx.logger.warn(message, {
7497
- tags: extras.tags,
7498
- meta: { error: extras.error }
7499
- });
7500
- else this.ctx.logger.warn(message, { meta: { extras } });
7501
- } }
7749
+ logger: {
7750
+ warn: (message, extras) => {
7751
+ if (isSegmentStoreLogExtras(extras)) this.ctx.logger.warn(message, {
7752
+ tags: extras.tags,
7753
+ meta: { error: extras.error }
7754
+ });
7755
+ else this.ctx.logger.warn(message, { meta: { extras } });
7756
+ },
7757
+ info: (message, extras) => {
7758
+ this.ctx.logger.info(message, { meta: extras.meta });
7759
+ }
7760
+ }
7502
7761
  };
7503
7762
  this.segmentStore = new SegmentStore(segmentStoreDeps);
7504
7763
  const brokerHandle = this.ctx.useCapability("stream-broker", brokerScope(ingestOwner.ownerNodeId));