@camstack/addon-pipeline 1.2.96 → 1.2.98
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/{addon-utils-B6usm-YO.js → addon-utils-eUS6n_Zj.js} +1 -1
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +5 -5
- package/dist/detection-pipeline/index.mjs +3 -3
- package/dist/{dist-w9QWDvWn.js → dist-dm3t4BOt.js} +29 -3
- package/dist/{dist-LIWfUNC_.mjs → dist-gXdWP96z.mjs} +29 -3
- package/dist/{event-loop-stall-monitor-DndI0S2s.js → event-loop-stall-monitor-Lki2_mYY.js} +1 -1
- package/dist/{event-loop-stall-monitor-BDC8qURn.mjs → event-loop-stall-monitor-TnQ21mu0.mjs} +1 -1
- package/dist/{lazy-sharp-BVqBydNU.js → lazy-sharp-oYppDIZR.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +51 -4
- package/dist/pipeline-runner/index.mjs +50 -3
- package/dist/{process-memory-k45KJaKH.mjs → process-memory-CKWh609B.mjs} +1 -1
- package/dist/{process-memory-C6zWIgos.js → process-memory-vkFVOdkV.js} +1 -1
- package/dist/recorder/index.js +296 -48
- package/dist/recorder/index.mjs +295 -47
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CJWMZMhQ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B7ERJocD.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-zT-cYUdF.mjs +26 -0
- package/dist/stream-broker/{hostInit-CGIo3d4K.mjs → hostInit-Cm2rl8-e.mjs} +3 -3
- package/dist/stream-broker/index.js +21 -6
- package/dist/stream-broker/index.mjs +21 -6
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DqrXmX0g.js → worker-protocol-BrlrMxXu.js} +1 -1
- package/dist/{worker-protocol-DTe7Ntat.mjs → worker-protocol-DJWdPLKF.mjs} +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D2WOTeYi.mjs +0 -26
package/dist/recorder/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const require_dist = require("../dist-
|
|
1
|
+
const require_dist = require("../dist-dm3t4BOt.js");
|
|
2
2
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
3
|
-
const require_addon_utils = require("../addon-utils-
|
|
3
|
+
const require_addon_utils = require("../addon-utils-eUS6n_Zj.js");
|
|
4
4
|
let node_crypto = require("node:crypto");
|
|
5
5
|
let node_child_process = require("node:child_process");
|
|
6
6
|
let node_path = require("node:path");
|
|
@@ -4333,9 +4333,15 @@ function buildRecordingProvider(deps) {
|
|
|
4333
4333
|
const first = group[0];
|
|
4334
4334
|
const cap = await deps.capacity(first.root);
|
|
4335
4335
|
const ids = group.map((l) => l.id);
|
|
4336
|
+
if (cap.error !== void 0) deps.logger.warn("recorder: volume capacity unavailable (statfs failed)", { meta: {
|
|
4337
|
+
locationId: first.id,
|
|
4338
|
+
root: first.root,
|
|
4339
|
+
error: cap.error
|
|
4340
|
+
} });
|
|
4336
4341
|
return {
|
|
4337
4342
|
locationId: first.id,
|
|
4338
4343
|
locationIds: ids,
|
|
4344
|
+
root: first.root,
|
|
4339
4345
|
usedBytes: deps.index.accountingForLocations(new Set(ids)).bytes,
|
|
4340
4346
|
availableBytes: cap.availableBytes,
|
|
4341
4347
|
totalBytes: cap.totalBytes
|
|
@@ -5569,13 +5575,20 @@ async function probeLocationVolume(location, rememberedVolumeId, deps) {
|
|
|
5569
5575
|
}
|
|
5570
5576
|
/**
|
|
5571
5577
|
* Where to write instead when a location fails its volume probe: the normal
|
|
5572
|
-
* placement chain over every
|
|
5573
|
-
* sibling of the same type; with
|
|
5574
|
-
* a shouting recorder beats a stopped camera,
|
|
5575
|
-
* one outcome no operator asked for.
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5578
|
+
* placement chain over every location NOT already known unusable. With N
|
|
5579
|
+
* locations this is a healthy sibling of the same type; with none left it is
|
|
5580
|
+
* the unusable location itself — a shouting recorder beats a stopped camera,
|
|
5581
|
+
* and refusing to record is the one outcome no operator asked for.
|
|
5582
|
+
*
|
|
5583
|
+
* `alreadyUnusable` is not an optimisation. Without it the search happily
|
|
5584
|
+
* returned a sibling that had ALSO failed its probe, so with two broken disks
|
|
5585
|
+
* every attach handed back the other one and the stored assignment flipped
|
|
5586
|
+
* A→B→A→B forever — one durable write and one audit row per attach, per camera,
|
|
5587
|
+
* per profile. Observed live on 2026-08-19 (the UD disk did not come back after
|
|
5588
|
+
* a reboot): 716 EACCES/min, with the ops-log ring flooded by the flapping.
|
|
5589
|
+
*/
|
|
5590
|
+
function failoverForUnusableLocation(unusable, locations, profile, alreadyUnusable = /* @__PURE__ */ new Set()) {
|
|
5591
|
+
const siblings = locations.filter((l) => l.id !== unusable.id && !alreadyUnusable.has(l.id));
|
|
5579
5592
|
try {
|
|
5580
5593
|
return resolvePlacement(siblings, profile);
|
|
5581
5594
|
} catch {
|
|
@@ -5626,6 +5639,13 @@ var PlacementService = class {
|
|
|
5626
5639
|
verified = /* @__PURE__ */ new Set();
|
|
5627
5640
|
/** Locations the probe declared unusable — re-probed after a change. */
|
|
5628
5641
|
unusable = /* @__PURE__ */ new Set();
|
|
5642
|
+
/** Cameras that have already announced "every location is unusable". One line
|
|
5643
|
+
* per camera per episode: the fault is asked per-camera, and repeating it per
|
|
5644
|
+
* attach is the noise this state exists to end. */
|
|
5645
|
+
allUnusableReported = /* @__PURE__ */ new Set();
|
|
5646
|
+
/** When the current all-unusable episode last (re-)probed. `null` ⇒ no
|
|
5647
|
+
* episode in progress. */
|
|
5648
|
+
allUnusableProbedAt = null;
|
|
5629
5649
|
/** The fair tie-break the PURE planner defers to (D37). Advisory state, and
|
|
5630
5650
|
* consulted only for a tie the capacity signal cannot separate. */
|
|
5631
5651
|
rotation = new PlacementRotation();
|
|
@@ -5635,6 +5655,12 @@ var PlacementService = class {
|
|
|
5635
5655
|
get probeDeps() {
|
|
5636
5656
|
return this.deps.probe ?? fsVolumeProbeDeps;
|
|
5637
5657
|
}
|
|
5658
|
+
get now() {
|
|
5659
|
+
return this.deps.now?.() ?? Date.now();
|
|
5660
|
+
}
|
|
5661
|
+
get reprobeMs() {
|
|
5662
|
+
return this.deps.reprobeMs ?? 6e4;
|
|
5663
|
+
}
|
|
5638
5664
|
/**
|
|
5639
5665
|
* Forget every probe verdict. Called whenever the location set is re-resolved
|
|
5640
5666
|
* (rescan, storage migration) — a remount is exactly the event the probe
|
|
@@ -5643,6 +5669,8 @@ var PlacementService = class {
|
|
|
5643
5669
|
onLocationsChanged() {
|
|
5644
5670
|
this.verified.clear();
|
|
5645
5671
|
this.unusable.clear();
|
|
5672
|
+
this.allUnusableReported.clear();
|
|
5673
|
+
this.allUnusableProbedAt = null;
|
|
5646
5674
|
}
|
|
5647
5675
|
/**
|
|
5648
5676
|
* Resolve the location for one (camera, profile) at an attach boundary.
|
|
@@ -5651,12 +5679,92 @@ var PlacementService = class {
|
|
|
5651
5679
|
*/
|
|
5652
5680
|
async place(deviceId, profile) {
|
|
5653
5681
|
const pool = this.pool();
|
|
5682
|
+
this.reprobeAfterBackoff(pool);
|
|
5654
5683
|
const resolved = await this.assignedLocation(deviceId, profile, pool) ?? resolvePlacement(pool, profile);
|
|
5655
5684
|
const usable = await this.ensureUsable(resolved, pool, profile, deviceId);
|
|
5685
|
+
if (this.everyLocationUnusable(pool)) {
|
|
5686
|
+
this.reportEveryLocationUnusable(deviceId, profile, resolved, pool);
|
|
5687
|
+
return resolved;
|
|
5688
|
+
}
|
|
5689
|
+
this.allUnusableReported.delete(deviceId);
|
|
5656
5690
|
await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id);
|
|
5657
5691
|
return usable;
|
|
5658
5692
|
}
|
|
5659
5693
|
/**
|
|
5694
|
+
* Has the marker probe declared this location's volume NOT the one we last
|
|
5695
|
+
* wrote to?
|
|
5696
|
+
*
|
|
5697
|
+
* Read by the segment-hour ledger: a walk over a root whose volume failed its
|
|
5698
|
+
* probe found nothing because nothing is mounted there, and that must never be
|
|
5699
|
+
* allowed to delete durable rows (D49). Unprobed ⇒ usable, which is honest —
|
|
5700
|
+
* the probe runs at the first writer attach, so at boot it has usually not run
|
|
5701
|
+
* at all, and the ledger's own magnitude guard covers that window.
|
|
5702
|
+
*/
|
|
5703
|
+
isLocationUsable(locationId) {
|
|
5704
|
+
return !this.unusable.has(locationId);
|
|
5705
|
+
}
|
|
5706
|
+
/** True when the pool is non-empty and the probe has failed EVERY member. */
|
|
5707
|
+
everyLocationUnusable(pool) {
|
|
5708
|
+
return pool.length > 0 && pool.every((location) => this.unusable.has(location.id));
|
|
5709
|
+
}
|
|
5710
|
+
/**
|
|
5711
|
+
* Announce the terminal state — ONCE per camera per episode.
|
|
5712
|
+
*
|
|
5713
|
+
* Before this, two unusable locations produced an infinite failover
|
|
5714
|
+
* ping-pong: `failoverForUnusableLocation` did not consult the unusable set,
|
|
5715
|
+
* so each attach handed back the other broken disk, `recordDecision` saw a
|
|
5716
|
+
* changed assignment and wrote the store plus an audit row, and the writer
|
|
5717
|
+
* hit EACCES on a path that was never going to work. Measured on the live hub
|
|
5718
|
+
* on 2026-08-19 with the UD disk unmounted: 716 EACCES/min.
|
|
5719
|
+
*
|
|
5720
|
+
* Recording is NOT refused. With a single location this state is reached the
|
|
5721
|
+
* moment that one location fails, and the recorder keeps attaching to it on
|
|
5722
|
+
* purpose — a shouting recorder beats a stopped camera. What changes is that
|
|
5723
|
+
* it shouts once instead of once per attach, and writes nothing durable while
|
|
5724
|
+
* shouting.
|
|
5725
|
+
*/
|
|
5726
|
+
reportEveryLocationUnusable(deviceId, profile, target, pool) {
|
|
5727
|
+
this.allUnusableProbedAt ??= this.now;
|
|
5728
|
+
if (this.allUnusableReported.has(deviceId)) return;
|
|
5729
|
+
this.allUnusableReported.add(deviceId);
|
|
5730
|
+
this.deps.logger.warn("recorder placement: EVERY recordings location failed its volume probe — the assignment is FROZEN on its current target and nothing more is written until a disk comes back", {
|
|
5731
|
+
tags: { deviceId },
|
|
5732
|
+
meta: {
|
|
5733
|
+
profile,
|
|
5734
|
+
targetLocationId: target.id,
|
|
5735
|
+
root: target.root,
|
|
5736
|
+
locationIds: pool.map((location) => location.id),
|
|
5737
|
+
reprobeMs: this.reprobeMs
|
|
5738
|
+
}
|
|
5739
|
+
});
|
|
5740
|
+
}
|
|
5741
|
+
/**
|
|
5742
|
+
* While every location is unusable, re-probe them all — at most once per
|
|
5743
|
+
* {@link ALL_UNUSABLE_REPROBE_MS}. This is the backoff: without it the probe
|
|
5744
|
+
* memo would hold for the life of the process (only `onLocationsChanged`
|
|
5745
|
+
* clears it) and a disk remounted by an operator would need an addon restart
|
|
5746
|
+
* to be noticed.
|
|
5747
|
+
*/
|
|
5748
|
+
reprobeAfterBackoff(pool) {
|
|
5749
|
+
if (!this.everyLocationUnusable(pool)) {
|
|
5750
|
+
this.allUnusableProbedAt = null;
|
|
5751
|
+
return;
|
|
5752
|
+
}
|
|
5753
|
+
const now = this.now;
|
|
5754
|
+
if (this.allUnusableProbedAt === null) {
|
|
5755
|
+
this.allUnusableProbedAt = now;
|
|
5756
|
+
return;
|
|
5757
|
+
}
|
|
5758
|
+
if (now - this.allUnusableProbedAt < this.reprobeMs) return;
|
|
5759
|
+
this.allUnusableProbedAt = now;
|
|
5760
|
+
this.deps.logger.info("recorder placement: re-probing every storage location after backoff — a disk that came back is picked up here", { meta: {
|
|
5761
|
+
locationIds: pool.map((location) => location.id),
|
|
5762
|
+
backoffMs: this.reprobeMs
|
|
5763
|
+
} });
|
|
5764
|
+
this.unusable.clear();
|
|
5765
|
+
this.verified.clear();
|
|
5766
|
+
}
|
|
5767
|
+
/**
|
|
5660
5768
|
* The locations placement may write to: operator-ENABLED and alias-guarded.
|
|
5661
5769
|
* A location the operator has not opted in is still read, swept and drained —
|
|
5662
5770
|
* it is simply never a write target. Creating a disk must not start writing
|
|
@@ -5805,13 +5913,13 @@ var PlacementService = class {
|
|
|
5805
5913
|
* ONCE per location per location-set generation — it is a claim, not a poll.
|
|
5806
5914
|
*/
|
|
5807
5915
|
async ensureUsable(chosen, locations, profile, deviceId) {
|
|
5808
|
-
if (this.unusable.has(chosen.id)) return failoverForUnusableLocation(chosen, locations, profile);
|
|
5916
|
+
if (this.unusable.has(chosen.id)) return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
|
|
5809
5917
|
if (this.verified.has(chosen.id)) return chosen;
|
|
5810
5918
|
const result = await probeLocationVolume(chosen, (await readPlacementState(this.deps.state)).volumeIds[chosen.id] ?? null, this.probeDeps);
|
|
5811
5919
|
if (result.outcome === "mismatch") {
|
|
5812
5920
|
this.unusable.add(chosen.id);
|
|
5813
5921
|
this.reportMountFault(result, deviceId);
|
|
5814
|
-
return failoverForUnusableLocation(chosen, locations, profile);
|
|
5922
|
+
return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
|
|
5815
5923
|
}
|
|
5816
5924
|
if (result.outcome === "unreadable") {
|
|
5817
5925
|
this.deps.logger.warn("recorder placement: volume probe unreadable — assignment unchanged", {
|
|
@@ -7925,6 +8033,34 @@ function makeBrokerCall(handle) {
|
|
|
7925
8033
|
return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
|
|
7926
8034
|
}
|
|
7927
8035
|
//#endregion
|
|
8036
|
+
//#region src/recorder/addon/location-capacity.ts
|
|
8037
|
+
/** Human-readable failure text for a thrown value of unknown shape. */
|
|
8038
|
+
function reasonOf(err) {
|
|
8039
|
+
return err instanceof Error ? err.message : String(err);
|
|
8040
|
+
}
|
|
8041
|
+
/**
|
|
8042
|
+
* Free + total bytes on the volume backing `root`.
|
|
8043
|
+
*
|
|
8044
|
+
* Never throws: an unreachable volume is one degraded ROW, not a failed
|
|
8045
|
+
* `getStorageUsage` — a NAS that stops answering must not blank the capacity of
|
|
8046
|
+
* every other disk on the node.
|
|
8047
|
+
*/
|
|
8048
|
+
async function readLocationCapacity(root, statfs) {
|
|
8049
|
+
try {
|
|
8050
|
+
const st = await statfs(root);
|
|
8051
|
+
return {
|
|
8052
|
+
availableBytes: st.bavail * st.bsize,
|
|
8053
|
+
totalBytes: st.blocks * st.bsize
|
|
8054
|
+
};
|
|
8055
|
+
} catch (err) {
|
|
8056
|
+
return {
|
|
8057
|
+
availableBytes: null,
|
|
8058
|
+
totalBytes: null,
|
|
8059
|
+
error: reasonOf(err)
|
|
8060
|
+
};
|
|
8061
|
+
}
|
|
8062
|
+
}
|
|
8063
|
+
//#endregion
|
|
7928
8064
|
//#region src/durable/durable-ledger.ts
|
|
7929
8065
|
/** Default reseed cap — every current consumer's row set is installation-bounded. */
|
|
7930
8066
|
var DEFAULT_LOAD_LIMIT = 1e5;
|
|
@@ -8212,6 +8348,17 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [{
|
|
|
8212
8348
|
name: "idx_recorder_segment_hours_hour",
|
|
8213
8349
|
columns: ["hourStartMs"]
|
|
8214
8350
|
}];
|
|
8351
|
+
/**
|
|
8352
|
+
* The largest share of a device's held hours a single walk may delete.
|
|
8353
|
+
*
|
|
8354
|
+
* On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
|
|
8355
|
+
* the UD disk had not mounted — and one walk took the ledger from 873 hours to
|
|
8356
|
+
* 32 (a 96% drop). The ledger is precisely what lets the NEXT boot skip a
|
|
8357
|
+
* ~400 s recursive walk, so a single bad walk taxed every boot after it. A
|
|
8358
|
+
* retention sweep does not delete half a camera's archive in one pass; a
|
|
8359
|
+
* `readdir` over an unmounted root deletes all of it.
|
|
8360
|
+
*/
|
|
8361
|
+
var LEDGER_PRUNE_MAX_SHARE = .5;
|
|
8215
8362
|
function hourStartMs(startMs) {
|
|
8216
8363
|
return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
|
|
8217
8364
|
}
|
|
@@ -8285,8 +8432,10 @@ var SPEC = {
|
|
|
8285
8432
|
var SegmentHourLedger = class {
|
|
8286
8433
|
ledger;
|
|
8287
8434
|
logger;
|
|
8435
|
+
isLocationUsable;
|
|
8288
8436
|
constructor(deps) {
|
|
8289
8437
|
this.logger = deps.logger;
|
|
8438
|
+
this.isLocationUsable = deps.isLocationUsable ?? (() => true);
|
|
8290
8439
|
this.ledger = new DurableLedger({
|
|
8291
8440
|
spec: SPEC,
|
|
8292
8441
|
store: deps.store,
|
|
@@ -8376,6 +8525,10 @@ var SegmentHourLedger = class {
|
|
|
8376
8525
|
*
|
|
8377
8526
|
* Devices not in `deviceIds` are left untouched (a partial walk must not
|
|
8378
8527
|
* prune cameras it did not look at).
|
|
8528
|
+
*
|
|
8529
|
+
* And a walk this ledger does not TRUST prunes nothing at all — see
|
|
8530
|
+
* {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
|
|
8531
|
+
* are unioned in; what it did not find decides nothing (D49).
|
|
8379
8532
|
*/
|
|
8380
8533
|
async reconcileFromIndex(index, deviceIds, nowMs) {
|
|
8381
8534
|
const walked = new Set(deviceIds);
|
|
@@ -8397,15 +8550,17 @@ var SegmentHourLedger = class {
|
|
|
8397
8550
|
paths: [...existing.paths, seg.path]
|
|
8398
8551
|
});
|
|
8399
8552
|
}
|
|
8553
|
+
const refused = this.refusedPrunes(walked, fromIndex);
|
|
8400
8554
|
for (const row of this.ledger.snapshot()) {
|
|
8401
8555
|
if (!walked.has(row.deviceId)) continue;
|
|
8402
8556
|
const disk = fromIndex.get(row.key);
|
|
8557
|
+
const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
|
|
8403
8558
|
if (disk === void 0) {
|
|
8404
|
-
if (
|
|
8559
|
+
if (additiveOnly) continue;
|
|
8405
8560
|
await this.ledger.forget(row.key);
|
|
8406
8561
|
continue;
|
|
8407
8562
|
}
|
|
8408
|
-
if (
|
|
8563
|
+
if (additiveOnly) {
|
|
8409
8564
|
const union = uniquePaths([...disk.paths, ...row.paths]);
|
|
8410
8565
|
if (!samePaths(union, row.paths)) await this.ledger.put({
|
|
8411
8566
|
...row,
|
|
@@ -8420,7 +8575,85 @@ var SegmentHourLedger = class {
|
|
|
8420
8575
|
await this.ledger.put(disk);
|
|
8421
8576
|
}
|
|
8422
8577
|
}
|
|
8578
|
+
/**
|
|
8579
|
+
* Which of this walk's deletions are REFUSED, and why.
|
|
8580
|
+
*
|
|
8581
|
+
* Two independent reasons, because the live fault of 2026-08-19 would have
|
|
8582
|
+
* escaped either one alone:
|
|
8583
|
+
*
|
|
8584
|
+
* - **the volume said no.** `PlacementService`'s marker probe knows the bytes
|
|
8585
|
+
* under a root are not the bytes we wrote. A walk of such a root found
|
|
8586
|
+
* nothing because nothing is mounted there, not because the footage is
|
|
8587
|
+
* gone.
|
|
8588
|
+
* - **the magnitude said no.** At BOOT the probe has not run yet — it runs at
|
|
8589
|
+
* the first writer attach — so on the morning the recorder respawned onto an
|
|
8590
|
+
* empty tmpfs the volume was not flagged and the prune went through: 873
|
|
8591
|
+
* hours to 32. A walk that deletes more than
|
|
8592
|
+
* {@link LEDGER_PRUNE_MAX_SHARE} of a device's held hours is refused on its
|
|
8593
|
+
* size alone.
|
|
8594
|
+
*
|
|
8595
|
+
* A refusal is one line per device (or per device+location), carrying the
|
|
8596
|
+
* delta, tagged with `deviceId` — a ledger that silently declined to prune and
|
|
8597
|
+
* a ledger with nothing to prune must never read the same.
|
|
8598
|
+
*/
|
|
8599
|
+
refusedPrunes(walked, fromIndex) {
|
|
8600
|
+
const heldByDevice = /* @__PURE__ */ new Map();
|
|
8601
|
+
const unusable = /* @__PURE__ */ new Map();
|
|
8602
|
+
for (const row of this.ledger.snapshot()) {
|
|
8603
|
+
if (!walked.has(row.deviceId)) continue;
|
|
8604
|
+
heldByDevice.set(row.deviceId, (heldByDevice.get(row.deviceId) ?? 0) + 1);
|
|
8605
|
+
if (this.isLocationUsable(row.locationId)) continue;
|
|
8606
|
+
const key = locationKey(row.deviceId, row.locationId);
|
|
8607
|
+
const seen = unusable.get(key);
|
|
8608
|
+
unusable.set(key, seen === void 0 ? {
|
|
8609
|
+
deviceId: row.deviceId,
|
|
8610
|
+
locationId: row.locationId,
|
|
8611
|
+
held: 1
|
|
8612
|
+
} : {
|
|
8613
|
+
...seen,
|
|
8614
|
+
held: seen.held + 1
|
|
8615
|
+
});
|
|
8616
|
+
}
|
|
8617
|
+
const onDiskByDevice = /* @__PURE__ */ new Map();
|
|
8618
|
+
for (const row of fromIndex.values()) onDiskByDevice.set(row.deviceId, (onDiskByDevice.get(row.deviceId) ?? 0) + 1);
|
|
8619
|
+
const devices = /* @__PURE__ */ new Set();
|
|
8620
|
+
for (const [deviceId, held] of heldByDevice) {
|
|
8621
|
+
if (held < 8) continue;
|
|
8622
|
+
const onDisk = onDiskByDevice.get(deviceId) ?? 0;
|
|
8623
|
+
if (onDisk >= held * .5) continue;
|
|
8624
|
+
devices.add(deviceId);
|
|
8625
|
+
this.logger.warn("recorder: segment-hour ledger prune REFUSED — this walk would delete most of the camera archive, which is what an unmounted root looks like; keeping the previous ledger", {
|
|
8626
|
+
tags: { deviceId },
|
|
8627
|
+
meta: {
|
|
8628
|
+
held,
|
|
8629
|
+
onDisk,
|
|
8630
|
+
dropped: held - onDisk,
|
|
8631
|
+
maxShare: LEDGER_PRUNE_MAX_SHARE
|
|
8632
|
+
}
|
|
8633
|
+
});
|
|
8634
|
+
}
|
|
8635
|
+
const locations = /* @__PURE__ */ new Set();
|
|
8636
|
+
for (const [key, entry] of unusable) {
|
|
8637
|
+
if (devices.has(entry.deviceId)) continue;
|
|
8638
|
+
locations.add(key);
|
|
8639
|
+
this.logger.warn("recorder: segment-hour ledger prune REFUSED — the volume under this location failed its probe, so the walk found nothing because nothing is mounted there", {
|
|
8640
|
+
tags: { deviceId: entry.deviceId },
|
|
8641
|
+
meta: {
|
|
8642
|
+
locationId: entry.locationId,
|
|
8643
|
+
held: entry.held,
|
|
8644
|
+
onDisk: onDiskByDevice.get(entry.deviceId) ?? 0
|
|
8645
|
+
}
|
|
8646
|
+
});
|
|
8647
|
+
}
|
|
8648
|
+
return {
|
|
8649
|
+
devices,
|
|
8650
|
+
locations
|
|
8651
|
+
};
|
|
8652
|
+
}
|
|
8423
8653
|
};
|
|
8654
|
+
function locationKey(deviceId, locationId) {
|
|
8655
|
+
return `${deviceId}|${locationId}`;
|
|
8656
|
+
}
|
|
8424
8657
|
//#endregion
|
|
8425
8658
|
//#region src/recorder/addon/recording-export-provider.ts
|
|
8426
8659
|
/** Content type of every rendered export — the engine only ever writes MP4. */
|
|
@@ -8910,7 +9143,9 @@ var macrotask = () => new Promise((resolve) => {
|
|
|
8910
9143
|
* unnoticed for six weeks precisely because nothing said anything.
|
|
8911
9144
|
*/
|
|
8912
9145
|
async function recoverStagingOrphans(dir, input, deps) {
|
|
8913
|
-
|
|
9146
|
+
const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
9147
|
+
const yieldBetween = deps.yieldBetween ?? macrotask;
|
|
9148
|
+
let names = null;
|
|
8914
9149
|
try {
|
|
8915
9150
|
names = await deps.listDir(dir);
|
|
8916
9151
|
} catch (err) {
|
|
@@ -8918,13 +9153,14 @@ async function recoverStagingOrphans(dir, input, deps) {
|
|
|
8918
9153
|
dir,
|
|
8919
9154
|
error: err instanceof Error ? err.message : String(err)
|
|
8920
9155
|
} });
|
|
8921
|
-
return {
|
|
8922
|
-
found: 0,
|
|
8923
|
-
recovered: 0,
|
|
8924
|
-
failed: 0,
|
|
8925
|
-
aborted: false
|
|
8926
|
-
};
|
|
8927
9156
|
}
|
|
9157
|
+
await yieldBetween();
|
|
9158
|
+
if (names === null) return {
|
|
9159
|
+
found: 0,
|
|
9160
|
+
recovered: 0,
|
|
9161
|
+
failed: 0,
|
|
9162
|
+
aborted: false
|
|
9163
|
+
};
|
|
8928
9164
|
let playlistNames;
|
|
8929
9165
|
const body = await deps.readPlaylist(dir).catch(() => null);
|
|
8930
9166
|
if (body !== null) playlistNames = new Set(parseLivePlaylist(body).map((e) => e.segPath.replace(/^.*\//, "")));
|
|
@@ -8946,8 +9182,6 @@ async function recoverStagingOrphans(dir, input, deps) {
|
|
|
8946
9182
|
oldestMs: plan[0]?.startMs,
|
|
8947
9183
|
newestMs: plan[plan.length - 1]?.startMs
|
|
8948
9184
|
} });
|
|
8949
|
-
const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
8950
|
-
const yieldBetween = deps.yieldBetween ?? macrotask;
|
|
8951
9185
|
let recovered = 0;
|
|
8952
9186
|
let failed = 0;
|
|
8953
9187
|
let aborted = false;
|
|
@@ -9047,24 +9281,41 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9047
9281
|
locations: locations.map((l) => l.id),
|
|
9048
9282
|
bootMs: deps.bootMs
|
|
9049
9283
|
} });
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9284
|
+
/**
|
|
9285
|
+
* One directory listing on the walk, paced through the pass's own pacer.
|
|
9286
|
+
*
|
|
9287
|
+
* "A handful of listings" is what this walk was costed as, and on a WARM
|
|
9288
|
+
* share it is — 4.1 s for the whole thing. Cold, after a power-cycle, each
|
|
9289
|
+
* one is a ~9.4 s FUSE round-trip and 58 of them summed to 547 s with the
|
|
9290
|
+
* loop to themselves, while the controller re-pinned writers 118 times and
|
|
9291
|
+
* the first recording landed at t+714 s.
|
|
9292
|
+
*
|
|
9293
|
+
* Paced per LISTING and not per entry, because that is where the latency
|
|
9294
|
+
* is; and through the SAME `yieldBetween` the recovery uses, so there is one
|
|
9295
|
+
* pacer, one back-off state and one set of counters for the whole pass —
|
|
9296
|
+
* never a second scheduling authority (D167).
|
|
9297
|
+
*/
|
|
9298
|
+
const listPaced = async (dir) => {
|
|
9299
|
+
let entries = null;
|
|
9054
9300
|
try {
|
|
9055
|
-
|
|
9301
|
+
entries = await deps.listDir(dir);
|
|
9056
9302
|
} catch {
|
|
9057
|
-
|
|
9303
|
+
entries = null;
|
|
9058
9304
|
}
|
|
9305
|
+
await yieldBetween();
|
|
9306
|
+
return entries;
|
|
9307
|
+
};
|
|
9308
|
+
const walkStartedMs = Date.now();
|
|
9309
|
+
const work = [];
|
|
9310
|
+
for (const location of locations) {
|
|
9311
|
+
const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
|
|
9312
|
+
const deviceDirs = await listPaced(stagingRoot);
|
|
9313
|
+
if (deviceDirs === null) continue;
|
|
9059
9314
|
for (const deviceDir of deviceDirs) {
|
|
9060
9315
|
const deviceId = Number(deviceDir);
|
|
9061
9316
|
if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
profileDirs = await deps.listDir(`${stagingRoot}/${deviceDir}`);
|
|
9065
|
-
} catch {
|
|
9066
|
-
continue;
|
|
9067
|
-
}
|
|
9317
|
+
const profileDirs = await listPaced(`${stagingRoot}/${deviceDir}`);
|
|
9318
|
+
if (profileDirs === null) continue;
|
|
9068
9319
|
const segmentSeconds = await deps.segmentSecondsFor(deviceId);
|
|
9069
9320
|
for (const profile of profileDirs) {
|
|
9070
9321
|
if (!KNOWN_PROFILES.has(profile)) continue;
|
|
@@ -9078,6 +9329,7 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9078
9329
|
}
|
|
9079
9330
|
}
|
|
9080
9331
|
}
|
|
9332
|
+
const walkMs = Date.now() - walkStartedMs;
|
|
9081
9333
|
const queue = [...work];
|
|
9082
9334
|
const lane = async () => {
|
|
9083
9335
|
for (;;) {
|
|
@@ -9120,6 +9372,7 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9120
9372
|
failed,
|
|
9121
9373
|
aborted,
|
|
9122
9374
|
ms: Date.now() - startedMs,
|
|
9375
|
+
walkMs,
|
|
9123
9376
|
...pacer === null ? {} : { pacing: pacer.stats() }
|
|
9124
9377
|
} });
|
|
9125
9378
|
return {
|
|
@@ -9372,7 +9625,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
9372
9625
|
}
|
|
9373
9626
|
this.segmentHours = new SegmentHourLedger({
|
|
9374
9627
|
store: this.ctx.api.settingsStore,
|
|
9375
|
-
logger: this.ctx.logger
|
|
9628
|
+
logger: this.ctx.logger,
|
|
9629
|
+
isLocationUsable: (locationId) => this.placement?.isLocationUsable(locationId) ?? true
|
|
9376
9630
|
});
|
|
9377
9631
|
try {
|
|
9378
9632
|
await this.segmentHours.declare();
|
|
@@ -10201,20 +10455,14 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
10201
10455
|
playbackRoots() {
|
|
10202
10456
|
return [node_path.default.join(this.ctx.dataDir, "playback"), ...this.resolvedLocations.map((l) => l.root)];
|
|
10203
10457
|
}
|
|
10204
|
-
/**
|
|
10458
|
+
/**
|
|
10459
|
+
* Free + total bytes on a location's volume (`statfs`); null plus a reason
|
|
10460
|
+
* when the volume will not answer. The reason is NOT logged here — the caller
|
|
10461
|
+
* (`getStorageUsage`) knows which location the root belongs to and logs it
|
|
10462
|
+
* against that location.
|
|
10463
|
+
*/
|
|
10205
10464
|
async locationCapacity(root) {
|
|
10206
|
-
|
|
10207
|
-
const st = await node_fs.promises.statfs(root);
|
|
10208
|
-
return {
|
|
10209
|
-
availableBytes: st.bavail * st.bsize,
|
|
10210
|
-
totalBytes: st.blocks * st.bsize
|
|
10211
|
-
};
|
|
10212
|
-
} catch {
|
|
10213
|
-
return {
|
|
10214
|
-
availableBytes: null,
|
|
10215
|
-
totalBytes: null
|
|
10216
|
-
};
|
|
10217
|
-
}
|
|
10465
|
+
return readLocationCapacity(root, (target) => node_fs.promises.statfs(target));
|
|
10218
10466
|
}
|
|
10219
10467
|
/**
|
|
10220
10468
|
* Global settings surfaced in the admin UI (and persisted to the addon
|