@camstack/addon-pipeline 1.2.83 → 1.2.85

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.
@@ -7684,6 +7684,22 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
7684
7684
  * hourly cadence keeps the storage.list/evict cost negligible while honouring
7685
7685
  * the recording-spec §6 periodic retention. */
7686
7686
  var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
7687
+ /**
7688
+ * How long after boot the FULL-archive index walk starts.
7689
+ *
7690
+ * The walk is a recursive `readdir` per device over the whole recordings
7691
+ * subtree — minutes of network-FS latency on a real archive (measured: 240 s
7692
+ * for ONE 43 017-entry device, 2026-08-15) and it is the read model for
7693
+ * playback/retention/footprint, NOT the write path. Every read self-hydrates
7694
+ * the window it is asked about, and the whole-archive consumers
7695
+ * (retention sweep, redundancy janitor, disk-pressure eviction) only ever
7696
+ * under-report while the index is partial — so the walk waits out the boot
7697
+ * storm (writers attaching, staging reconcile relocating, first viewer paint)
7698
+ * instead of competing with it. Five minutes because the storm is measured in
7699
+ * seconds-to-a-minute and the consumers re-run on their own cadences anyway
7700
+ * (retention hourly, eviction on pressure).
7701
+ */
7702
+ var FULL_WALK_DEFER_MS = 5 * 6e4;
7687
7703
  /** Grace after a completed download before a `deleteAfterDownload` file is removed. */
7688
7704
  var EXPORT_DELETE_GRACE_MS = 6e4;
7689
7705
  /** Only `.mp4` export ids matching this shape are servable (traversal guard). */
@@ -7749,6 +7765,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7749
7765
  exportEngine = null;
7750
7766
  /** Periodic export-expiry sweep timer. Cleared on shutdown. */
7751
7767
  exportSweepTimer = null;
7768
+ /** Timer arming the deferred full-archive index walk. Null once fired. */
7769
+ fullWalkTimer = null;
7770
+ /** Single-flight guard for the full-archive walk (onHubReachable can re-enter
7771
+ * on a hub reconnect while a deferred walk is still running). */
7772
+ fullWalkInFlight = false;
7752
7773
  /** The `recording` cap provider — retained so the periodic retention sweep can
7753
7774
  * call its `pruneFootage`. Null until built in `onInitialize`. */
7754
7775
  recordingProvider = null;
@@ -8121,6 +8142,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
8121
8142
  }
8122
8143
  this.retentionSweeper?.stop();
8123
8144
  this.retentionSweeper = null;
8145
+ if (this.fullWalkTimer !== null) {
8146
+ clearTimeout(this.fullWalkTimer);
8147
+ this.fullWalkTimer = null;
8148
+ }
8124
8149
  this.exportEngine = null;
8125
8150
  this.recordingProvider = null;
8126
8151
  }
@@ -8190,41 +8215,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
8190
8215
  return;
8191
8216
  }
8192
8217
  this.warmToday([...configs.keys()]);
8193
- const hydrateStartedMs = Date.now();
8194
- await hydrateDevicesFromStorage(this.ctx.api, this.index, [...configs.keys()], this.resolvedLocations, this.ctx.logger);
8195
- this.ctx.logger.info("recorder: hydrated index from storage", { meta: {
8196
- deviceCount: configs.size,
8197
- locationCount: this.resolvedLocations.length,
8198
- ms: Date.now() - hydrateStartedMs
8199
- } });
8200
- const loggedDomains = /* @__PURE__ */ new Set();
8201
- for (const loc of this.resolvedLocations) {
8202
- const domain = this.locationDomain(loc.id);
8203
- const domainKey = [...domain].toSorted().join("+");
8204
- if (loggedDomains.has(domainKey)) continue;
8205
- loggedDomains.add(domainKey);
8206
- const acct = this.index.accountingForLocations(domain);
8207
- this.ctx.logger.info("recorder: eviction domain footage span at hydrate", { meta: {
8208
- locationIds: [...domain].toSorted(),
8209
- root: loc.root,
8210
- count: acct.count,
8211
- bytes: acct.bytes,
8212
- oldestMs: acct.oldestMs,
8213
- newestMs: acct.newestMs
8214
- } });
8215
- }
8218
+ this.scheduleDeferredFullWalk([...configs.keys()]);
8216
8219
  this.cleanupLegacyThumbsDirs();
