@camstack/addon-pipeline 1.2.135 → 1.2.136
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/recorder/index.js +346 -31
- package/dist/recorder/index.mjs +346 -31
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C14Qqrqn.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CLtMmF1E.mjs} +3 -3
- package/dist/stream-broker/{hostInit-kjsveCjG.mjs → hostInit-CP7Xhd8J.mjs} +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +5 -3
package/dist/recorder/index.js
CHANGED
|
@@ -8734,6 +8734,38 @@ var RESTART_MAX_MS = 1e4;
|
|
|
8734
8734
|
/** A process that ran at least this long is "stable" → reset the restart count,
|
|
8735
8735
|
* so sporadic blips over a long lifetime never accumulate into a give-up. */
|
|
8736
8736
|
var STABLE_RUN_MS = 3e4;
|
|
8737
|
+
/**
|
|
8738
|
+
* The window the SECOND budget counts over — and it counts EVERY restart,
|
|
8739
|
+
* whatever that run lasted.
|
|
8740
|
+
*
|
|
8741
|
+
* `STABLE_RUN_MS` above is a reset, and a reset is a blind spot: measured on
|
|
8742
|
+
* 2026-08-27, `SegmentWriter restarting` fired **1 814 times in 12 h** and
|
|
8743
|
+
* almost every `ranMs` was over 30 s (122 620 / 165 255 / 345 420 / 394 522 ms
|
|
8744
|
+
* are four consecutive real samples), so `attempt` never left 1, `onGaveUp`
|
|
8745
|
+
* never fired, and `recoverWriterGaveUp` never ran. The storm could have lasted
|
|
8746
|
+
* forever without a single line saying anything was wrong.
|
|
8747
|
+
*
|
|
8748
|
+
* A window cannot be zeroed by a long run, which is the whole point. Modelled
|
|
8749
|
+
* on `CrashSupervisor` (kernel, D6): timestamps age out of `windowMs`, and the
|
|
8750
|
+
* count inside it is the budget.
|
|
8751
|
+
*/
|
|
8752
|
+
var RESTART_WINDOW_MS = 60 * 6e4;
|
|
8753
|
+
/**
|
|
8754
|
+
* Restarts inside {@link RESTART_WINDOW_MS} before the writer stops respawning
|
|
8755
|
+
* and escalates to the controller.
|
|
8756
|
+
*
|
|
8757
|
+
* 15/hour, from the measured distribution. Per writer over the same 12 h:
|
|
8758
|
+
* 1441/high 549 (**45.8/h**), 615/high 241 (20.1/h), 618/high 217 (18.1/h),
|
|
8759
|
+
* 592/high 211 (17.6/h), 590/high 195 (16.3/h) — every one of them a storm and
|
|
8760
|
+
* every one of them silent — against `low` profiles at 4–15 per 12 h (≤1.3/h),
|
|
8761
|
+
* which is what a healthy writer looks like. 15 separates them with room for a
|
|
8762
|
+
* camera that blips hourly and is otherwise fine.
|
|
8763
|
+
*
|
|
8764
|
+
* It never shadows {@link MAX_RESTARTS}: 10 CONSECUTIVE rapid failures happen
|
|
8765
|
+
* inside ~90 s, so the rapid breaker still trips first on a hard failure. This
|
|
8766
|
+
* one exists for the slow storm the rapid breaker structurally cannot see.
|
|
8767
|
+
*/
|
|
8768
|
+
var MAX_RESTARTS_IN_WINDOW = 15;
|
|
8737
8769
|
/** Grace between the SIGTERM in stop() and the escalated SIGKILL. */
|
|
8738
8770
|
var KILL_GRACE_MS = 500;
|
|
8739
8771
|
/** Supervises one passthrough ffmpeg for a single (camera, profile). */
|
|
@@ -8743,6 +8775,12 @@ var SegmentWriter = class {
|
|
|
8743
8775
|
proc = null;
|
|
8744
8776
|
stopped = false;
|
|
8745
8777
|
restarts = 0;
|
|
8778
|
+
/**
|
|
8779
|
+
* Epoch-ms of every termination still inside {@link RESTART_WINDOW_MS}.
|
|
8780
|
+
* Replaced, never mutated in place, so a reader can never observe a
|
|
8781
|
+
* half-pruned window.
|
|
8782
|
+
*/
|
|
8783
|
+
restartWindow = [];
|
|
8746
8784
|
restartTimer = null;
|
|
8747
8785
|
startedAt = 0;
|
|
8748
8786
|
exitWait = null;
|
|
@@ -8776,10 +8814,11 @@ var SegmentWriter = class {
|
|
|
8776
8814
|
settled = true;
|
|
8777
8815
|
this.onTermination(termination, stderrTail);
|
|
8778
8816
|
};
|
|
8779
|
-
proc.on("exit", (code) => {
|
|
8817
|
+
proc.on("exit", (code, signal) => {
|
|
8780
8818
|
terminate({
|
|
8781
8819
|
reason: "exit",
|
|
8782
|
-
exitCode: typeof code === "number" ? code : null
|
|
8820
|
+
exitCode: typeof code === "number" ? code : null,
|
|
8821
|
+
signal: typeof signal === "string" ? signal : null
|
|
8783
8822
|
});
|
|
8784
8823
|
});
|
|
8785
8824
|
proc.on("error", (raw) => {
|
|
@@ -8795,32 +8834,51 @@ var SegmentWriter = class {
|
|
|
8795
8834
|
/**
|
|
8796
8835
|
* The ONE restart policy, reached from both `exit` and `error`.
|
|
8797
8836
|
*
|
|
8798
|
-
* `
|
|
8799
|
-
*
|
|
8800
|
-
*
|
|
8801
|
-
* camera, and a writer that stops recording one camera must be greppable by
|
|
8802
|
-
* that camera.
|
|
8837
|
+
* Every line below carries `tags: { deviceId }` from `deps.deviceId` — "why
|
|
8838
|
+
* is 617 worse than 615" is always asked per camera, and a writer that stops
|
|
8839
|
+
* recording one camera must be greppable by that camera.
|
|
8803
8840
|
*/
|
|
8804
8841
|
onTermination(termination, stderrTail) {
|
|
8805
8842
|
this.resolveExit?.();
|
|
8806
8843
|
this.resolveExit = null;
|
|
8807
8844
|
this.proc = null;
|
|
8808
8845
|
if (this.stopped) return;
|
|
8809
|
-
const
|
|
8846
|
+
const now = Date.now();
|
|
8847
|
+
const ranMs = now - this.startedAt;
|
|
8810
8848
|
if (ranMs >= STABLE_RUN_MS) this.restarts = 0;
|
|
8811
|
-
|
|
8812
|
-
|
|
8849
|
+
this.restartWindow = [...this.restartWindow.filter((at) => at > now - RESTART_WINDOW_MS), now];
|
|
8850
|
+
const restartsInWindow = this.restartWindow.length;
|
|
8851
|
+
if (termination.reason === "exit") this.deps.logger.warn("SegmentWriter ffmpeg exited", {
|
|
8852
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8853
|
+
meta: {
|
|
8813
8854
|
outDir: this.cfg.outDir,
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
8855
|
+
exitCode: termination.exitCode,
|
|
8856
|
+
signal: termination.signal,
|
|
8857
|
+
ranMs,
|
|
8858
|
+
restartsInWindow,
|
|
8859
|
+
windowMs: RESTART_WINDOW_MS,
|
|
8860
|
+
stderrTail: stderrTail.join(" | ")
|
|
8861
|
+
}
|
|
8862
|
+
});
|
|
8863
|
+
if (termination.reason === "spawn-error") {
|
|
8864
|
+
this.deps.logger.warn("SegmentWriter ffmpeg spawn failed", {
|
|
8865
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8866
|
+
meta: {
|
|
8820
8867
|
outDir: this.cfg.outDir,
|
|
8821
8868
|
errorCode: termination.errorCode,
|
|
8822
|
-
error: termination.message
|
|
8823
|
-
|
|
8869
|
+
error: termination.message,
|
|
8870
|
+
permanent: termination.permanent
|
|
8871
|
+
}
|
|
8872
|
+
});
|
|
8873
|
+
if (termination.permanent) {
|
|
8874
|
+
this.deps.logger.error("SegmentWriter giving up: ffmpeg cannot be executed on this node", {
|
|
8875
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8876
|
+
meta: {
|
|
8877
|
+
outDir: this.cfg.outDir,
|
|
8878
|
+
errorCode: termination.errorCode,
|
|
8879
|
+
error: termination.message
|
|
8880
|
+
}
|
|
8881
|
+
});
|
|
8824
8882
|
this.stopped = true;
|
|
8825
8883
|
this.deps.onGaveUp?.();
|
|
8826
8884
|
return;
|
|
@@ -8828,25 +8886,51 @@ var SegmentWriter = class {
|
|
|
8828
8886
|
this.deps.onResourcePressure?.();
|
|
8829
8887
|
}
|
|
8830
8888
|
if (this.restarts >= MAX_RESTARTS) {
|
|
8831
|
-
this.deps.logger.warn("SegmentWriter giving up after max restarts", {
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
8889
|
+
this.deps.logger.warn("SegmentWriter giving up after max restarts", {
|
|
8890
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8891
|
+
meta: {
|
|
8892
|
+
outDir: this.cfg.outDir,
|
|
8893
|
+
reason: termination.reason,
|
|
8894
|
+
code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
|
|
8895
|
+
stderrTail: stderrTail.join(" | ")
|
|
8896
|
+
}
|
|
8897
|
+
});
|
|
8898
|
+
this.stopped = true;
|
|
8899
|
+
this.deps.onGaveUp?.();
|
|
8900
|
+
return;
|
|
8901
|
+
}
|
|
8902
|
+
if (restartsInWindow >= MAX_RESTARTS_IN_WINDOW) {
|
|
8903
|
+
this.deps.logger.warn("SegmentWriter giving up: restart storm", {
|
|
8904
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8905
|
+
meta: {
|
|
8906
|
+
outDir: this.cfg.outDir,
|
|
8907
|
+
restartsInWindow,
|
|
8908
|
+
windowMs: RESTART_WINDOW_MS,
|
|
8909
|
+
budget: MAX_RESTARTS_IN_WINDOW,
|
|
8910
|
+
attempt: this.restarts,
|
|
8911
|
+
ranMs,
|
|
8912
|
+
reason: termination.reason,
|
|
8913
|
+
code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
|
|
8914
|
+
stderrTail: stderrTail.join(" | ")
|
|
8915
|
+
}
|
|
8916
|
+
});
|
|
8837
8917
|
this.stopped = true;
|
|
8838
8918
|
this.deps.onGaveUp?.();
|
|
8839
8919
|
return;
|
|
8840
8920
|
}
|
|
8841
8921
|
this.restarts++;
|
|
8842
8922
|
const delayMs = Math.min(RESTART_BASE_MS * 2 ** (this.restarts - 1), RESTART_MAX_MS);
|
|
8843
|
-
this.deps.logger.info("SegmentWriter restarting", {
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
|
|
8848
|
-
|
|
8849
|
-
|
|
8923
|
+
this.deps.logger.info("SegmentWriter restarting", {
|
|
8924
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8925
|
+
meta: {
|
|
8926
|
+
outDir: this.cfg.outDir,
|
|
8927
|
+
attempt: this.restarts,
|
|
8928
|
+
restartsInWindow,
|
|
8929
|
+
delayMs,
|
|
8930
|
+
ranMs,
|
|
8931
|
+
reason: termination.reason
|
|
8932
|
+
}
|
|
8933
|
+
});
|
|
8850
8934
|
this.restartTimer = setTimeout(() => {
|
|
8851
8935
|
this.restartTimer = null;
|
|
8852
8936
|
this.start();
|
|
@@ -10110,6 +10194,7 @@ var RecordingController = class {
|
|
|
10110
10194
|
}, {
|
|
10111
10195
|
spawn: this.deps.spawn,
|
|
10112
10196
|
logger: deviceLog,
|
|
10197
|
+
deviceId,
|
|
10113
10198
|
onGaveUp: () => {
|
|
10114
10199
|
this.recoverWriterGaveUp(deviceId, profile);
|
|
10115
10200
|
},
|
|
@@ -10533,6 +10618,209 @@ function buildRecordingExportProvider(deps) {
|
|
|
10533
10618
|
*/
|
|
10534
10619
|
var resolveRecordingHubHostname = require_hub_hostname.resolveHubHostname;
|
|
10535
10620
|
//#endregion
|
|
10621
|
+
//#region src/recorder/redundant-segments.ts
|
|
10622
|
+
/**
|
|
10623
|
+
* Return the indices (into `segs`) of segments safe to delete: those fully
|
|
10624
|
+
* contained within a kept segment `[coverStart, coverEnd]`. Sorting by start
|
|
10625
|
+
* ascending, then by end DESCENDING, guarantees the containing (longer) segment
|
|
10626
|
+
* is seen first and kept, and any shorter segment nested inside it is removed.
|
|
10627
|
+
*/
|
|
10628
|
+
function selectRedundantSegments(segs) {
|
|
10629
|
+
if (segs.length < 2) return [];
|
|
10630
|
+
const order = segs.map((s, i) => ({
|
|
10631
|
+
start: s.startMs,
|
|
10632
|
+
end: s.startMs + s.durMs,
|
|
10633
|
+
i
|
|
10634
|
+
})).toSorted((a, b) => a.start - b.start || b.end - a.end);
|
|
10635
|
+
const remove = [];
|
|
10636
|
+
let coverStart = Number.NEGATIVE_INFINITY;
|
|
10637
|
+
let coverEnd = Number.NEGATIVE_INFINITY;
|
|
10638
|
+
for (const seg of order) if (seg.start >= coverStart && seg.end <= coverEnd && seg.end > seg.start) remove.push(seg.i);
|
|
10639
|
+
else {
|
|
10640
|
+
coverStart = seg.start;
|
|
10641
|
+
coverEnd = seg.end;
|
|
10642
|
+
}
|
|
10643
|
+
return remove;
|
|
10644
|
+
}
|
|
10645
|
+
//#endregion
|
|
10646
|
+
//#region src/recorder/addon/redundancy-sweep.ts
|
|
10647
|
+
/**
|
|
10648
|
+
* Periodic redundancy sweep — the only thing that removes duplicated footage.
|
|
10649
|
+
*
|
|
10650
|
+
* WHY IT EXISTS AGAIN
|
|
10651
|
+
* ───────────────────
|
|
10652
|
+
* Every `SegmentWriter` restart re-dials the broker with `withRecordingIntent`,
|
|
10653
|
+
* and the broker answers a RECORDING dial with its pre-roll ring (≈10 s). So a
|
|
10654
|
+
* restart does not resume at the live edge: it re-writes ~10 s of media that is
|
|
10655
|
+
* already on disk. At the 2026-08-27 measurement — **1 814 restarts in 12 h** —
|
|
10656
|
+
* that is ~5 hours of duplicated `high`-profile footage per day, on a fuse
|
|
10657
|
+
* share that was already at 90 % full.
|
|
10658
|
+
*
|
|
10659
|
+
* The pruner that used to remove it, `runRedundancyJanitor`, was reachable ONLY
|
|
10660
|
+
* from `runFullArchiveWalk`, which was reachable only from
|
|
10661
|
+
* `scheduleDeferredFullWalk`, which had no caller at all — so on 2026-08-27
|
|
10662
|
+
* (commit `9308428b4`) all three were deleted together as dead code. That was
|
|
10663
|
+
* correct about the walk and left the system with **no duplicate pruner
|
|
10664
|
+
* whatsoever**. The precedent for letting this class of waste run unattended is
|
|
10665
|
+
* the 179 GB of stranded staging segments in `staging-reconcile.ts`.
|
|
10666
|
+
*
|
|
10667
|
+
* WHAT IS DIFFERENT FROM THE ONE THAT WAS DELETED
|
|
10668
|
+
* ───────────────────────────────────────────────
|
|
10669
|
+
* The old janitor ran once, after a walk of the WHOLE archive, and asked the
|
|
10670
|
+
* in-RAM index for `segments(deviceId)` — a full copy plus a sort of every row
|
|
10671
|
+
* the device owns. At the live shape (7.1 M rows) that copy alone was measured
|
|
10672
|
+
* at ~560 MB of transient heap, which is precisely the cost D248 had just
|
|
10673
|
+
* finished removing from the pressure sweep. Reconnecting it in that shape
|
|
10674
|
+
* would have traded one regression for another.
|
|
10675
|
+
*
|
|
10676
|
+
* So this one is bounded by a LOOKBACK WINDOW, not by the archive: duplicates
|
|
10677
|
+
* are made by a restart, restarts are now, and `segmentsStartingIn` slices the
|
|
10678
|
+
* sorted view instead of copying it. Cost grows with footage RECORDED in the
|
|
10679
|
+
* window — the invariant the recorder is supposed to satisfy — never with rows
|
|
10680
|
+
* already indexed.
|
|
10681
|
+
*
|
|
10682
|
+
* WHAT IT WILL NOT DO
|
|
10683
|
+
* ───────────────────
|
|
10684
|
+
* It never deletes a segment that contributes unique coverage:
|
|
10685
|
+
* {@link selectRedundantSegments} selects only segments fully contained inside
|
|
10686
|
+
* another segment that is KEPT, and staggered partial overlaps are always kept.
|
|
10687
|
+
* The delete itself is `SegmentStore.evict` — the same audited path
|
|
10688
|
+
* disk-pressure eviction uses, so a removed segment is de-indexed exactly as an
|
|
10689
|
+
* evicted one is. A failure for one (device, profile, location) is logged and
|
|
10690
|
+
* never aborts the pass.
|
|
10691
|
+
*/
|
|
10692
|
+
/** The profiles a recorder writes. Same set `staging-reconcile` sweeps. */
|
|
10693
|
+
var SWEPT_PROFILES = [
|
|
10694
|
+
"high",
|
|
10695
|
+
"mid",
|
|
10696
|
+
"low"
|
|
10697
|
+
];
|
|
10698
|
+
/** Group rows by the location that owns them. `evict` is per-location. */
|
|
10699
|
+
function byLocation(rows) {
|
|
10700
|
+
const out = /* @__PURE__ */ new Map();
|
|
10701
|
+
for (const row of rows) {
|
|
10702
|
+
const found = out.get(row.locationId);
|
|
10703
|
+
if (found) found.push(row);
|
|
10704
|
+
else out.set(row.locationId, [row]);
|
|
10705
|
+
}
|
|
10706
|
+
return out;
|
|
10707
|
+
}
|
|
10708
|
+
/**
|
|
10709
|
+
* Owns its timer and a single-flight guard, the same shape the retention sweep
|
|
10710
|
+
* and the export janitor already use — a slow pass is never overlapped by the
|
|
10711
|
+
* next tick.
|
|
10712
|
+
*/
|
|
10713
|
+
var RedundancySweeper = class {
|
|
10714
|
+
deps;
|
|
10715
|
+
timer = null;
|
|
10716
|
+
sweeping = false;
|
|
10717
|
+
constructor(deps) {
|
|
10718
|
+
this.deps = deps;
|
|
10719
|
+
}
|
|
10720
|
+
/**
|
|
10721
|
+
* Arm the sweep on its interval. Idempotent.
|
|
10722
|
+
*
|
|
10723
|
+
* Deliberately NOT run immediately: boot is when the writers are attaching,
|
|
10724
|
+
* the staging reconcile is relocating thousands of files over shfs and the
|
|
10725
|
+
* first viewer is painting. A duplicate that has waited an hour can wait
|
|
10726
|
+
* another one; the write path cannot.
|
|
10727
|
+
*/
|
|
10728
|
+
start() {
|
|
10729
|
+
if (this.timer !== null) return;
|
|
10730
|
+
this.timer = setInterval(() => {
|
|
10731
|
+
this.sweep();
|
|
10732
|
+
}, this.deps.intervalMs);
|
|
10733
|
+
this.timer.unref?.();
|
|
10734
|
+
}
|
|
10735
|
+
/** Stop the timer. Idempotent. An in-flight pass still completes. */
|
|
10736
|
+
stop() {
|
|
10737
|
+
if (this.timer !== null) {
|
|
10738
|
+
clearInterval(this.timer);
|
|
10739
|
+
this.timer = null;
|
|
10740
|
+
}
|
|
10741
|
+
}
|
|
10742
|
+
/**
|
|
10743
|
+
* One pass. Never throws: a device-list read failure aborts THIS pass
|
|
10744
|
+
* (logged), a per-location evict failure is logged and the pass continues.
|
|
10745
|
+
*/
|
|
10746
|
+
async sweep() {
|
|
10747
|
+
const empty = {
|
|
10748
|
+
files: 0,
|
|
10749
|
+
bytes: 0,
|
|
10750
|
+
devices: 0
|
|
10751
|
+
};
|
|
10752
|
+
if (this.sweeping) {
|
|
10753
|
+
this.deps.logger.debug("recorder: redundancy sweep already running — skipping this tick");
|
|
10754
|
+
return empty;
|
|
10755
|
+
}
|
|
10756
|
+
this.sweeping = true;
|
|
10757
|
+
try {
|
|
10758
|
+
let deviceIds;
|
|
10759
|
+
try {
|
|
10760
|
+
deviceIds = await this.deps.deviceIds();
|
|
10761
|
+
} catch (err) {
|
|
10762
|
+
this.deps.logger.warn("recorder: redundancy sweep could not read the device list", { meta: { error: require_dist.errMsg(err) } });
|
|
10763
|
+
return empty;
|
|
10764
|
+
}
|
|
10765
|
+
const toMs = (this.deps.now ?? Date.now)();
|
|
10766
|
+
const fromMs = toMs - this.deps.lookbackMs;
|
|
10767
|
+
let files = 0;
|
|
10768
|
+
let bytes = 0;
|
|
10769
|
+
let devices = 0;
|
|
10770
|
+
for (const deviceId of deviceIds) {
|
|
10771
|
+
let removedHere = 0;
|
|
10772
|
+
for (const profile of SWEPT_PROFILES) {
|
|
10773
|
+
const rows = this.deps.segmentsInWindow(deviceId, profile, fromMs, toMs);
|
|
10774
|
+
const redundant = selectRedundantSegments(rows);
|
|
10775
|
+
if (redundant.length === 0) continue;
|
|
10776
|
+
const victims = redundant.map((i) => rows[i]).filter((row) => row !== void 0);
|
|
10777
|
+
for (const [locationId, group] of byLocation(victims)) try {
|
|
10778
|
+
const reclaimed = await this.deps.evict(locationId, group);
|
|
10779
|
+
files += group.length;
|
|
10780
|
+
bytes += reclaimed;
|
|
10781
|
+
removedHere += group.length;
|
|
10782
|
+
} catch (err) {
|
|
10783
|
+
this.deps.logger.warn("recorder: redundancy sweep could not evict duplicates", {
|
|
10784
|
+
tags: { deviceId },
|
|
10785
|
+
meta: {
|
|
10786
|
+
profile,
|
|
10787
|
+
locationId,
|
|
10788
|
+
count: group.length,
|
|
10789
|
+
error: require_dist.errMsg(err)
|
|
10790
|
+
}
|
|
10791
|
+
});
|
|
10792
|
+
}
|
|
10793
|
+
}
|
|
10794
|
+
if (removedHere > 0) {
|
|
10795
|
+
devices += 1;
|
|
10796
|
+
this.deps.logger.info("recorder: removed duplicated segments for device", {
|
|
10797
|
+
tags: { deviceId },
|
|
10798
|
+
meta: {
|
|
10799
|
+
files: removedHere,
|
|
10800
|
+
fromMs,
|
|
10801
|
+
toMs
|
|
10802
|
+
}
|
|
10803
|
+
});
|
|
10804
|
+
}
|
|
10805
|
+
}
|
|
10806
|
+
this.deps.logger.info("recorder: redundancy sweep complete", { meta: {
|
|
10807
|
+
devices,
|
|
10808
|
+
files,
|
|
10809
|
+
bytes,
|
|
10810
|
+
lookbackMs: this.deps.lookbackMs,
|
|
10811
|
+
deviceCount: deviceIds.length
|
|
10812
|
+
} });
|
|
10813
|
+
return {
|
|
10814
|
+
files,
|
|
10815
|
+
bytes,
|
|
10816
|
+
devices
|
|
10817
|
+
};
|
|
10818
|
+
} finally {
|
|
10819
|
+
this.sweeping = false;
|
|
10820
|
+
}
|
|
10821
|
+
}
|
|
10822
|
+
};
|
|
10823
|
+
//#endregion
|
|
10536
10824
|
//#region src/recorder/addon/retention-sweep.ts
|
|
10537
10825
|
/**
|
|
10538
10826
|
* Periodic footage-retention sweep (recording-spec §6).
|
|
@@ -12093,6 +12381,18 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
|
12093
12381
|
* hourly cadence keeps the storage.list/evict cost negligible while honouring
|
|
12094
12382
|
* the recording-spec §6 periodic retention. */
|
|
12095
12383
|
var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
12384
|
+
/** How often the redundancy sweep removes footage a writer restart duplicated. */
|
|
12385
|
+
var REDUNDANCY_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
12386
|
+
/**
|
|
12387
|
+
* How far back each redundancy pass looks.
|
|
12388
|
+
*
|
|
12389
|
+
* Twice the interval, so a pass that is skipped (single-flight) or delayed by a
|
|
12390
|
+
* slow predecessor still covers the window its predecessor was supposed to.
|
|
12391
|
+
* It is deliberately NOT "the whole archive": duplicates are made by a writer
|
|
12392
|
+
* restart, restarts are recent, and a full-archive selection is the ~560 MB
|
|
12393
|
+
* transient copy D248 removed. See `redundancy-sweep.ts`.
|
|
12394
|
+
*/
|
|
12395
|
+
var REDUNDANCY_SWEEP_LOOKBACK_MS = 2 * REDUNDANCY_SWEEP_INTERVAL_MS;
|
|
12096
12396
|
/** What the per-volume usage row reports when the hour ledger is unavailable:
|
|
12097
12397
|
* nothing measured, rather than a total counted off a partial RAM index. */
|
|
12098
12398
|
var EMPTY_ARCHIVE_ACCOUNTING = {
|
|
@@ -12245,6 +12545,9 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12245
12545
|
/** Periodic footage-retention sweep (owns its timer + single-flight guard).
|
|
12246
12546
|
* Constructed + started in `onHubReachable`, stopped on shutdown. */
|
|
12247
12547
|
retentionSweeper = null;
|
|
12548
|
+
/** Periodic duplicate-footage sweep — the ONLY pruner of media a writer
|
|
12549
|
+
* restart re-wrote. Constructed + started in `onHubReachable`. */
|
|
12550
|
+
redundancySweeper = null;
|
|
12248
12551
|
/** Reversible migration pause lease; never reflected in RecordingConfig. */
|
|
12249
12552
|
storageMigrationLeaseId = null;
|
|
12250
12553
|
/**
|
|
@@ -12733,6 +13036,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12733
13036
|
}
|
|
12734
13037
|
this.retentionSweeper?.stop();
|
|
12735
13038
|
this.retentionSweeper = null;
|
|
13039
|
+
this.redundancySweeper?.stop();
|
|
13040
|
+
this.redundancySweeper = null;
|
|
12736
13041
|
this.exportEngine = null;
|
|
12737
13042
|
this.recordingProvider = null;
|
|
12738
13043
|
}
|
|
@@ -12872,6 +13177,16 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12872
13177
|
intervalMs: RETENTION_SWEEP_INTERVAL_MS
|
|
12873
13178
|
});
|
|
12874
13179
|
this.retentionSweeper?.start();
|
|
13180
|
+
const store = this.segmentStore;
|
|
13181
|
+
if (store && this.redundancySweeper === null) this.redundancySweeper = new RedundancySweeper({
|
|
13182
|
+
deviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
|
|
13183
|
+
segmentsInWindow: (deviceId, profile, fromMs, toMs) => this.index.segmentsStartingIn(deviceId, profile, fromMs, toMs),
|
|
13184
|
+
evict: (locationId, rows) => store.evict(locationId, rows),
|
|
13185
|
+
logger: this.ctx.logger,
|
|
13186
|
+
intervalMs: REDUNDANCY_SWEEP_INTERVAL_MS,
|
|
13187
|
+
lookbackMs: REDUNDANCY_SWEEP_LOOKBACK_MS
|
|
13188
|
+
});
|
|
13189
|
+
this.redundancySweeper?.start();
|
|
12875
13190
|
}
|
|
12876
13191
|
/**
|
|
12877
13192
|
* Rebuild the sensor trigger's reverse index from the persisted configs.
|
package/dist/recorder/index.mjs
CHANGED
|
@@ -8732,6 +8732,38 @@ var RESTART_MAX_MS = 1e4;
|
|
|
8732
8732
|
/** A process that ran at least this long is "stable" → reset the restart count,
|
|
8733
8733
|
* so sporadic blips over a long lifetime never accumulate into a give-up. */
|
|
8734
8734
|
var STABLE_RUN_MS = 3e4;
|
|
8735
|
+
/**
|
|
8736
|
+
* The window the SECOND budget counts over — and it counts EVERY restart,
|
|
8737
|
+
* whatever that run lasted.
|
|
8738
|
+
*
|
|
8739
|
+
* `STABLE_RUN_MS` above is a reset, and a reset is a blind spot: measured on
|
|
8740
|
+
* 2026-08-27, `SegmentWriter restarting` fired **1 814 times in 12 h** and
|
|
8741
|
+
* almost every `ranMs` was over 30 s (122 620 / 165 255 / 345 420 / 394 522 ms
|
|
8742
|
+
* are four consecutive real samples), so `attempt` never left 1, `onGaveUp`
|
|
8743
|
+
* never fired, and `recoverWriterGaveUp` never ran. The storm could have lasted
|
|
8744
|
+
* forever without a single line saying anything was wrong.
|
|
8745
|
+
*
|
|
8746
|
+
* A window cannot be zeroed by a long run, which is the whole point. Modelled
|
|
8747
|
+
* on `CrashSupervisor` (kernel, D6): timestamps age out of `windowMs`, and the
|
|
8748
|
+
* count inside it is the budget.
|
|
8749
|
+
*/
|
|
8750
|
+
var RESTART_WINDOW_MS = 60 * 6e4;
|
|
8751
|
+
/**
|
|
8752
|
+
* Restarts inside {@link RESTART_WINDOW_MS} before the writer stops respawning
|
|
8753
|
+
* and escalates to the controller.
|
|
8754
|
+
*
|
|
8755
|
+
* 15/hour, from the measured distribution. Per writer over the same 12 h:
|
|
8756
|
+
* 1441/high 549 (**45.8/h**), 615/high 241 (20.1/h), 618/high 217 (18.1/h),
|
|
8757
|
+
* 592/high 211 (17.6/h), 590/high 195 (16.3/h) — every one of them a storm and
|
|
8758
|
+
* every one of them silent — against `low` profiles at 4–15 per 12 h (≤1.3/h),
|
|
8759
|
+
* which is what a healthy writer looks like. 15 separates them with room for a
|
|
8760
|
+
* camera that blips hourly and is otherwise fine.
|
|
8761
|
+
*
|
|
8762
|
+
* It never shadows {@link MAX_RESTARTS}: 10 CONSECUTIVE rapid failures happen
|
|
8763
|
+
* inside ~90 s, so the rapid breaker still trips first on a hard failure. This
|
|
8764
|
+
* one exists for the slow storm the rapid breaker structurally cannot see.
|
|
8765
|
+
*/
|
|
8766
|
+
var MAX_RESTARTS_IN_WINDOW = 15;
|
|
8735
8767
|
/** Grace between the SIGTERM in stop() and the escalated SIGKILL. */
|
|
8736
8768
|
var KILL_GRACE_MS = 500;
|
|
8737
8769
|
/** Supervises one passthrough ffmpeg for a single (camera, profile). */
|
|
@@ -8741,6 +8773,12 @@ var SegmentWriter = class {
|
|
|
8741
8773
|
proc = null;
|
|
8742
8774
|
stopped = false;
|
|
8743
8775
|
restarts = 0;
|
|
8776
|
+
/**
|
|
8777
|
+
* Epoch-ms of every termination still inside {@link RESTART_WINDOW_MS}.
|
|
8778
|
+
* Replaced, never mutated in place, so a reader can never observe a
|
|
8779
|
+
* half-pruned window.
|
|
8780
|
+
*/
|
|
8781
|
+
restartWindow = [];
|
|
8744
8782
|
restartTimer = null;
|
|
8745
8783
|
startedAt = 0;
|
|
8746
8784
|
exitWait = null;
|
|
@@ -8774,10 +8812,11 @@ var SegmentWriter = class {
|
|
|
8774
8812
|
settled = true;
|
|
8775
8813
|
this.onTermination(termination, stderrTail);
|
|
8776
8814
|
};
|
|
8777
|
-
proc.on("exit", (code) => {
|
|
8815
|
+
proc.on("exit", (code, signal) => {
|
|
8778
8816
|
terminate({
|
|
8779
8817
|
reason: "exit",
|
|
8780
|
-
exitCode: typeof code === "number" ? code : null
|
|
8818
|
+
exitCode: typeof code === "number" ? code : null,
|
|
8819
|
+
signal: typeof signal === "string" ? signal : null
|
|
8781
8820
|
});
|
|
8782
8821
|
});
|
|
8783
8822
|
proc.on("error", (raw) => {
|
|
@@ -8793,32 +8832,51 @@ var SegmentWriter = class {
|
|
|
8793
8832
|
/**
|
|
8794
8833
|
* The ONE restart policy, reached from both `exit` and `error`.
|
|
8795
8834
|
*
|
|
8796
|
-
* `
|
|
8797
|
-
*
|
|
8798
|
-
*
|
|
8799
|
-
* camera, and a writer that stops recording one camera must be greppable by
|
|
8800
|
-
* that camera.
|
|
8835
|
+
* Every line below carries `tags: { deviceId }` from `deps.deviceId` — "why
|
|
8836
|
+
* is 617 worse than 615" is always asked per camera, and a writer that stops
|
|
8837
|
+
* recording one camera must be greppable by that camera.
|
|
8801
8838
|
*/
|
|
8802
8839
|
onTermination(termination, stderrTail) {
|
|
8803
8840
|
this.resolveExit?.();
|
|
8804
8841
|
this.resolveExit = null;
|
|
8805
8842
|
this.proc = null;
|
|
8806
8843
|
if (this.stopped) return;
|
|
8807
|
-
const
|
|
8844
|
+
const now = Date.now();
|
|
8845
|
+
const ranMs = now - this.startedAt;
|
|
8808
8846
|
if (ranMs >= STABLE_RUN_MS) this.restarts = 0;
|
|
8809
|
-
|
|
8810
|
-
|
|
8847
|
+
this.restartWindow = [...this.restartWindow.filter((at) => at > now - RESTART_WINDOW_MS), now];
|
|
8848
|
+
const restartsInWindow = this.restartWindow.length;
|
|
8849
|
+
if (termination.reason === "exit") this.deps.logger.warn("SegmentWriter ffmpeg exited", {
|
|
8850
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8851
|
+
meta: {
|
|
8811
8852
|
outDir: this.cfg.outDir,
|
|
8812
|
-
|
|
8813
|
-
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
8853
|
+
exitCode: termination.exitCode,
|
|
8854
|
+
signal: termination.signal,
|
|
8855
|
+
ranMs,
|
|
8856
|
+
restartsInWindow,
|
|
8857
|
+
windowMs: RESTART_WINDOW_MS,
|
|
8858
|
+
stderrTail: stderrTail.join(" | ")
|
|
8859
|
+
}
|
|
8860
|
+
});
|
|
8861
|
+
if (termination.reason === "spawn-error") {
|
|
8862
|
+
this.deps.logger.warn("SegmentWriter ffmpeg spawn failed", {
|
|
8863
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8864
|
+
meta: {
|
|
8818
8865
|
outDir: this.cfg.outDir,
|
|
8819
8866
|
errorCode: termination.errorCode,
|
|
8820
|
-
error: termination.message
|
|
8821
|
-
|
|
8867
|
+
error: termination.message,
|
|
8868
|
+
permanent: termination.permanent
|
|
8869
|
+
}
|
|
8870
|
+
});
|
|
8871
|
+
if (termination.permanent) {
|
|
8872
|
+
this.deps.logger.error("SegmentWriter giving up: ffmpeg cannot be executed on this node", {
|
|
8873
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8874
|
+
meta: {
|
|
8875
|
+
outDir: this.cfg.outDir,
|
|
8876
|
+
errorCode: termination.errorCode,
|
|
8877
|
+
error: termination.message
|
|
8878
|
+
}
|
|
8879
|
+
});
|
|
8822
8880
|
this.stopped = true;
|
|
8823
8881
|
this.deps.onGaveUp?.();
|
|
8824
8882
|
return;
|
|
@@ -8826,25 +8884,51 @@ var SegmentWriter = class {
|
|
|
8826
8884
|
this.deps.onResourcePressure?.();
|
|
8827
8885
|
}
|
|
8828
8886
|
if (this.restarts >= MAX_RESTARTS) {
|
|
8829
|
-
this.deps.logger.warn("SegmentWriter giving up after max restarts", {
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8887
|
+
this.deps.logger.warn("SegmentWriter giving up after max restarts", {
|
|
8888
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8889
|
+
meta: {
|
|
8890
|
+
outDir: this.cfg.outDir,
|
|
8891
|
+
reason: termination.reason,
|
|
8892
|
+
code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
|
|
8893
|
+
stderrTail: stderrTail.join(" | ")
|
|
8894
|
+
}
|
|
8895
|
+
});
|
|
8896
|
+
this.stopped = true;
|
|
8897
|
+
this.deps.onGaveUp?.();
|
|
8898
|
+
return;
|
|
8899
|
+
}
|
|
8900
|
+
if (restartsInWindow >= MAX_RESTARTS_IN_WINDOW) {
|
|
8901
|
+
this.deps.logger.warn("SegmentWriter giving up: restart storm", {
|
|
8902
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8903
|
+
meta: {
|
|
8904
|
+
outDir: this.cfg.outDir,
|
|
8905
|
+
restartsInWindow,
|
|
8906
|
+
windowMs: RESTART_WINDOW_MS,
|
|
8907
|
+
budget: MAX_RESTARTS_IN_WINDOW,
|
|
8908
|
+
attempt: this.restarts,
|
|
8909
|
+
ranMs,
|
|
8910
|
+
reason: termination.reason,
|
|
8911
|
+
code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
|
|
8912
|
+
stderrTail: stderrTail.join(" | ")
|
|
8913
|
+
}
|
|
8914
|
+
});
|
|
8835
8915
|
this.stopped = true;
|
|
8836
8916
|
this.deps.onGaveUp?.();
|
|
8837
8917
|
return;
|
|
8838
8918
|
}
|
|
8839
8919
|
this.restarts++;
|
|
8840
8920
|
const delayMs = Math.min(RESTART_BASE_MS * 2 ** (this.restarts - 1), RESTART_MAX_MS);
|
|
8841
|
-
this.deps.logger.info("SegmentWriter restarting", {
|
|
8842
|
-
|
|
8843
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
|
|
8921
|
+
this.deps.logger.info("SegmentWriter restarting", {
|
|
8922
|
+
tags: { deviceId: this.deps.deviceId },
|
|
8923
|
+
meta: {
|
|
8924
|
+
outDir: this.cfg.outDir,
|
|
8925
|
+
attempt: this.restarts,
|
|
8926
|
+
restartsInWindow,
|
|
8927
|
+
delayMs,
|
|
8928
|
+
ranMs,
|
|
8929
|
+
reason: termination.reason
|
|
8930
|
+
}
|
|
8931
|
+
});
|
|
8848
8932
|
this.restartTimer = setTimeout(() => {
|
|
8849
8933
|
this.restartTimer = null;
|
|
8850
8934
|
this.start();
|
|
@@ -10108,6 +10192,7 @@ var RecordingController = class {
|
|
|
10108
10192
|
}, {
|
|
10109
10193
|
spawn: this.deps.spawn,
|
|
10110
10194
|
logger: deviceLog,
|
|
10195
|
+
deviceId,
|
|
10111
10196
|
onGaveUp: () => {
|
|
10112
10197
|
this.recoverWriterGaveUp(deviceId, profile);
|
|
10113
10198
|
},
|
|
@@ -10531,6 +10616,209 @@ function buildRecordingExportProvider(deps) {
|
|
|
10531
10616
|
*/
|
|
10532
10617
|
var resolveRecordingHubHostname = resolveHubHostname;
|
|
10533
10618
|
//#endregion
|
|
10619
|
+
//#region src/recorder/redundant-segments.ts
|
|
10620
|
+
/**
|
|
10621
|
+
* Return the indices (into `segs`) of segments safe to delete: those fully
|
|
10622
|
+
* contained within a kept segment `[coverStart, coverEnd]`. Sorting by start
|
|
10623
|
+
* ascending, then by end DESCENDING, guarantees the containing (longer) segment
|
|
10624
|
+
* is seen first and kept, and any shorter segment nested inside it is removed.
|
|
10625
|
+
*/
|
|
10626
|
+
function selectRedundantSegments(segs) {
|
|
10627
|
+
if (segs.length < 2) return [];
|
|
10628
|
+
const order = segs.map((s, i) => ({
|
|
10629
|
+
start: s.startMs,
|
|
10630
|
+
end: s.startMs + s.durMs,
|
|
10631
|
+
i
|
|
10632
|
+
})).toSorted((a, b) => a.start - b.start || b.end - a.end);
|
|
10633
|
+
const remove = [];
|
|
10634
|
+
let coverStart = Number.NEGATIVE_INFINITY;
|
|
10635
|
+
let coverEnd = Number.NEGATIVE_INFINITY;
|
|
10636
|
+
for (const seg of order) if (seg.start >= coverStart && seg.end <= coverEnd && seg.end > seg.start) remove.push(seg.i);
|
|
10637
|
+
else {
|
|
10638
|
+
coverStart = seg.start;
|
|
10639
|
+
coverEnd = seg.end;
|
|
10640
|
+
}
|
|
10641
|
+
return remove;
|
|
10642
|
+
}
|
|
10643
|
+
//#endregion
|
|
10644
|
+
//#region src/recorder/addon/redundancy-sweep.ts
|
|
10645
|
+
/**
|
|
10646
|
+
* Periodic redundancy sweep — the only thing that removes duplicated footage.
|
|
10647
|
+
*
|
|
10648
|
+
* WHY IT EXISTS AGAIN
|
|
10649
|
+
* ───────────────────
|
|
10650
|
+
* Every `SegmentWriter` restart re-dials the broker with `withRecordingIntent`,
|
|
10651
|
+
* and the broker answers a RECORDING dial with its pre-roll ring (≈10 s). So a
|
|
10652
|
+
* restart does not resume at the live edge: it re-writes ~10 s of media that is
|
|
10653
|
+
* already on disk. At the 2026-08-27 measurement — **1 814 restarts in 12 h** —
|
|
10654
|
+
* that is ~5 hours of duplicated `high`-profile footage per day, on a fuse
|
|
10655
|
+
* share that was already at 90 % full.
|
|
10656
|
+
*
|
|
10657
|
+
* The pruner that used to remove it, `runRedundancyJanitor`, was reachable ONLY
|
|
10658
|
+
* from `runFullArchiveWalk`, which was reachable only from
|
|
10659
|
+
* `scheduleDeferredFullWalk`, which had no caller at all — so on 2026-08-27
|
|
10660
|
+
* (commit `9308428b4`) all three were deleted together as dead code. That was
|
|
10661
|
+
* correct about the walk and left the system with **no duplicate pruner
|
|
10662
|
+
* whatsoever**. The precedent for letting this class of waste run unattended is
|
|
10663
|
+
* the 179 GB of stranded staging segments in `staging-reconcile.ts`.
|
|
10664
|
+
*
|
|
10665
|
+
* WHAT IS DIFFERENT FROM THE ONE THAT WAS DELETED
|
|
10666
|
+
* ───────────────────────────────────────────────
|
|
10667
|
+
* The old janitor ran once, after a walk of the WHOLE archive, and asked the
|
|
10668
|
+
* in-RAM index for `segments(deviceId)` — a full copy plus a sort of every row
|
|
10669
|
+
* the device owns. At the live shape (7.1 M rows) that copy alone was measured
|
|
10670
|
+
* at ~560 MB of transient heap, which is precisely the cost D248 had just
|
|
10671
|
+
* finished removing from the pressure sweep. Reconnecting it in that shape
|
|
10672
|
+
* would have traded one regression for another.
|
|
10673
|
+
*
|
|
10674
|
+
* So this one is bounded by a LOOKBACK WINDOW, not by the archive: duplicates
|
|
10675
|
+
* are made by a restart, restarts are now, and `segmentsStartingIn` slices the
|
|
10676
|
+
* sorted view instead of copying it. Cost grows with footage RECORDED in the
|
|
10677
|
+
* window — the invariant the recorder is supposed to satisfy — never with rows
|
|
10678
|
+
* already indexed.
|
|
10679
|
+
*
|
|
10680
|
+
* WHAT IT WILL NOT DO
|
|
10681
|
+
* ───────────────────
|
|
10682
|
+
* It never deletes a segment that contributes unique coverage:
|
|
10683
|
+
* {@link selectRedundantSegments} selects only segments fully contained inside
|
|
10684
|
+
* another segment that is KEPT, and staggered partial overlaps are always kept.
|
|
10685
|
+
* The delete itself is `SegmentStore.evict` — the same audited path
|
|
10686
|
+
* disk-pressure eviction uses, so a removed segment is de-indexed exactly as an
|
|
10687
|
+
* evicted one is. A failure for one (device, profile, location) is logged and
|
|
10688
|
+
* never aborts the pass.
|
|
10689
|
+
*/
|
|
10690
|
+
/** The profiles a recorder writes. Same set `staging-reconcile` sweeps. */
|
|
10691
|
+
var SWEPT_PROFILES = [
|
|
10692
|
+
"high",
|
|
10693
|
+
"mid",
|
|
10694
|
+
"low"
|
|
10695
|
+
];
|
|
10696
|
+
/** Group rows by the location that owns them. `evict` is per-location. */
|
|
10697
|
+
function byLocation(rows) {
|
|
10698
|
+
const out = /* @__PURE__ */ new Map();
|
|
10699
|
+
for (const row of rows) {
|
|
10700
|
+
const found = out.get(row.locationId);
|
|
10701
|
+
if (found) found.push(row);
|
|
10702
|
+
else out.set(row.locationId, [row]);
|
|
10703
|
+
}
|
|
10704
|
+
return out;
|
|
10705
|
+
}
|
|
10706
|
+
/**
|
|
10707
|
+
* Owns its timer and a single-flight guard, the same shape the retention sweep
|
|
10708
|
+
* and the export janitor already use — a slow pass is never overlapped by the
|
|
10709
|
+
* next tick.
|
|
10710
|
+
*/
|
|
10711
|
+
var RedundancySweeper = class {
|
|
10712
|
+
deps;
|
|
10713
|
+
timer = null;
|
|
10714
|
+
sweeping = false;
|
|
10715
|
+
constructor(deps) {
|
|
10716
|
+
this.deps = deps;
|
|
10717
|
+
}
|
|
10718
|
+
/**
|
|
10719
|
+
* Arm the sweep on its interval. Idempotent.
|
|
10720
|
+
*
|
|
10721
|
+
* Deliberately NOT run immediately: boot is when the writers are attaching,
|
|
10722
|
+
* the staging reconcile is relocating thousands of files over shfs and the
|
|
10723
|
+
* first viewer is painting. A duplicate that has waited an hour can wait
|
|
10724
|
+
* another one; the write path cannot.
|
|
10725
|
+
*/
|
|
10726
|
+
start() {
|
|
10727
|
+
if (this.timer !== null) return;
|
|
10728
|
+
this.timer = setInterval(() => {
|
|
10729
|
+
this.sweep();
|
|
10730
|
+
}, this.deps.intervalMs);
|
|
10731
|
+
this.timer.unref?.();
|
|
10732
|
+
}
|
|
10733
|
+
/** Stop the timer. Idempotent. An in-flight pass still completes. */
|
|
10734
|
+
stop() {
|
|
10735
|
+
if (this.timer !== null) {
|
|
10736
|
+
clearInterval(this.timer);
|
|
10737
|
+
this.timer = null;
|
|
10738
|
+
}
|
|
10739
|
+
}
|
|
10740
|
+
/**
|
|
10741
|
+
* One pass. Never throws: a device-list read failure aborts THIS pass
|
|
10742
|
+
* (logged), a per-location evict failure is logged and the pass continues.
|
|
10743
|
+
*/
|
|
10744
|
+
async sweep() {
|
|
10745
|
+
const empty = {
|
|
10746
|
+
files: 0,
|
|
10747
|
+
bytes: 0,
|
|
10748
|
+
devices: 0
|
|
10749
|
+
};
|
|
10750
|
+
if (this.sweeping) {
|
|
10751
|
+
this.deps.logger.debug("recorder: redundancy sweep already running — skipping this tick");
|
|
10752
|
+
return empty;
|
|
10753
|
+
}
|
|
10754
|
+
this.sweeping = true;
|
|
10755
|
+
try {
|
|
10756
|
+
let deviceIds;
|
|
10757
|
+
try {
|
|
10758
|
+
deviceIds = await this.deps.deviceIds();
|
|
10759
|
+
} catch (err) {
|
|
10760
|
+
this.deps.logger.warn("recorder: redundancy sweep could not read the device list", { meta: { error: errMsg(err) } });
|
|
10761
|
+
return empty;
|
|
10762
|
+
}
|
|
10763
|
+
const toMs = (this.deps.now ?? Date.now)();
|
|
10764
|
+
const fromMs = toMs - this.deps.lookbackMs;
|
|
10765
|
+
let files = 0;
|
|
10766
|
+
let bytes = 0;
|
|
10767
|
+
let devices = 0;
|
|
10768
|
+
for (const deviceId of deviceIds) {
|
|
10769
|
+
let removedHere = 0;
|
|
10770
|
+
for (const profile of SWEPT_PROFILES) {
|
|
10771
|
+
const rows = this.deps.segmentsInWindow(deviceId, profile, fromMs, toMs);
|
|
10772
|
+
const redundant = selectRedundantSegments(rows);
|
|
10773
|
+
if (redundant.length === 0) continue;
|
|
10774
|
+
const victims = redundant.map((i) => rows[i]).filter((row) => row !== void 0);
|
|
10775
|
+
for (const [locationId, group] of byLocation(victims)) try {
|
|
10776
|
+
const reclaimed = await this.deps.evict(locationId, group);
|
|
10777
|
+
files += group.length;
|
|
10778
|
+
bytes += reclaimed;
|
|
10779
|
+
removedHere += group.length;
|
|
10780
|
+
} catch (err) {
|
|
10781
|
+
this.deps.logger.warn("recorder: redundancy sweep could not evict duplicates", {
|
|
10782
|
+
tags: { deviceId },
|
|
10783
|
+
meta: {
|
|
10784
|
+
profile,
|
|
10785
|
+
locationId,
|
|
10786
|
+
count: group.length,
|
|
10787
|
+
error: errMsg(err)
|
|
10788
|
+
}
|
|
10789
|
+
});
|
|
10790
|
+
}
|
|
10791
|
+
}
|
|
10792
|
+
if (removedHere > 0) {
|
|
10793
|
+
devices += 1;
|
|
10794
|
+
this.deps.logger.info("recorder: removed duplicated segments for device", {
|
|
10795
|
+
tags: { deviceId },
|
|
10796
|
+
meta: {
|
|
10797
|
+
files: removedHere,
|
|
10798
|
+
fromMs,
|
|
10799
|
+
toMs
|
|
10800
|
+
}
|
|
10801
|
+
});
|
|
10802
|
+
}
|
|
10803
|
+
}
|
|
10804
|
+
this.deps.logger.info("recorder: redundancy sweep complete", { meta: {
|
|
10805
|
+
devices,
|
|
10806
|
+
files,
|
|
10807
|
+
bytes,
|
|
10808
|
+
lookbackMs: this.deps.lookbackMs,
|
|
10809
|
+
deviceCount: deviceIds.length
|
|
10810
|
+
} });
|
|
10811
|
+
return {
|
|
10812
|
+
files,
|
|
10813
|
+
bytes,
|
|
10814
|
+
devices
|
|
10815
|
+
};
|
|
10816
|
+
} finally {
|
|
10817
|
+
this.sweeping = false;
|
|
10818
|
+
}
|
|
10819
|
+
}
|
|
10820
|
+
};
|
|
10821
|
+
//#endregion
|
|
10534
10822
|
//#region src/recorder/addon/retention-sweep.ts
|
|
10535
10823
|
/**
|
|
10536
10824
|
* Periodic footage-retention sweep (recording-spec §6).
|
|
@@ -12091,6 +12379,18 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
|
12091
12379
|
* hourly cadence keeps the storage.list/evict cost negligible while honouring
|
|
12092
12380
|
* the recording-spec §6 periodic retention. */
|
|
12093
12381
|
var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
12382
|
+
/** How often the redundancy sweep removes footage a writer restart duplicated. */
|
|
12383
|
+
var REDUNDANCY_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
12384
|
+
/**
|
|
12385
|
+
* How far back each redundancy pass looks.
|
|
12386
|
+
*
|
|
12387
|
+
* Twice the interval, so a pass that is skipped (single-flight) or delayed by a
|
|
12388
|
+
* slow predecessor still covers the window its predecessor was supposed to.
|
|
12389
|
+
* It is deliberately NOT "the whole archive": duplicates are made by a writer
|
|
12390
|
+
* restart, restarts are recent, and a full-archive selection is the ~560 MB
|
|
12391
|
+
* transient copy D248 removed. See `redundancy-sweep.ts`.
|
|
12392
|
+
*/
|
|
12393
|
+
var REDUNDANCY_SWEEP_LOOKBACK_MS = 2 * REDUNDANCY_SWEEP_INTERVAL_MS;
|
|
12094
12394
|
/** What the per-volume usage row reports when the hour ledger is unavailable:
|
|
12095
12395
|
* nothing measured, rather than a total counted off a partial RAM index. */
|
|
12096
12396
|
var EMPTY_ARCHIVE_ACCOUNTING = {
|
|
@@ -12243,6 +12543,9 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12243
12543
|
/** Periodic footage-retention sweep (owns its timer + single-flight guard).
|
|
12244
12544
|
* Constructed + started in `onHubReachable`, stopped on shutdown. */
|
|
12245
12545
|
retentionSweeper = null;
|
|
12546
|
+
/** Periodic duplicate-footage sweep — the ONLY pruner of media a writer
|
|
12547
|
+
* restart re-wrote. Constructed + started in `onHubReachable`. */
|
|
12548
|
+
redundancySweeper = null;
|
|
12246
12549
|
/** Reversible migration pause lease; never reflected in RecordingConfig. */
|
|
12247
12550
|
storageMigrationLeaseId = null;
|
|
12248
12551
|
/**
|
|
@@ -12731,6 +13034,8 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12731
13034
|
}
|
|
12732
13035
|
this.retentionSweeper?.stop();
|
|
12733
13036
|
this.retentionSweeper = null;
|
|
13037
|
+
this.redundancySweeper?.stop();
|
|
13038
|
+
this.redundancySweeper = null;
|
|
12734
13039
|
this.exportEngine = null;
|
|
12735
13040
|
this.recordingProvider = null;
|
|
12736
13041
|
}
|
|
@@ -12870,6 +13175,16 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12870
13175
|
intervalMs: RETENTION_SWEEP_INTERVAL_MS
|
|
12871
13176
|
});
|
|
12872
13177
|
this.retentionSweeper?.start();
|
|
13178
|
+
const store = this.segmentStore;
|
|
13179
|
+
if (store && this.redundancySweeper === null) this.redundancySweeper = new RedundancySweeper({
|
|
13180
|
+
deviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
|
|
13181
|
+
segmentsInWindow: (deviceId, profile, fromMs, toMs) => this.index.segmentsStartingIn(deviceId, profile, fromMs, toMs),
|
|
13182
|
+
evict: (locationId, rows) => store.evict(locationId, rows),
|
|
13183
|
+
logger: this.ctx.logger,
|
|
13184
|
+
intervalMs: REDUNDANCY_SWEEP_INTERVAL_MS,
|
|
13185
|
+
lookbackMs: REDUNDANCY_SWEEP_LOOKBACK_MS
|
|
13186
|
+
});
|
|
13187
|
+
this.redundancySweeper?.start();
|
|
12873
13188
|
}
|
|
12874
13189
|
/**
|
|
12875
13190
|
* Rebuild the sensor trigger's reverse index from the persisted configs.
|
|
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
|
|
|
3
3
|
var e = {
|
|
4
4
|
"@camstack/sdk": {
|
|
5
5
|
name: "@camstack/sdk",
|
|
6
|
-
version: "1.2.
|
|
6
|
+
version: "1.2.38",
|
|
7
7
|
scope: ["default"],
|
|
8
8
|
loaded: !1,
|
|
9
9
|
from: "addon_stream_broker_widgets",
|
|
@@ -18,7 +18,7 @@ var e = {
|
|
|
18
18
|
},
|
|
19
19
|
"@camstack/types": {
|
|
20
20
|
name: "@camstack/types",
|
|
21
|
-
version: "1.2.
|
|
21
|
+
version: "1.2.116",
|
|
22
22
|
scope: ["default"],
|
|
23
23
|
loaded: !1,
|
|
24
24
|
from: "addon_stream_broker_widgets",
|
|
@@ -33,7 +33,7 @@ var e = {
|
|
|
33
33
|
},
|
|
34
34
|
"@camstack/ui-library": {
|
|
35
35
|
name: "@camstack/ui-library",
|
|
36
|
-
version: "1.2.
|
|
36
|
+
version: "1.2.79",
|
|
37
37
|
scope: ["default"],
|
|
38
38
|
loaded: !1,
|
|
39
39
|
from: "addon_stream_broker_widgets",
|
|
@@ -36,7 +36,7 @@ async function r() {
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"@camstack/types": {
|
|
39
|
-
version: "1.2.
|
|
39
|
+
version: "1.2.116",
|
|
40
40
|
scope: "default",
|
|
41
41
|
shareConfig: {
|
|
42
42
|
singleton: !0,
|
|
@@ -45,7 +45,7 @@ async function r() {
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"@camstack/sdk": {
|
|
48
|
-
version: "1.2.
|
|
48
|
+
version: "1.2.38",
|
|
49
49
|
scope: "default",
|
|
50
50
|
shareConfig: {
|
|
51
51
|
singleton: !0,
|
|
@@ -81,7 +81,7 @@ async function r() {
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"@camstack/ui-library": {
|
|
84
|
-
version: "1.2.
|
|
84
|
+
version: "1.2.79",
|
|
85
85
|
scope: "default",
|
|
86
86
|
shareConfig: {
|
|
87
87
|
singleton: !0,
|
|
@@ -30,7 +30,7 @@ async function d(e) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
async function f() {
|
|
33
|
-
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-
|
|
33
|
+
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CLtMmF1E.mjs")).catch((e) => {
|
|
34
34
|
throw l = void 0, e;
|
|
35
35
|
}), l;
|
|
36
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-pipeline",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.136",
|
|
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",
|
|
@@ -168,7 +168,8 @@
|
|
|
168
168
|
"entry": "./dist/stream-broker/index.js",
|
|
169
169
|
"execution": {
|
|
170
170
|
"placement": "any-node",
|
|
171
|
-
"heapProfile": "heavy"
|
|
171
|
+
"heapProfile": "heavy",
|
|
172
|
+
"rssBudgetMb": 2048
|
|
172
173
|
},
|
|
173
174
|
"capabilities": [
|
|
174
175
|
{
|
|
@@ -201,7 +202,8 @@
|
|
|
201
202
|
"execution": {
|
|
202
203
|
"placement": "any-node",
|
|
203
204
|
"heapProfile": "heavy",
|
|
204
|
-
"maxOldSpaceMb": 3072
|
|
205
|
+
"maxOldSpaceMb": 3072,
|
|
206
|
+
"rssBudgetMb": 3584
|
|
205
207
|
},
|
|
206
208
|
"capabilities": [
|
|
207
209
|
{
|