@camstack/addon-pipeline 1.2.222 → 1.2.223

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.
@@ -397,8 +397,20 @@ function resampleMono16k(chunk) {
397
397
  }
398
398
  return out;
399
399
  }
400
- /** How many chunks to drain per poll — a small burst absorbs jitter. */
401
- var PULL_MAX_COUNT = 8;
400
+ /**
401
+ * How many times one tick may re-poll immediately after a FULL batch (D465).
402
+ *
403
+ * A batch that comes back full is not a drain, it is a backlog: there is more
404
+ * waiting and the next chunk is already older than the window it belongs to.
405
+ * Continuing bounds the damage for any producer cadence — including one this
406
+ * file cannot know — without adding a single request when there is no backlog.
407
+ * The broker's FIFO holds 64, so two continuations clear a completely full
408
+ * queue; four is margin, and the bound is what stops a permanently faster
409
+ * producer from turning this into a spin.
410
+ */
411
+ var MAX_DRAIN_CONTINUATIONS = 4;
412
+ /** Re-state a standing backlog at most this often. */
413
+ var BACKLOG_RELOG_MS = 3e4;
402
414
  /**
403
415
  * Consecutive pull failures before we attempt to re-subscribe. A single failed
404
416
  * poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
@@ -441,7 +453,9 @@ function startAudioChunkPoller(options) {
441
453
  stopped: false,
442
454
  retryTimer: void 0,
443
455
  pollTimer: void 0,
444
- activeSubscriptionId: null
456
+ activeSubscriptionId: null,
457
+ backlogged: false,
458
+ lastBacklogLogAt: 0
445
459
  };
446
460
  const teardown = () => {
447
461
  if (lifecycle.stopped) return;
@@ -567,19 +581,44 @@ function startPolling(options, lifecycle) {
567
581
  const subId = lifecycle.activeSubscriptionId;
568
582
  if (!subId) return;
569
583
  try {
570
- const chunks = await api.streamBroker.pullAudioChunks.query({
571
- subscriptionId: subId,
572
- maxCount: PULL_MAX_COUNT
573
- }, pin);
574
- if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
575
- brokerId,
576
- subscriptionId: subId
577
- } });
578
- consecutiveFailures = 0;
579
- for (const chunk of chunks) {
584
+ let continuations = 0;
585
+ let drained = 0;
586
+ for (;;) {
587
+ const chunks = await api.streamBroker.pullAudioChunks.query({
588
+ subscriptionId: subId,
589
+ maxCount: 32
590
+ }, pin);
591
+ if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
592
+ brokerId,
593
+ subscriptionId: subId
594
+ } });
595
+ consecutiveFailures = 0;
596
+ drained += chunks.length;
597
+ for (const chunk of chunks) {
598
+ if (lifecycle.stopped) break;
599
+ await onChunk(chunk);
600
+ }
580
601
  if (lifecycle.stopped) break;
581
- await onChunk(chunk);
602
+ if (chunks.length < 32) break;
603
+ if (continuations >= MAX_DRAIN_CONTINUATIONS) break;
604
+ continuations += 1;
582
605
  }
606
+ if (continuations > 0) {
607
+ const now = Date.now();
608
+ if (!lifecycle.backlogged || now - lifecycle.lastBacklogLogAt >= BACKLOG_RELOG_MS) {
609
+ lifecycle.lastBacklogLogAt = now;
610
+ logger.warn("audio-chunk poller: backlog — a full batch is not a drain", { meta: {
611
+ brokerId,
612
+ subscriptionId: subId,
613
+ drained,
614
+ maxCount: 32,
615
+ continuations,
616
+ pollIntervalMs: options.pollIntervalMs ?? 200,
617
+ hint: "this camera produces chunks faster than one poll of this cadence can carry — the broker FIFO is overwriting next"
618
+ } });
619
+ }
620
+ lifecycle.backlogged = true;
621
+ } else lifecycle.backlogged = false;
583
622
  } catch (err) {
584
623
  consecutiveFailures += 1;
585
624
  if (consecutiveFailures === 1) logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", { meta: {
@@ -391,8 +391,20 @@ function resampleMono16k(chunk) {
391
391
  }
392
392
  return out;
393
393
  }
394
- /** How many chunks to drain per poll — a small burst absorbs jitter. */
395
- var PULL_MAX_COUNT = 8;
394
+ /**
395
+ * How many times one tick may re-poll immediately after a FULL batch (D465).
396
+ *
397
+ * A batch that comes back full is not a drain, it is a backlog: there is more
398
+ * waiting and the next chunk is already older than the window it belongs to.
399
+ * Continuing bounds the damage for any producer cadence — including one this
400
+ * file cannot know — without adding a single request when there is no backlog.
401
+ * The broker's FIFO holds 64, so two continuations clear a completely full
402
+ * queue; four is margin, and the bound is what stops a permanently faster
403
+ * producer from turning this into a spin.
404
+ */
405
+ var MAX_DRAIN_CONTINUATIONS = 4;
406
+ /** Re-state a standing backlog at most this often. */
407
+ var BACKLOG_RELOG_MS = 3e4;
396
408
  /**
397
409
  * Consecutive pull failures before we attempt to re-subscribe. A single failed
398
410
  * poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
@@ -435,7 +447,9 @@ function startAudioChunkPoller(options) {
435
447
  stopped: false,
436
448
  retryTimer: void 0,
437
449
  pollTimer: void 0,
438
- activeSubscriptionId: null
450
+ activeSubscriptionId: null,
451
+ backlogged: false,
452
+ lastBacklogLogAt: 0
439
453
  };
440
454
  const teardown = () => {
441
455
  if (lifecycle.stopped) return;
@@ -561,19 +575,44 @@ function startPolling(options, lifecycle) {
561
575
  const subId = lifecycle.activeSubscriptionId;
562
576
  if (!subId) return;
563
577
  try {
564
- const chunks = await api.streamBroker.pullAudioChunks.query({
565
- subscriptionId: subId,
566
- maxCount: PULL_MAX_COUNT
567
- }, pin);
568
- if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
569
- brokerId,
570
- subscriptionId: subId
571
- } });
572
- consecutiveFailures = 0;
573
- for (const chunk of chunks) {
578
+ let continuations = 0;
579
+ let drained = 0;
580
+ for (;;) {
581
+ const chunks = await api.streamBroker.pullAudioChunks.query({
582
+ subscriptionId: subId,
583
+ maxCount: 32
584
+ }, pin);
585
+ if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
586
+ brokerId,
587
+ subscriptionId: subId
588
+ } });
589
+ consecutiveFailures = 0;
590
+ drained += chunks.length;
591
+ for (const chunk of chunks) {
592
+ if (lifecycle.stopped) break;
593
+ await onChunk(chunk);
594
+ }
574
595
  if (lifecycle.stopped) break;
575
- await onChunk(chunk);
596
+ if (chunks.length < 32) break;
597
+ if (continuations >= MAX_DRAIN_CONTINUATIONS) break;
598
+ continuations += 1;
576
599
  }
600
+ if (continuations > 0) {
601
+ const now = Date.now();
602
+ if (!lifecycle.backlogged || now - lifecycle.lastBacklogLogAt >= BACKLOG_RELOG_MS) {
603
+ lifecycle.lastBacklogLogAt = now;
604
+ logger.warn("audio-chunk poller: backlog — a full batch is not a drain", { meta: {
605
+ brokerId,
606
+ subscriptionId: subId,
607
+ drained,
608
+ maxCount: 32,
609
+ continuations,
610
+ pollIntervalMs: options.pollIntervalMs ?? 200,
611
+ hint: "this camera produces chunks faster than one poll of this cadence can carry — the broker FIFO is overwriting next"
612
+ } });
613
+ }
614
+ lifecycle.backlogged = true;
615
+ } else lifecycle.backlogged = false;
577
616
  } catch (err) {
578
617
  consecutiveFailures += 1;
579
618
  if (consecutiveFailures === 1) logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", { meta: {
@@ -6507,41 +6507,34 @@ function isUsable(bbox) {
6507
6507
  //#endregion
6508
6508
  //#region src/pipeline-runner/runtime-labels.ts
6509
6509
  /**
6510
- * `null` for either input means "could not be read", and the corresponding
6511
- * field is OMITTED. The two are independent: an engine that failed to answer
6512
- * must not also erase the models, which is how the original field managed to
6513
- * be null on every line ever logged.
6514
- */
6515
- function buildRuntimeLabels(engine, steps) {
6516
- const runtime = engine === null ? void 0 : [
6517
- engine.runtime,
6518
- engine.backend,
6519
- engine.device
6520
- ].filter((part) => part !== void 0 && part !== "").join("/");
6521
- const models = steps === null ? void 0 : [...new Set(steps.filter((s) => s.enabled).map((s) => s.modelId))];
6510
+ * `null` means the read FAILED; `[]` means nothing is resident. Both omit the
6511
+ * fields, but they are different facts and the caller logs the failure.
6512
+ *
6513
+ * The `runtime` pool is named first because it is the one serving cameras; the
6514
+ * rest follow in arrival order. `python` is deliberately NOT printed: every
6515
+ * engine available on this cluster is python, and a token that cannot vary
6516
+ * costs a column and tells the reader nothing.
6517
+ */
6518
+ function buildRuntimeLabels(engines) {
6519
+ if (engines === null || engines.length === 0) return {};
6520
+ const ordered = [...engines.filter((e) => e.kind === "runtime"), ...engines.filter((e) => e.kind !== "runtime")];
6521
+ const devices = [...new Set(ordered.map((e) => e.device).filter((d) => !!d))];
6522
+ const backends = [...new Set(ordered.map((e) => e.backend))];
6523
+ const backend = backends.length === 1 ? backends[0] : "mixed";
6524
+ const runtime = devices.length === 0 ? backend : `${backend}/${devices.join("+")}`;
6525
+ const primary = ordered[0];
6526
+ if (primary === void 0) return {};
6527
+ const models = [];
6528
+ for (const engine of ordered) for (const model of engine.modelsLoaded) {
6529
+ if (models.includes(model)) continue;
6530
+ if (engine === primary || primary.modelsLoaded.includes(model)) models.push(model);
6531
+ else models.push(engine.device === void 0 ? model : `${model}@${engine.device}`);
6532
+ }
6522
6533
  return {
6523
- ...runtime !== void 0 ? { pipelineRuntime: runtime } : {},
6524
- ...models !== void 0 ? { pipelineModels: models } : {}
6534
+ pipelineRuntime: runtime,
6535
+ pipelineModels: models
6525
6536
  };
6526
6537
  }
6527
- /**
6528
- * Depth-first flatten of the active pipeline tree.
6529
- *
6530
- * A DISABLED step takes its subtree with it: the children of a step that does
6531
- * not run did not run either, and listing a face model under a disabled face
6532
- * detector would name a model that never loaded.
6533
- */
6534
- function flattenLabelledSteps(steps) {
6535
- const out = [];
6536
- for (const step of steps) {
6537
- out.push({
6538
- modelId: step.modelId,
6539
- enabled: step.enabled
6540
- });
6541
- if (step.enabled && step.children !== void 0) out.push(...flattenLabelledSteps(step.children));
6542
- }
6543
- return out;
6544
- }
6545
6538
  //#endregion
6546
6539
  //#region src/pipeline-runner/track-frame-park.ts
6547
6540
  /** Park one frame for one track. Never throws; a refusal is an answer. */
@@ -6702,6 +6695,8 @@ var TILE_CUT_DROP_LOG_INTERVAL_MS = 6e4;
6702
6695
  * operator re-selects one or lazy provisioning completes — not per frame.
6703
6696
  */
6704
6697
  var RUNTIME_INFO_REFRESH_MS = 3e5;
6698
+ /** Sentinel for "no api" so it is one transition, not a per-refresh line. */
6699
+ var NO_API_FAILURE = "no addon api";
6705
6700
  /** How often a camera reports parcels that left without a release (D422). */
6706
6701
  var PARKED_EVICTION_LOG_INTERVAL_MS = 6e4;
6707
6702
  /**
@@ -7139,6 +7134,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
7139
7134
  nodeId = "unknown";
7140
7135
  metricsSnapshotTimer = null;
7141
7136
  runtimeInfoTimer = null;
7137
+ /** Last runtime-label failure, so only a CHANGE is logged (D463). */
7138
+ lastRuntimeInfoFailure = null;
7142
7139
  unsubMotionEvents = null;
7143
7140
  /** Devices we have positively observed reporting `battery.sleeping: true`.
7144
7141
  * Absent = unknown or awake; both mean "do not gate" (D49). Fed only by
@@ -8823,28 +8820,47 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
8823
8820
  */
8824
8821
  async refreshRuntimeInfo() {
8825
8822
  const api = this.ctxIfReady?.api;
8826
- if (!api) return;
8827
- let engine = null;
8828
- let steps = null;
8829
- try {
8830
- engine = await api.pipelineExecutor.getSelectedEngine.query();
8831
- } catch (err) {
8832
- this.ctxIfReady?.logger.debug("runtime labels: selected engine unreadable", { meta: { error: require_dist.errMsg(err) } });
8823
+ const sampler = this.runner?.timingSampler;
8824
+ if (!api || sampler === void 0 || sampler === null) {
8825
+ this.noteRuntimeInfoFailure(NO_API_FAILURE);
8826
+ return;
8833
8827
  }
8828
+ let engines = null;
8829
+ let failure = null;
8834
8830
  try {
8835
- const active = await api.pipelineExecutor.getGlobalSteps.query();
8836
- steps = active === null ? null : flattenLabelledSteps(active);
8831
+ engines = (await api.pipelineExecutor.listLoadedEngines.query()).map((r) => ({
8832
+ backend: r.engine.backend,
8833
+ ...r.engine.device !== void 0 ? { device: r.engine.device } : {},
8834
+ kind: r.kind,
8835
+ modelsLoaded: r.modelsLoaded
8836
+ }));
8837
8837
  } catch (err) {
8838
- this.ctxIfReady?.logger.debug("runtime labels: active steps unreadable", { meta: { error: require_dist.errMsg(err) } });
8838
+ failure = require_dist.errMsg(err);
8839
8839
  }
8840
- const sampler = this.runner?.timingSampler;
8841
- if (sampler === void 0 || sampler === null) return;
8840
+ this.noteRuntimeInfoFailure(failure);
8842
8841
  sampler.runtimeInfo = {
8843
8842
  ...sampler.runtimeInfo,
8844
- ...buildRuntimeLabels(engine, steps)
8843
+ ...buildRuntimeLabels(engines)
8845
8844
  };
8846
8845
  }
8847
8846
  /**
8847
+ * Log a runtime-label failure at its TRANSITION, and at `warn`.
8848
+ *
8849
+ * This branch first shipped at `debug`, on the argument that the absent field
8850
+ * in the line was signal enough. It was not: within the hour `little-unraid`
8851
+ * was the one node still logging `pipelineRuntime=null` and there was no way
8852
+ * to tell "this node has no engine" from "the read threw", because the hub
8853
+ * writes `info` and the agent forwards to it. That is D438's blind
8854
+ * instrument for the third time. Logging the transition rather than every
8855
+ * refresh is what keeps it from becoming a storm.
8856
+ */
8857
+ noteRuntimeInfoFailure(failure) {
8858
+ if (failure === this.lastRuntimeInfoFailure) return;
8859
+ if (failure === null) this.ctxIfReady?.logger.info("runtime labels: readable again");
8860
+ else this.ctxIfReady?.logger.warn("runtime labels unreadable — this node cannot name its engines", { meta: { error: failure } });
8861
+ this.lastRuntimeInfoFailure = failure;
8862
+ }
8863
+ /**
8848
8864
  * The model format this node dispatches on when no `deviceKey` is given —
8849
8865
  * its selected engine's format. `null` on any failure, which the caller
8850
8866
  * turns into a refusal rather than a guess: a model pin that cannot be
@@ -6499,41 +6499,34 @@ function isUsable(bbox) {
6499
6499
  //#endregion
6500
6500
  //#region src/pipeline-runner/runtime-labels.ts
6501
6501
  /**
6502
- * `null` for either input means "could not be read", and the corresponding
6503
- * field is OMITTED. The two are independent: an engine that failed to answer
6504
- * must not also erase the models, which is how the original field managed to
6505
- * be null on every line ever logged.
6506
- */
6507
- function buildRuntimeLabels(engine, steps) {
6508
- const runtime = engine === null ? void 0 : [
6509
- engine.runtime,
6510
- engine.backend,
6511
- engine.device
6512
- ].filter((part) => part !== void 0 && part !== "").join("/");
6513
- const models = steps === null ? void 0 : [...new Set(steps.filter((s) => s.enabled).map((s) => s.modelId))];
6502
+ * `null` means the read FAILED; `[]` means nothing is resident. Both omit the
6503
+ * fields, but they are different facts and the caller logs the failure.
6504
+ *
6505
+ * The `runtime` pool is named first because it is the one serving cameras; the
6506
+ * rest follow in arrival order. `python` is deliberately NOT printed: every
6507
+ * engine available on this cluster is python, and a token that cannot vary
6508
+ * costs a column and tells the reader nothing.
6509
+ */
6510
+ function buildRuntimeLabels(engines) {
6511
+ if (engines === null || engines.length === 0) return {};
6512
+ const ordered = [...engines.filter((e) => e.kind === "runtime"), ...engines.filter((e) => e.kind !== "runtime")];
6513
+ const devices = [...new Set(ordered.map((e) => e.device).filter((d) => !!d))];
6514
+ const backends = [...new Set(ordered.map((e) => e.backend))];
6515
+ const backend = backends.length === 1 ? backends[0] : "mixed";
6516
+ const runtime = devices.length === 0 ? backend : `${backend}/${devices.join("+")}`;
6517
+ const primary = ordered[0];
6518
+ if (primary === void 0) return {};
6519
+ const models = [];
6520
+ for (const engine of ordered) for (const model of engine.modelsLoaded) {
6521
+ if (models.includes(model)) continue;
6522
+ if (engine === primary || primary.modelsLoaded.includes(model)) models.push(model);
6523
+ else models.push(engine.device === void 0 ? model : `${model}@${engine.device}`);
6524
+ }
6514
6525
  return {
6515
- ...runtime !== void 0 ? { pipelineRuntime: runtime } : {},
6516
- ...models !== void 0 ? { pipelineModels: models } : {}
6526
+ pipelineRuntime: runtime,
6527
+ pipelineModels: models
6517
6528
  };
6518
6529
  }
6519
- /**
6520
- * Depth-first flatten of the active pipeline tree.
6521
- *
6522
- * A DISABLED step takes its subtree with it: the children of a step that does
6523
- * not run did not run either, and listing a face model under a disabled face
6524
- * detector would name a model that never loaded.
6525
- */
6526
- function flattenLabelledSteps(steps) {
6527
- const out = [];
6528
- for (const step of steps) {
6529
- out.push({
6530
- modelId: step.modelId,
6531
- enabled: step.enabled
6532
- });
6533
- if (step.enabled && step.children !== void 0) out.push(...flattenLabelledSteps(step.children));
6534
- }
6535
- return out;
6536
- }
6537
6530
  //#endregion
6538
6531
  //#region src/pipeline-runner/track-frame-park.ts
6539
6532
  /** Park one frame for one track. Never throws; a refusal is an answer. */
@@ -6694,6 +6687,8 @@ var TILE_CUT_DROP_LOG_INTERVAL_MS = 6e4;
6694
6687
  * operator re-selects one or lazy provisioning completes — not per frame.
6695
6688
  */
6696
6689
  var RUNTIME_INFO_REFRESH_MS = 3e5;
6690
+ /** Sentinel for "no api" so it is one transition, not a per-refresh line. */
6691
+ var NO_API_FAILURE = "no addon api";
6697
6692
  /** How often a camera reports parcels that left without a release (D422). */
6698
6693
  var PARKED_EVICTION_LOG_INTERVAL_MS = 6e4;
6699
6694
  /**
@@ -7131,6 +7126,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
7131
7126
  nodeId = "unknown";
7132
7127
  metricsSnapshotTimer = null;
7133
7128
  runtimeInfoTimer = null;
7129
+ /** Last runtime-label failure, so only a CHANGE is logged (D463). */
7130
+ lastRuntimeInfoFailure = null;
7134
7131
  unsubMotionEvents = null;
7135
7132
  /** Devices we have positively observed reporting `battery.sleeping: true`.
7136
7133
  * Absent = unknown or awake; both mean "do not gate" (D49). Fed only by
@@ -8815,28 +8812,47 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
8815
8812
  */
8816
8813
  async refreshRuntimeInfo() {
8817
8814
  const api = this.ctxIfReady?.api;
8818
- if (!api) return;
8819
- let engine = null;
8820
- let steps = null;
8821
- try {
8822
- engine = await api.pipelineExecutor.getSelectedEngine.query();
8823
- } catch (err) {
8824
- this.ctxIfReady?.logger.debug("runtime labels: selected engine unreadable", { meta: { error: errMsg(err) } });
8815
+ const sampler = this.runner?.timingSampler;
8816
+ if (!api || sampler === void 0 || sampler === null) {
8817
+ this.noteRuntimeInfoFailure(NO_API_FAILURE);
8818
+ return;
8825
8819
  }
8820
+ let engines = null;
8821
+ let failure = null;
8826
8822
  try {
8827
- const active = await api.pipelineExecutor.getGlobalSteps.query();
8828
- steps = active === null ? null : flattenLabelledSteps(active);
8823
+ engines = (await api.pipelineExecutor.listLoadedEngines.query()).map((r) => ({
8824
+ backend: r.engine.backend,
8825
+ ...r.engine.device !== void 0 ? { device: r.engine.device } : {},
8826
+ kind: r.kind,
8827
+ modelsLoaded: r.modelsLoaded
8828
+ }));
8829
8829
  } catch (err) {
8830
- this.ctxIfReady?.logger.debug("runtime labels: active steps unreadable", { meta: { error: errMsg(err) } });
8830
+ failure = errMsg(err);
8831
8831
  }
8832
- const sampler = this.runner?.timingSampler;
8833
- if (sampler === void 0 || sampler === null) return;
8832
+ this.noteRuntimeInfoFailure(failure);
8834
8833
  sampler.runtimeInfo = {
8835
8834
  ...sampler.runtimeInfo,
8836
- ...buildRuntimeLabels(engine, steps)
8835
+ ...buildRuntimeLabels(engines)
8837
8836
  };
8838
8837
  }
8839
8838
  /**
8839
+ * Log a runtime-label failure at its TRANSITION, and at `warn`.
8840
+ *
8841
+ * This branch first shipped at `debug`, on the argument that the absent field
8842
+ * in the line was signal enough. It was not: within the hour `little-unraid`
8843
+ * was the one node still logging `pipelineRuntime=null` and there was no way
8844
+ * to tell "this node has no engine" from "the read threw", because the hub
8845
+ * writes `info` and the agent forwards to it. That is D438's blind
8846
+ * instrument for the third time. Logging the transition rather than every
8847
+ * refresh is what keeps it from becoming a storm.
8848
+ */
8849
+ noteRuntimeInfoFailure(failure) {
8850
+ if (failure === this.lastRuntimeInfoFailure) return;
8851
+ if (failure === null) this.ctxIfReady?.logger.info("runtime labels: readable again");
8852
+ else this.ctxIfReady?.logger.warn("runtime labels unreadable — this node cannot name its engines", { meta: { error: failure } });
8853
+ this.lastRuntimeInfoFailure = failure;
8854
+ }
8855
+ /**
8840
8856
  * The model format this node dispatches on when no `deviceKey` is given —
8841
8857
  * its selected engine's format. `null` on any failure, which the caller
8842
8858
  * turns into a refusal rather than a guess: a model pin that cannot be
@@ -7777,6 +7777,7 @@ function buildRecordingProvider(deps) {
7777
7777
  },
7778
7778
  setDeviceConfig: async ({ deviceId, config }) => {
7779
7779
  const saved = await saveDeviceConfig(deps.configStore, deviceId, config);
7780
+ deps.onConfigSaved?.(deviceId, saved);
7780
7781
  if (deps.onConfigChanged) try {
7781
7782
  await deps.onConfigChanged(deviceId);
7782
7783
  } catch (err) {
@@ -10963,29 +10964,75 @@ Object.freeze({
10963
10964
  "turn-provider": ["getTurnServers"],
10964
10965
  "user-passkeys": ["listPasskeys"]
10965
10966
  });
10966
- //#endregion
10967
- //#region src/recorder/addon/last-known-config.ts
10968
10967
  /**
10969
- * Wrap `load` so a failed read serves the last value that succeeded.
10968
+ * Build the recorder's config mirror.
10970
10969
  *
10971
- * Returns a drop-in replacement for {@link LoadDeviceConfigFn}: every existing
10972
- * call site gains the fallback without changing, which is the point — the
10973
- * trigger post-buffer was the path that got CAUGHT writing a wrong number, but
10974
- * it is one of five reads and the other four fail the same way.
10970
+ * `load` is the fallible store read (`loadDeviceConfig(configStore, id)`), and
10971
+ * every existing call site keeps its signature: the controller is handed
10972
+ * {@link RecordingConfigMirror.read} and cannot tell the difference, which is
10973
+ * the point — the trigger post-buffer is the path that got CAUGHT writing a
10974
+ * wrong number, but it is one of six reads and the other five are the volume.
10975
10975
  */
10976
- function withLastKnownConfig(deps) {
10976
+ function createRecordingConfigMirror(deps) {
10977
10977
  const now = deps.now ?? (() => Date.now());
10978
- const cache = /* @__PURE__ */ new Map();
10979
- return async (deviceId) => {
10978
+ const freshMs = deps.freshMs ?? 3e4;
10979
+ const reportIntervalMs = deps.reportIntervalMs ?? 6e4;
10980
+ const mirror = /* @__PURE__ */ new Map();
10981
+ const window = {
10982
+ served: 0,
10983
+ storeReads: 0,
10984
+ fallbacks: 0,
10985
+ startedAt: now()
10986
+ };
10987
+ /**
10988
+ * One line per window, fleet-level (no `deviceId`: this aggregates every
10989
+ * camera, and the per-camera lines are the fallback WARNs above).
10990
+ *
10991
+ * It exists because a branch that ACCEPTS work and produces no store traffic
10992
+ * is indistinguishable from a branch nobody calls: without this line, a
10993
+ * mirror that silently stopped mirroring and a mirror doing its job both look
10994
+ * like silence, and the only visible difference is 1 800 calls a minute on a
10995
+ * runner nobody is watching.
10996
+ */
10997
+ const reportIfDue = (atMs) => {
10998
+ const windowMs = atMs - window.startedAt;
10999
+ if (windowMs < reportIntervalMs) return;
11000
+ const calls = window.served + window.storeReads;
11001
+ window.startedAt = atMs;
11002
+ const served = window.served;
11003
+ const storeReads = window.storeReads;
11004
+ const fallbacks = window.fallbacks;
11005
+ window.served = 0;
11006
+ window.storeReads = 0;
11007
+ window.fallbacks = 0;
11008
+ if (calls === 0) return;
11009
+ deps.logger.info("recorder: config mirror window", { meta: {
11010
+ served,
11011
+ storeReads,
11012
+ fallbacks,
11013
+ devices: mirror.size,
11014
+ windowMs,
11015
+ freshMs
11016
+ } });
11017
+ };
11018
+ const read = async (deviceId) => {
11019
+ const atMs = now();
11020
+ reportIfDue(atMs);
11021
+ const held = mirror.get(deviceId);
11022
+ if (held !== void 0 && atMs - held.atMs < freshMs) {
11023
+ window.served += 1;
11024
+ return held.config;
11025
+ }
11026
+ window.storeReads += 1;
10980
11027
  try {
10981
11028
  const config = await deps.load(deviceId);
10982
- cache.set(deviceId, {
11029
+ mirror.set(deviceId, {
10983
11030
  config,
10984
11031
  atMs: now()
10985
11032
  });
10986
11033
  return config;
10987
11034
  } catch (err) {
10988
- const cached = cache.get(deviceId);
11035
+ const cached = mirror.get(deviceId);
10989
11036
  if (cached === void 0) {
10990
11037
  deps.logger.warn("recorder: config read failed and NOTHING is cached for this camera — no last-known-good to serve", {
10991
11038
  tags: { deviceId },
@@ -10993,17 +11040,29 @@ function withLastKnownConfig(deps) {
10993
11040
  });
10994
11041
  throw err;
10995
11042
  }
10996
- const ageMs = now() - cached.atMs;
11043
+ window.fallbacks += 1;
10997
11044
  deps.logger.warn("recorder: config read failed — serving the LAST-KNOWN-GOOD config (not the shipped default)", {
10998
11045
  tags: { deviceId },
10999
11046
  meta: {
11000
- ageMs,
11047
+ ageMs: now() - cached.atMs,
11001
11048
  error: require_dist.errMsg(err)
11002
11049
  }
11003
11050
  });
11004
11051
  return cached.config;
11005
11052
  }
11006
11053
  };
11054
+ return {
11055
+ read,
11056
+ put(deviceId, config) {
11057
+ mirror.set(deviceId, {
11058
+ config,
11059
+ atMs: now()
11060
+ });
11061
+ },
11062
+ invalidate(deviceId) {
11063
+ mirror.delete(deviceId);
11064
+ }
11065
+ };
11007
11066
  }
11008
11067
  //#endregion
11009
11068
  //#region src/recorder/addon/ledger-walk-reconcile.ts
@@ -17459,21 +17518,22 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17459
17518
  /** Continuous + events band write-path controller (ffmpeg writers + watchers). */
17460
17519
  controller = null;
17461
17520
  /**
17462
- * The controller's config read, with last-known-good fallback.
17521
+ * The controller's config read — this addon's mirror of the rows it owns
17522
+ * (`config-mirror.ts`, D466), with the last-known-good fallback.
17463
17523
  *
17464
- * Built ONCE and memoized, because the cache lives INSIDE the wrapper:
17465
- * rebuilding it per call would throw away the very value the fallback exists
17466
- * to serve. Lazy rather than a field initializer — a field initializer runs
17467
- * before the constructor assigns `this.ctx`, so `this.ctx.logger` would be
17468
- * `undefined` at that point.
17524
+ * Built ONCE and memoized, because the map lives INSIDE it: rebuilding it per
17525
+ * call would throw away both the freshness the trigger path is served from
17526
+ * and the last value a failed read falls back to. Lazy rather than a field
17527
+ * initializer — a field initializer runs before the constructor assigns
17528
+ * `this.ctx`, so `this.ctx.logger` would be `undefined` at that point.
17469
17529
  */
17470
- lastKnownGoodRead = null;
17471
- get loadConfigLastKnownGood() {
17472
- this.lastKnownGoodRead ??= withLastKnownConfig({
17530
+ mirroredConfigRead = null;
17531
+ get configMirror() {
17532
+ this.mirroredConfigRead ??= createRecordingConfigMirror({
17473
17533
  load: (deviceId) => loadDeviceConfig(this.configStore(), deviceId),
17474
17534
  logger: this.ctx.logger
17475
17535
  });
17476
- return this.lastKnownGoodRead;
17536
+ return this.mirroredConfigRead;
17477
17537
  }
17478
17538
  /** Unsubscribe handle for the EventCapture motion subscription. */
17479
17539
  eventUnsub = null;
@@ -17721,7 +17781,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17721
17781
  nodeId: this.nodeId,
17722
17782
  ownerNodeId: ingestOwner.ownerNodeId,
17723
17783
  segmentStore: this.segmentStore,
17724
- loadConfig: this.loadConfigLastKnownGood,
17784
+ loadConfig: this.configMirror.read,
17725
17785
  enabledDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
17726
17786
  /**
17727
17787
  * The DURABLE half of the trigger path (D8/D11). `pipelineAnalytics` rows
@@ -17860,6 +17920,9 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17860
17920
  calendar: this.calendar,
17861
17921
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
17862
17922
  hydrateWindow: (deviceId, fromMs, toMs, verifyClosedHours) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0, { verifyClosedHours: verifyClosedHours === true }),
17923
+ onConfigSaved: (deviceId, config) => {
17924
+ this.configMirror.put(deviceId, config);
17925
+ },
17863
17926
  onConfigChanged: async (deviceId) => {
17864
17927
  await this.refreshSensorTriggerBindings();
17865
17928
  await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());