8217
- const store = this.segmentStore;
8218
- if (store) try {
8219
- await runRedundancyJanitor({
8220
- deviceIds: [...configs.keys()],
8221
- segmentsFor: (deviceId, profile) => this.index.segments(deviceId, profile),
8222
- evict: (location, rows) => store.evict(location, rows),
8223
- logger: this.ctx.logger
8224
- });
8225
- } catch (err) {
8226
- this.ctx.logger.warn("recorder: redundancy janitor failed", { meta: { error: require_dist.errMsg(err) } });
8227
- }
8228
8220
  await this.bootstrapExports();
8229
8221
  const provider = this.recordingProvider;
8230
8222
  if (provider && this.retentionSweeper === null) this.retentionSweeper = new RetentionSweeper({
@@ -8667,6 +8659,74 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
8667
8659
  ms: Date.now() - started
8668
8660
  } });
8669
8661
  }
8662
+ /**
8663
+ * Arm the deferred full-archive index walk (see {@link FULL_WALK_DEFER_MS}).
8664
+ * Idempotent across `onHubReachable` re-entries: an armed timer is not
8665
+ * re-armed, and a walk already running is not duplicated.
8666
+ */
8667
+ scheduleDeferredFullWalk(deviceIds) {
8668
+ if (this.fullWalkTimer !== null || this.fullWalkInFlight) return;
8669
+ this.fullWalkTimer = setTimeout(() => {
8670
+ this.fullWalkTimer = null;
8671
+ this.runFullArchiveWalk(deviceIds);
8672
+ }, FULL_WALK_DEFER_MS);
8673
+ this.fullWalkTimer.unref?.();
8674
+ this.ctx.logger.info("recorder: full-archive index walk deferred", { meta: {
8675
+ deferMs: FULL_WALK_DEFER_MS,
8676
+ deviceCount: deviceIds.length
8677
+ } });
8678
+ }
8679
+ /**
8680
+ * The deferred boot walk itself: hydrate every configured device from the
8681
+ * recordings roots, then run the two consumers that need the COMPLETE index
8682
+ * (the eviction-domain span log and the redundancy janitor). Bounded-parallel
8683
+ * per device, and each camera is marked hydrated at ITS OWN completion — not
8684
+ * after the fleet (D167). Never overlaps itself.
8685
+ */
8686
+ async runFullArchiveWalk(deviceIds) {
8687
+ if (this.fullWalkInFlight) return;
8688
+ this.fullWalkInFlight = true;
8689
+ const hydrateStartedMs = Date.now();
8690
+ try {
8691
+ await hydrateDevicesFromStorage(this.ctx.api, this.index, deviceIds, this.resolvedLocations, this.ctx.logger);
8692
+ this.ctx.logger.info("recorder: hydrated index from storage", { meta: {
8693
+ deviceCount: deviceIds.length,
8694
+ locationCount: this.resolvedLocations.length,
8695
+ ms: Date.now() - hydrateStartedMs
8696
+ } });
8697
+ const loggedDomains = /* @__PURE__ */ new Set();
8698
+ for (const loc of this.resolvedLocations) {
8699
+ const domain = this.locationDomain(loc.id);
8700
+ const domainKey = [...domain].toSorted().join("+");
8701
+ if (loggedDomains.has(domainKey)) continue;
8702
+ loggedDomains.add(domainKey);
8703
+ const acct = this.index.accountingForLocations(domain);
8704
+ this.ctx.logger.info("recorder: eviction domain footage span at hydrate", { meta: {
8705
+ locationIds: [...domain].toSorted(),
8706
+ root: loc.root,
8707
+ count: acct.count,
8708
+ bytes: acct.bytes,
8709
+ oldestMs: acct.oldestMs,
8710
+ newestMs: acct.newestMs
8711
+ } });
8712
+ }
8713
+ const store = this.segmentStore;
8714
+ if (store) try {
8715
+ await runRedundancyJanitor({
8716
+ deviceIds,
8717
+ segmentsFor: (deviceId, profile) => this.index.segments(deviceId, profile),
8718
+ evict: (location, rows) => store.evict(location, rows),
8719
+ logger: this.ctx.logger
8720
+ });
8721
+ } catch (err) {
8722
+ this.ctx.logger.warn("recorder: redundancy janitor failed", { meta: { error: require_dist.errMsg(err) } });
8723
+ }
8724
+ } catch (err) {
8725
+ this.ctx.logger.warn("recorder: full-archive walk failed", { meta: { error: require_dist.errMsg(err) } });
8726
+ } finally {
8727
+ this.fullWalkInFlight = false;
8728
+ }
8729
+ }
8670
8730
  configStore() {
8671
8731
  return this.state(RECORDING_CONFIGS_KEY, RecordingConfigsBlobSchema, {});
8672
8732
  }
@@ -7683,6 +7683,22 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
7683
7683
  * hourly cadence keeps the storage.list/evict cost negligible while honouring
7684
7684
  * the recording-spec §6 periodic retention. */
7685
7685
  var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
7686
+ /**
7687
+ * How long after boot the FULL-archive index walk starts.
7688
+ *
7689
+ * The walk is a recursive `readdir` per device over the whole recordings
7690
+ * subtree — minutes of network-FS latency on a real archive (measured: 240 s
7691
+ * for ONE 43 017-entry device, 2026-08-15) and it is the read model for
7692
+ * playback/retention/footprint, NOT the write path. Every read self-hydrates
7693
+ * the window it is asked about, and the whole-archive consumers
7694
+ * (retention sweep, redundancy janitor, disk-pressure eviction) only ever
7695
+ * under-report while the index is partial — so the walk waits out the boot
7696
+ * storm (writers attaching, staging reconcile relocating, first viewer paint)
7697
+ * instead of competing with it. Five minutes because the storm is measured in
7698
+ * seconds-to-a-minute and the consumers re-run on their own cadences anyway
7699
+ * (retention hourly, eviction on pressure).
7700
+ */
7701
+ var FULL_WALK_DEFER_MS = 5 * 6e4;
7686
7702
  /** Grace after a completed download before a `deleteAfterDownload` file is removed. */
7687
7703
  var EXPORT_DELETE_GRACE_MS = 6e4;
7688
7704
  /** Only `.mp4` export ids matching this shape are servable (traversal guard). */
@@ -7748,6 +7764,11 @@ var RecorderV2Addon = class extends BaseAddon {
7748
7764
  exportEngine = null;
7749
7765
  /** Periodic export-expiry sweep timer. Cleared on shutdown. */
7750
7766
  exportSweepTimer = null;
7767
+ /** Timer arming the deferred full-archive index walk. Null once fired. */
7768
+ fullWalkTimer = null;
7769
+ /** Single-flight guard for the full-archive walk (onHubReachable can re-enter
7770
+ * on a hub reconnect while a deferred walk is still running). */
7771
+ fullWalkInFlight = false;
7751
7772
  /** The `recording` cap provider — retained so the periodic retention sweep can
7752
7773
  * call its `pruneFootage`. Null until built in `onInitialize`. */
7753
7774
  recordingProvider = null;
@@ -8120,6 +8141,10 @@ var RecorderV2Addon = class extends BaseAddon {
8120
8141
  }
8121
8142
  this.retentionSweeper?.stop();
8122
8143
  this.retentionSweeper = null;
8144
+ if (this.fullWalkTimer !== null) {
8145
+ clearTimeout(this.fullWalkTimer);
8146
+ this.fullWalkTimer = null;
8147
+ }
8123
8148
  this.exportEngine = null;
8124
8149
  this.recordingProvider = null;
8125
8150
  }
@@ -8189,41 +8214,8 @@ var RecorderV2Addon = class extends BaseAddon {
8189
8214
  return;
8190
8215
  }
8191
8216
  this.warmToday([...configs.keys()]);
8192
- const hydrateStartedMs = Date.now();
8193
- await hydrateDevicesFromStorage(this.ctx.api, this.index, [...configs.keys()], this.resolvedLocations, this.ctx.logger);
8194
- this.ctx.logger.info("recorder: hydrated index from storage", { meta: {
8195
- deviceCount: configs.size,
8196
- locationCount: this.resolvedLocations.length,
8197
- ms: Date.now() - hydrateStartedMs
8198
- } });
8199
- const loggedDomains = /* @__PURE__ */ new Set();
8200
- for (const loc of this.resolvedLocations) {
8201
- const domain = this.locationDomain(loc.id);
8202
- const domainKey = [...domain].toSorted().join("+");
8203
- if (loggedDomains.has(domainKey)) continue;
8204
- loggedDomains.add(domainKey);
8205
- const acct = this.index.accountingForLocations(domain);
8206
- this.ctx.logger.info("recorder: eviction domain footage span at hydrate", { meta: {
8207
- locationIds: [...domain].toSorted(),
8208
- root: loc.root,
8209
- count: acct.count,
8210
- bytes: acct.bytes,
8211
- oldestMs: acct.oldestMs,
8212
- newestMs: acct.newestMs
8213
- } });
8214
- }
8217
+ this.scheduleDeferredFullWalk([...configs.keys()]);
8215
8218
  this.cleanupLegacyThumbsDirs();
8216
- const store = this.segmentStore;
8217
- if (store) try {
8218
- await runRedundancyJanitor({
8219
- deviceIds: [...configs.keys()],
8220
- segmentsFor: (deviceId, profile) => this.index.segments(deviceId, profile),
8221
- evict: (location, rows) => store.evict(location, rows),
8222
- logger: this.ctx.logger
8223
- });
8224
- } catch (err) {
8225
- this.ctx.logger.warn("recorder: redundancy janitor failed", { meta: { error: errMsg(err) } });
8226
- }
8227
8219
  await this.bootstrapExports();
8228
8220
  const provider = this.recordingProvider;
8229
8221
  if (provider && this.retentionSweeper === null) this.retentionSweeper = new RetentionSweeper({
@@ -8666,6 +8658,74 @@ var RecorderV2Addon = class extends BaseAddon {
8666
8658
  ms: Date.now() - started
8667
8659
  } });
8668
8660
  }
8661
+ /**
8662
+ * Arm the deferred full-archive index walk (see {@link FULL_WALK_DEFER_MS}).
8663
+ * Idempotent across `onHubReachable` re-entries: an armed timer is not
8664
+ * re-armed, and a walk already running is not duplicated.
8665
+ */
8666
+ scheduleDeferredFullWalk(deviceIds) {
8667
+ if (this.fullWalkTimer !== null || this.fullWalkInFlight) return;
8668
+ this.fullWalkTimer = setTimeout(() => {
8669
+ this.fullWalkTimer = null;
8670
+ this.runFullArchiveWalk(deviceIds);
8671
+ }, FULL_WALK_DEFER_MS);
8672
+ this.fullWalkTimer.unref?.();
8673
+ this.ctx.logger.info("recorder: full-archive index walk deferred", { meta: {
8674
+ deferMs: FULL_WALK_DEFER_MS,
8675
+ deviceCount: deviceIds.length
8676
+ } });
8677
+ }
8678
+ /**
8679
+ * The deferred boot walk itself: hydrate every configured device from the
8680
+ * recordings roots, then run the two consumers that need the COMPLETE index
8681
+ * (the eviction-domain span log and the redundancy janitor). Bounded-parallel
8682
+ * per device, and each camera is marked hydrated at ITS OWN completion — not
8683
+ * after the fleet (D167). Never overlaps itself.
8684
+ */
8685
+ async runFullArchiveWalk(deviceIds) {
8686
+ if (this.fullWalkInFlight) return;
8687
+ this.fullWalkInFlight = true;
8688
+ const hydrateStartedMs = Date.now();
8689
+ try {
8690
+ await hydrateDevicesFromStorage(this.ctx.api, this.index, deviceIds, this.resolvedLocations, this.ctx.logger);
8691
+ this.ctx.logger.info("recorder: hydrated index from storage", { meta: {
8692
+ deviceCount: deviceIds.length,
8693
+ locationCount: this.resolvedLocations.length,
8694
+ ms: Date.now() - hydrateStartedMs
8695
+ } });
8696
+ const loggedDomains = /* @__PURE__ */ new Set();
8697
+ for (const loc of this.resolvedLocations) {
8698
+ const domain = this.locationDomain(loc.id);
8699
+ const domainKey = [...domain].toSorted().join("+");
8700
+ if (loggedDomains.has(domainKey)) continue;
8701
+ loggedDomains.add(domainKey);
8702
+ const acct = this.index.accountingForLocations(domain);
8703
+ this.ctx.logger.info("recorder: eviction domain footage span at hydrate", { meta: {
8704
+ locationIds: [...domain].toSorted(),
8705
+ root: loc.root,
8706
+ count: acct.count,
8707
+ bytes: acct.bytes,
8708
+ oldestMs: acct.oldestMs,
8709
+ newestMs: acct.newestMs
8710
+ } });
8711
+ }
8712
+ const store = this.segmentStore;
8713
+ if (store) try {
8714
+ await runRedundancyJanitor({
8715
+ deviceIds,
8716
+ segmentsFor: (deviceId, profile) => this.index.segments(deviceId, profile),
8717
+ evict: (location, rows) => store.evict(location, rows),
8718
+ logger: this.ctx.logger
8719
+ });
8720
+ } catch (err) {
8721
+ this.ctx.logger.warn("recorder: redundancy janitor failed", { meta: { error: errMsg(err) } });
8722
+ }
8723
+ } catch (err) {
8724
+ this.ctx.logger.warn("recorder: full-archive walk failed", { meta: { error: errMsg(err) } });
8725
+ } finally {
8726
+ this.fullWalkInFlight = false;
8727
+ }
8728
+ }
8669
8729
  configStore() {
8670
8730
  return this.state(RECORDING_CONFIGS_KEY, RecordingConfigsBlobSchema, {});
8671
8731
  }
@@ -15280,7 +15280,8 @@ var StreamBrokerManager = class StreamBrokerManager {
15280
15280
  * is byte-identical everywhere it used to be right.
15281
15281
  */
15282
15282
  async wakeBatteryDeviceOnPlay(deviceId, brokerId, camStreamId) {
15283
- if (this.hasRegisteredBroker(brokerId)) return;
15283
+ const demandHasBroker = camStreamId === void 0 ? () => this.hasAnyRegisteredBrokerForDevice(deviceId) : () => this.hasRegisteredBroker(brokerId);
15284
+ if (demandHasBroker()) return;
15284
15285
  if (this.disabledDevices.has(deviceId)) return;
15285
15286
  const api = this.api;
15286
15287
  if (!api) return;
@@ -15309,9 +15310,18 @@ var StreamBrokerManager = class StreamBrokerManager {
15309
15310
  }),
15310
15311
  reconcileCatalog: (id) => this.reconcileDeviceCatalog(id),
15311
15312
  ensureBroker: () => this.ensureBrokerForWebrtcSession(deviceId, brokerId, camStreamId),
15312
- hasBroker: () => this.hasRegisteredBroker(brokerId)
15313
+ hasBroker: demandHasBroker
15313
15314
  });
15314
15315
  }
15316
+ /** Any broker (local or remote-adapter) registered for this device? The
15317
+ * wake-on-play answer for `adaptive` targets — see the comment in
15318
+ * {@link wakeBatteryDeviceOnPlay}. */
15319
+ hasAnyRegisteredBrokerForDevice(deviceId) {
15320
+ const prefix = `${String(deviceId)}/`;
15321
+ for (const id of this.brokers.keys()) if (id.startsWith(prefix)) return true;
15322
+ for (const id of this.remoteBrokerAdapters.keys()) if (id.startsWith(prefix)) return true;
15323
+ return false;
15324
+ }
15315
15325
  /**
15316
15326
  * Single-flight body for `ensureBrokerForWebrtcSession`'s remote-adapter
15317
15327
  * get-or-create — resolves the ingest owner, and for `remote-adapter` mode
@@ -15275,7 +15275,8 @@ var StreamBrokerManager = class StreamBrokerManager {
15275
15275
  * is byte-identical everywhere it used to be right.
15276
15276
  */
15277
15277
  async wakeBatteryDeviceOnPlay(deviceId, brokerId, camStreamId) {
15278
- if (this.hasRegisteredBroker(brokerId)) return;
15278
+ const demandHasBroker = camStreamId === void 0 ? () => this.hasAnyRegisteredBrokerForDevice(deviceId) : () => this.hasRegisteredBroker(brokerId);
15279
+ if (demandHasBroker()) return;
15279
15280
  if (this.disabledDevices.has(deviceId)) return;
15280
15281
  const api = this.api;
15281
15282
  if (!api) return;
@@ -15304,9 +15305,18 @@ var StreamBrokerManager = class StreamBrokerManager {
15304
15305
  }),
15305
15306
  reconcileCatalog: (id) => this.reconcileDeviceCatalog(id),
15306
15307
  ensureBroker: () => this.ensureBrokerForWebrtcSession(deviceId, brokerId, camStreamId),
15307
- hasBroker: () => this.hasRegisteredBroker(brokerId)
15308
+ hasBroker: demandHasBroker
15308
15309
  });
15309
15310
  }
15311
+ /** Any broker (local or remote-adapter) registered for this device? The
15312
+ * wake-on-play answer for `adaptive` targets — see the comment in
15313
+ * {@link wakeBatteryDeviceOnPlay}. */
15314
+ hasAnyRegisteredBrokerForDevice(deviceId) {
15315
+ const prefix = `${String(deviceId)}/`;
15316
+ for (const id of this.brokers.keys()) if (id.startsWith(prefix)) return true;
15317
+ for (const id of this.remoteBrokerAdapters.keys()) if (id.startsWith(prefix)) return true;
15318
+ return false;
15319
+ }
15310
15320
  /**
15311
15321
  * Single-flight body for `ensureBrokerForWebrtcSession`'s remote-adapter
15312
15322
  * get-or-create — resolves the ingest owner, and for `remote-adapter` mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.83",
3
+ "version": "1.2.85",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",