@camstack/addon-pipeline 1.2.97 → 1.2.99
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 +253 -33
- package/dist/recorder/index.mjs +253 -33
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-tSj8DwCM.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B7ERJocD.mjs} +2 -2
- package/dist/stream-broker/{hostInit-DzFWvn0v.mjs → hostInit-Cm2rl8-e.mjs} +2 -2
- package/dist/stream-broker/index.js +20 -5
- package/dist/stream-broker/index.mjs +20 -5
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
package/dist/recorder/index.js
CHANGED
|
@@ -5575,13 +5575,20 @@ async function probeLocationVolume(location, rememberedVolumeId, deps) {
|
|
|
5575
5575
|
}
|
|
5576
5576
|
/**
|
|
5577
5577
|
* Where to write instead when a location fails its volume probe: the normal
|
|
5578
|
-
* placement chain over every
|
|
5579
|
-
* sibling of the same type; with
|
|
5580
|
-
* a shouting recorder beats a stopped camera,
|
|
5581
|
-
* one outcome no operator asked for.
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
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));
|
|
5585
5592
|
try {
|
|
5586
5593
|
return resolvePlacement(siblings, profile);
|
|
5587
5594
|
} catch {
|
|
@@ -5632,6 +5639,13 @@ var PlacementService = class {
|
|
|
5632
5639
|
verified = /* @__PURE__ */ new Set();
|
|
5633
5640
|
/** Locations the probe declared unusable — re-probed after a change. */
|
|
5634
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;
|
|
5635
5649
|
/** The fair tie-break the PURE planner defers to (D37). Advisory state, and
|
|
5636
5650
|
* consulted only for a tie the capacity signal cannot separate. */
|
|
5637
5651
|
rotation = new PlacementRotation();
|
|
@@ -5641,6 +5655,12 @@ var PlacementService = class {
|
|
|
5641
5655
|
get probeDeps() {
|
|
5642
5656
|
return this.deps.probe ?? fsVolumeProbeDeps;
|
|
5643
5657
|
}
|
|
5658
|
+
get now() {
|
|
5659
|
+
return this.deps.now?.() ?? Date.now();
|
|
5660
|
+
}
|
|
5661
|
+
get reprobeMs() {
|
|
5662
|
+
return this.deps.reprobeMs ?? 6e4;
|
|
5663
|
+
}
|
|
5644
5664
|
/**
|
|
5645
5665
|
* Forget every probe verdict. Called whenever the location set is re-resolved
|
|
5646
5666
|
* (rescan, storage migration) — a remount is exactly the event the probe
|
|
@@ -5649,6 +5669,8 @@ var PlacementService = class {
|
|
|
5649
5669
|
onLocationsChanged() {
|
|
5650
5670
|
this.verified.clear();
|
|
5651
5671
|
this.unusable.clear();
|
|
5672
|
+
this.allUnusableReported.clear();
|
|
5673
|
+
this.allUnusableProbedAt = null;
|
|
5652
5674
|
}
|
|
5653
5675
|
/**
|
|
5654
5676
|
* Resolve the location for one (camera, profile) at an attach boundary.
|
|
@@ -5657,12 +5679,92 @@ var PlacementService = class {
|
|
|
5657
5679
|
*/
|
|
5658
5680
|
async place(deviceId, profile) {
|
|
5659
5681
|
const pool = this.pool();
|
|
5682
|
+
this.reprobeAfterBackoff(pool);
|
|
5660
5683
|
const resolved = await this.assignedLocation(deviceId, profile, pool) ?? resolvePlacement(pool, profile);
|
|
5661
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);
|
|
5662
5690
|
await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id);
|
|
5663
5691
|
return usable;
|
|
5664
5692
|
}
|
|
5665
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
|
+
/**
|
|
5666
5768
|
* The locations placement may write to: operator-ENABLED and alias-guarded.
|
|
5667
5769
|
* A location the operator has not opted in is still read, swept and drained —
|
|
5668
5770
|
* it is simply never a write target. Creating a disk must not start writing
|
|
@@ -5811,13 +5913,13 @@ var PlacementService = class {
|
|
|
5811
5913
|
* ONCE per location per location-set generation — it is a claim, not a poll.
|
|
5812
5914
|
*/
|
|
5813
5915
|
async ensureUsable(chosen, locations, profile, deviceId) {
|
|
5814
|
-
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);
|
|
5815
5917
|
if (this.verified.has(chosen.id)) return chosen;
|
|
5816
5918
|
const result = await probeLocationVolume(chosen, (await readPlacementState(this.deps.state)).volumeIds[chosen.id] ?? null, this.probeDeps);
|
|
5817
5919
|
if (result.outcome === "mismatch") {
|
|
5818
5920
|
this.unusable.add(chosen.id);
|
|
5819
5921
|
this.reportMountFault(result, deviceId);
|
|
5820
|
-
return failoverForUnusableLocation(chosen, locations, profile);
|
|
5922
|
+
return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
|
|
5821
5923
|
}
|
|
5822
5924
|
if (result.outcome === "unreadable") {
|
|
5823
5925
|
this.deps.logger.warn("recorder placement: volume probe unreadable — assignment unchanged", {
|
|
@@ -8246,6 +8348,17 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [{
|
|
|
8246
8348
|
name: "idx_recorder_segment_hours_hour",
|
|
8247
8349
|
columns: ["hourStartMs"]
|
|
8248
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;
|
|
8249
8362
|
function hourStartMs(startMs) {
|
|
8250
8363
|
return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
|
|
8251
8364
|
}
|
|
@@ -8319,8 +8432,10 @@ var SPEC = {
|
|
|
8319
8432
|
var SegmentHourLedger = class {
|
|
8320
8433
|
ledger;
|
|
8321
8434
|
logger;
|
|
8435
|
+
isLocationUsable;
|
|
8322
8436
|
constructor(deps) {
|
|
8323
8437
|
this.logger = deps.logger;
|
|
8438
|
+
this.isLocationUsable = deps.isLocationUsable ?? (() => true);
|
|
8324
8439
|
this.ledger = new DurableLedger({
|
|
8325
8440
|
spec: SPEC,
|
|
8326
8441
|
store: deps.store,
|
|
@@ -8410,6 +8525,10 @@ var SegmentHourLedger = class {
|
|
|
8410
8525
|
*
|
|
8411
8526
|
* Devices not in `deviceIds` are left untouched (a partial walk must not
|
|
8412
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).
|
|
8413
8532
|
*/
|
|
8414
8533
|
async reconcileFromIndex(index, deviceIds, nowMs) {
|
|
8415
8534
|
const walked = new Set(deviceIds);
|
|
@@ -8431,15 +8550,17 @@ var SegmentHourLedger = class {
|
|
|
8431
8550
|
paths: [...existing.paths, seg.path]
|
|
8432
8551
|
});
|
|
8433
8552
|
}
|
|
8553
|
+
const refused = this.refusedPrunes(walked, fromIndex);
|
|
8434
8554
|
for (const row of this.ledger.snapshot()) {
|
|
8435
8555
|
if (!walked.has(row.deviceId)) continue;
|
|
8436
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));
|
|
8437
8558
|
if (disk === void 0) {
|
|
8438
|
-
if (
|
|
8559
|
+
if (additiveOnly) continue;
|
|
8439
8560
|
await this.ledger.forget(row.key);
|
|
8440
8561
|
continue;
|
|
8441
8562
|
}
|
|
8442
|
-
if (
|
|
8563
|
+
if (additiveOnly) {
|
|
8443
8564
|
const union = uniquePaths([...disk.paths, ...row.paths]);
|
|
8444
8565
|
if (!samePaths(union, row.paths)) await this.ledger.put({
|
|
8445
8566
|
...row,
|
|
@@ -8454,7 +8575,85 @@ var SegmentHourLedger = class {
|
|
|
8454
8575
|
await this.ledger.put(disk);
|
|
8455
8576
|
}
|
|
8456
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
|
+
}
|
|
8457
8653
|
};
|
|
8654
|
+
function locationKey(deviceId, locationId) {
|
|
8655
|
+
return `${deviceId}|${locationId}`;
|
|
8656
|
+
}
|
|
8458
8657
|
//#endregion
|
|
8459
8658
|
//#region src/recorder/addon/recording-export-provider.ts
|
|
8460
8659
|
/** Content type of every rendered export — the engine only ever writes MP4. */
|
|
@@ -8944,7 +9143,9 @@ var macrotask = () => new Promise((resolve) => {
|
|
|
8944
9143
|
* unnoticed for six weeks precisely because nothing said anything.
|
|
8945
9144
|
*/
|
|
8946
9145
|
async function recoverStagingOrphans(dir, input, deps) {
|
|
8947
|
-
|
|
9146
|
+
const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
9147
|
+
const yieldBetween = deps.yieldBetween ?? macrotask;
|
|
9148
|
+
let names = null;
|
|
8948
9149
|
try {
|
|
8949
9150
|
names = await deps.listDir(dir);
|
|
8950
9151
|
} catch (err) {
|
|
@@ -8952,13 +9153,14 @@ async function recoverStagingOrphans(dir, input, deps) {
|
|
|
8952
9153
|
dir,
|
|
8953
9154
|
error: err instanceof Error ? err.message : String(err)
|
|
8954
9155
|
} });
|
|
8955
|
-
return {
|
|
8956
|
-
found: 0,
|
|
8957
|
-
recovered: 0,
|
|
8958
|
-
failed: 0,
|
|
8959
|
-
aborted: false
|
|
8960
|
-
};
|
|
8961
9156
|
}
|
|
9157
|
+
await yieldBetween();
|
|
9158
|
+
if (names === null) return {
|
|
9159
|
+
found: 0,
|
|
9160
|
+
recovered: 0,
|
|
9161
|
+
failed: 0,
|
|
9162
|
+
aborted: false
|
|
9163
|
+
};
|
|
8962
9164
|
let playlistNames;
|
|
8963
9165
|
const body = await deps.readPlaylist(dir).catch(() => null);
|
|
8964
9166
|
if (body !== null) playlistNames = new Set(parseLivePlaylist(body).map((e) => e.segPath.replace(/^.*\//, "")));
|
|
@@ -8980,8 +9182,6 @@ async function recoverStagingOrphans(dir, input, deps) {
|
|
|
8980
9182
|
oldestMs: plan[0]?.startMs,
|
|
8981
9183
|
newestMs: plan[plan.length - 1]?.startMs
|
|
8982
9184
|
} });
|
|
8983
|
-
const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
8984
|
-
const yieldBetween = deps.yieldBetween ?? macrotask;
|
|
8985
9185
|
let recovered = 0;
|
|
8986
9186
|
let failed = 0;
|
|
8987
9187
|
let aborted = false;
|
|
@@ -9081,24 +9281,41 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9081
9281
|
locations: locations.map((l) => l.id),
|
|
9082
9282
|
bootMs: deps.bootMs
|
|
9083
9283
|
} });
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
|
|
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;
|
|
9088
9300
|
try {
|
|
9089
|
-
|
|
9301
|
+
entries = await deps.listDir(dir);
|
|
9090
9302
|
} catch {
|
|
9091
|
-
|
|
9303
|
+
entries = null;
|
|
9092
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;
|
|
9093
9314
|
for (const deviceDir of deviceDirs) {
|
|
9094
9315
|
const deviceId = Number(deviceDir);
|
|
9095
9316
|
if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
|
|
9096
|
-
|
|
9097
|
-
|
|
9098
|
-
profileDirs = await deps.listDir(`${stagingRoot}/${deviceDir}`);
|
|
9099
|
-
} catch {
|
|
9100
|
-
continue;
|
|
9101
|
-
}
|
|
9317
|
+
const profileDirs = await listPaced(`${stagingRoot}/${deviceDir}`);
|
|
9318
|
+
if (profileDirs === null) continue;
|
|
9102
9319
|
const segmentSeconds = await deps.segmentSecondsFor(deviceId);
|
|
9103
9320
|
for (const profile of profileDirs) {
|
|
9104
9321
|
if (!KNOWN_PROFILES.has(profile)) continue;
|
|
@@ -9112,6 +9329,7 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9112
9329
|
}
|
|
9113
9330
|
}
|
|
9114
9331
|
}
|
|
9332
|
+
const walkMs = Date.now() - walkStartedMs;
|
|
9115
9333
|
const queue = [...work];
|
|
9116
9334
|
const lane = async () => {
|
|
9117
9335
|
for (;;) {
|
|
@@ -9154,6 +9372,7 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9154
9372
|
failed,
|
|
9155
9373
|
aborted,
|
|
9156
9374
|
ms: Date.now() - startedMs,
|
|
9375
|
+
walkMs,
|
|
9157
9376
|
...pacer === null ? {} : { pacing: pacer.stats() }
|
|
9158
9377
|
} });
|
|
9159
9378
|
return {
|
|
@@ -9406,7 +9625,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
9406
9625
|
}
|
|
9407
9626
|
this.segmentHours = new SegmentHourLedger({
|
|
9408
9627
|
store: this.ctx.api.settingsStore,
|
|
9409
|
-
logger: this.ctx.logger
|
|
9628
|
+
logger: this.ctx.logger,
|
|
9629
|
+
isLocationUsable: (locationId) => this.placement?.isLocationUsable(locationId) ?? true
|
|
9410
9630
|
});
|
|
9411
9631
|
try {
|
|
9412
9632
|
await this.segmentHours.declare();
|
package/dist/recorder/index.mjs
CHANGED
|
@@ -5574,13 +5574,20 @@ async function probeLocationVolume(location, rememberedVolumeId, deps) {
|
|
|
5574
5574
|
}
|
|
5575
5575
|
/**
|
|
5576
5576
|
* Where to write instead when a location fails its volume probe: the normal
|
|
5577
|
-
* placement chain over every
|
|
5578
|
-
* sibling of the same type; with
|
|
5579
|
-
* a shouting recorder beats a stopped camera,
|
|
5580
|
-
* one outcome no operator asked for.
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5577
|
+
* placement chain over every location NOT already known unusable. With N
|
|
5578
|
+
* locations this is a healthy sibling of the same type; with none left it is
|
|
5579
|
+
* the unusable location itself — a shouting recorder beats a stopped camera,
|
|
5580
|
+
* and refusing to record is the one outcome no operator asked for.
|
|
5581
|
+
*
|
|
5582
|
+
* `alreadyUnusable` is not an optimisation. Without it the search happily
|
|
5583
|
+
* returned a sibling that had ALSO failed its probe, so with two broken disks
|
|
5584
|
+
* every attach handed back the other one and the stored assignment flipped
|
|
5585
|
+
* A→B→A→B forever — one durable write and one audit row per attach, per camera,
|
|
5586
|
+
* per profile. Observed live on 2026-08-19 (the UD disk did not come back after
|
|
5587
|
+
* a reboot): 716 EACCES/min, with the ops-log ring flooded by the flapping.
|
|
5588
|
+
*/
|
|
5589
|
+
function failoverForUnusableLocation(unusable, locations, profile, alreadyUnusable = /* @__PURE__ */ new Set()) {
|
|
5590
|
+
const siblings = locations.filter((l) => l.id !== unusable.id && !alreadyUnusable.has(l.id));
|
|
5584
5591
|
try {
|
|
5585
5592
|
return resolvePlacement(siblings, profile);
|
|
5586
5593
|
} catch {
|
|
@@ -5631,6 +5638,13 @@ var PlacementService = class {
|
|
|
5631
5638
|
verified = /* @__PURE__ */ new Set();
|
|
5632
5639
|
/** Locations the probe declared unusable — re-probed after a change. */
|
|
5633
5640
|
unusable = /* @__PURE__ */ new Set();
|
|
5641
|
+
/** Cameras that have already announced "every location is unusable". One line
|
|
5642
|
+
* per camera per episode: the fault is asked per-camera, and repeating it per
|
|
5643
|
+
* attach is the noise this state exists to end. */
|
|
5644
|
+
allUnusableReported = /* @__PURE__ */ new Set();
|
|
5645
|
+
/** When the current all-unusable episode last (re-)probed. `null` ⇒ no
|
|
5646
|
+
* episode in progress. */
|
|
5647
|
+
allUnusableProbedAt = null;
|
|
5634
5648
|
/** The fair tie-break the PURE planner defers to (D37). Advisory state, and
|
|
5635
5649
|
* consulted only for a tie the capacity signal cannot separate. */
|
|
5636
5650
|
rotation = new PlacementRotation();
|
|
@@ -5640,6 +5654,12 @@ var PlacementService = class {
|
|
|
5640
5654
|
get probeDeps() {
|
|
5641
5655
|
return this.deps.probe ?? fsVolumeProbeDeps;
|
|
5642
5656
|
}
|
|
5657
|
+
get now() {
|
|
5658
|
+
return this.deps.now?.() ?? Date.now();
|
|
5659
|
+
}
|
|
5660
|
+
get reprobeMs() {
|
|
5661
|
+
return this.deps.reprobeMs ?? 6e4;
|
|
5662
|
+
}
|
|
5643
5663
|
/**
|
|
5644
5664
|
* Forget every probe verdict. Called whenever the location set is re-resolved
|
|
5645
5665
|
* (rescan, storage migration) — a remount is exactly the event the probe
|
|
@@ -5648,6 +5668,8 @@ var PlacementService = class {
|
|
|
5648
5668
|
onLocationsChanged() {
|
|
5649
5669
|
this.verified.clear();
|
|
5650
5670
|
this.unusable.clear();
|
|
5671
|
+
this.allUnusableReported.clear();
|
|
5672
|
+
this.allUnusableProbedAt = null;
|
|
5651
5673
|
}
|
|
5652
5674
|
/**
|
|
5653
5675
|
* Resolve the location for one (camera, profile) at an attach boundary.
|
|
@@ -5656,12 +5678,92 @@ var PlacementService = class {
|
|
|
5656
5678
|
*/
|
|
5657
5679
|
async place(deviceId, profile) {
|
|
5658
5680
|
const pool = this.pool();
|
|
5681
|
+
this.reprobeAfterBackoff(pool);
|
|
5659
5682
|
const resolved = await this.assignedLocation(deviceId, profile, pool) ?? resolvePlacement(pool, profile);
|
|
5660
5683
|
const usable = await this.ensureUsable(resolved, pool, profile, deviceId);
|
|
5684
|
+
if (this.everyLocationUnusable(pool)) {
|
|
5685
|
+
this.reportEveryLocationUnusable(deviceId, profile, resolved, pool);
|
|
5686
|
+
return resolved;
|
|
5687
|
+
}
|
|
5688
|
+
this.allUnusableReported.delete(deviceId);
|
|
5661
5689
|
await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id);
|
|
5662
5690
|
return usable;
|
|
5663
5691
|
}
|
|
5664
5692
|
/**
|
|
5693
|
+
* Has the marker probe declared this location's volume NOT the one we last
|
|
5694
|
+
* wrote to?
|
|
5695
|
+
*
|
|
5696
|
+
* Read by the segment-hour ledger: a walk over a root whose volume failed its
|
|
5697
|
+
* probe found nothing because nothing is mounted there, and that must never be
|
|
5698
|
+
* allowed to delete durable rows (D49). Unprobed ⇒ usable, which is honest —
|
|
5699
|
+
* the probe runs at the first writer attach, so at boot it has usually not run
|
|
5700
|
+
* at all, and the ledger's own magnitude guard covers that window.
|
|
5701
|
+
*/
|
|
5702
|
+
isLocationUsable(locationId) {
|
|
5703
|
+
return !this.unusable.has(locationId);
|
|
5704
|
+
}
|
|
5705
|
+
/** True when the pool is non-empty and the probe has failed EVERY member. */
|
|
5706
|
+
everyLocationUnusable(pool) {
|
|
5707
|
+
return pool.length > 0 && pool.every((location) => this.unusable.has(location.id));
|
|
5708
|
+
}
|
|
5709
|
+
/**
|
|
5710
|
+
* Announce the terminal state — ONCE per camera per episode.
|
|
5711
|
+
*
|
|
5712
|
+
* Before this, two unusable locations produced an infinite failover
|
|
5713
|
+
* ping-pong: `failoverForUnusableLocation` did not consult the unusable set,
|
|
5714
|
+
* so each attach handed back the other broken disk, `recordDecision` saw a
|
|
5715
|
+
* changed assignment and wrote the store plus an audit row, and the writer
|
|
5716
|
+
* hit EACCES on a path that was never going to work. Measured on the live hub
|
|
5717
|
+
* on 2026-08-19 with the UD disk unmounted: 716 EACCES/min.
|
|
5718
|
+
*
|
|
5719
|
+
* Recording is NOT refused. With a single location this state is reached the
|
|
5720
|
+
* moment that one location fails, and the recorder keeps attaching to it on
|
|
5721
|
+
* purpose — a shouting recorder beats a stopped camera. What changes is that
|
|
5722
|
+
* it shouts once instead of once per attach, and writes nothing durable while
|
|
5723
|
+
* shouting.
|
|
5724
|
+
*/
|
|
5725
|
+
reportEveryLocationUnusable(deviceId, profile, target, pool) {
|
|
5726
|
+
this.allUnusableProbedAt ??= this.now;
|
|
5727
|
+
if (this.allUnusableReported.has(deviceId)) return;
|
|
5728
|
+
this.allUnusableReported.add(deviceId);
|
|
5729
|
+
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", {
|
|
5730
|
+
tags: { deviceId },
|
|
5731
|
+
meta: {
|
|
5732
|
+
profile,
|
|
5733
|
+
targetLocationId: target.id,
|
|
5734
|
+
root: target.root,
|
|
5735
|
+
locationIds: pool.map((location) => location.id),
|
|
5736
|
+
reprobeMs: this.reprobeMs
|
|
5737
|
+
}
|
|
5738
|
+
});
|
|
5739
|
+
}
|
|
5740
|
+
/**
|
|
5741
|
+
* While every location is unusable, re-probe them all — at most once per
|
|
5742
|
+
* {@link ALL_UNUSABLE_REPROBE_MS}. This is the backoff: without it the probe
|
|
5743
|
+
* memo would hold for the life of the process (only `onLocationsChanged`
|
|
5744
|
+
* clears it) and a disk remounted by an operator would need an addon restart
|
|
5745
|
+
* to be noticed.
|
|
5746
|
+
*/
|
|
5747
|
+
reprobeAfterBackoff(pool) {
|
|
5748
|
+
if (!this.everyLocationUnusable(pool)) {
|
|
5749
|
+
this.allUnusableProbedAt = null;
|
|
5750
|
+
return;
|
|
5751
|
+
}
|
|
5752
|
+
const now = this.now;
|
|
5753
|
+
if (this.allUnusableProbedAt === null) {
|
|
5754
|
+
this.allUnusableProbedAt = now;
|
|
5755
|
+
return;
|
|
5756
|
+
}
|
|
5757
|
+
if (now - this.allUnusableProbedAt < this.reprobeMs) return;
|
|
5758
|
+
this.allUnusableProbedAt = now;
|
|
5759
|
+
this.deps.logger.info("recorder placement: re-probing every storage location after backoff — a disk that came back is picked up here", { meta: {
|
|
5760
|
+
locationIds: pool.map((location) => location.id),
|
|
5761
|
+
backoffMs: this.reprobeMs
|
|
5762
|
+
} });
|
|
5763
|
+
this.unusable.clear();
|
|
5764
|
+
this.verified.clear();
|
|
5765
|
+
}
|
|
5766
|
+
/**
|
|
5665
5767
|
* The locations placement may write to: operator-ENABLED and alias-guarded.
|
|
5666
5768
|
* A location the operator has not opted in is still read, swept and drained —
|
|
5667
5769
|
* it is simply never a write target. Creating a disk must not start writing
|
|
@@ -5810,13 +5912,13 @@ var PlacementService = class {
|
|
|
5810
5912
|
* ONCE per location per location-set generation — it is a claim, not a poll.
|
|
5811
5913
|
*/
|
|
5812
5914
|
async ensureUsable(chosen, locations, profile, deviceId) {
|
|
5813
|
-
if (this.unusable.has(chosen.id)) return failoverForUnusableLocation(chosen, locations, profile);
|
|
5915
|
+
if (this.unusable.has(chosen.id)) return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
|
|
5814
5916
|
if (this.verified.has(chosen.id)) return chosen;
|
|
5815
5917
|
const result = await probeLocationVolume(chosen, (await readPlacementState(this.deps.state)).volumeIds[chosen.id] ?? null, this.probeDeps);
|
|
5816
5918
|
if (result.outcome === "mismatch") {
|
|
5817
5919
|
this.unusable.add(chosen.id);
|
|
5818
5920
|
this.reportMountFault(result, deviceId);
|
|
5819
|
-
return failoverForUnusableLocation(chosen, locations, profile);
|
|
5921
|
+
return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
|
|
5820
5922
|
}
|
|
5821
5923
|
if (result.outcome === "unreadable") {
|
|
5822
5924
|
this.deps.logger.warn("recorder placement: volume probe unreadable — assignment unchanged", {
|
|
@@ -8245,6 +8347,17 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [{
|
|
|
8245
8347
|
name: "idx_recorder_segment_hours_hour",
|
|
8246
8348
|
columns: ["hourStartMs"]
|
|
8247
8349
|
}];
|
|
8350
|
+
/**
|
|
8351
|
+
* The largest share of a device's held hours a single walk may delete.
|
|
8352
|
+
*
|
|
8353
|
+
* On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
|
|
8354
|
+
* the UD disk had not mounted — and one walk took the ledger from 873 hours to
|
|
8355
|
+
* 32 (a 96% drop). The ledger is precisely what lets the NEXT boot skip a
|
|
8356
|
+
* ~400 s recursive walk, so a single bad walk taxed every boot after it. A
|
|
8357
|
+
* retention sweep does not delete half a camera's archive in one pass; a
|
|
8358
|
+
* `readdir` over an unmounted root deletes all of it.
|
|
8359
|
+
*/
|
|
8360
|
+
var LEDGER_PRUNE_MAX_SHARE = .5;
|
|
8248
8361
|
function hourStartMs(startMs) {
|
|
8249
8362
|
return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
|
|
8250
8363
|
}
|
|
@@ -8318,8 +8431,10 @@ var SPEC = {
|
|
|
8318
8431
|
var SegmentHourLedger = class {
|
|
8319
8432
|
ledger;
|
|
8320
8433
|
logger;
|
|
8434
|
+
isLocationUsable;
|
|
8321
8435
|
constructor(deps) {
|
|
8322
8436
|
this.logger = deps.logger;
|
|
8437
|
+
this.isLocationUsable = deps.isLocationUsable ?? (() => true);
|
|
8323
8438
|
this.ledger = new DurableLedger({
|
|
8324
8439
|
spec: SPEC,
|
|
8325
8440
|
store: deps.store,
|
|
@@ -8409,6 +8524,10 @@ var SegmentHourLedger = class {
|
|
|
8409
8524
|
*
|
|
8410
8525
|
* Devices not in `deviceIds` are left untouched (a partial walk must not
|
|
8411
8526
|
* prune cameras it did not look at).
|
|
8527
|
+
*
|
|
8528
|
+
* And a walk this ledger does not TRUST prunes nothing at all — see
|
|
8529
|
+
* {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
|
|
8530
|
+
* are unioned in; what it did not find decides nothing (D49).
|
|
8412
8531
|
*/
|
|
8413
8532
|
async reconcileFromIndex(index, deviceIds, nowMs) {
|
|
8414
8533
|
const walked = new Set(deviceIds);
|
|
@@ -8430,15 +8549,17 @@ var SegmentHourLedger = class {
|
|
|
8430
8549
|
paths: [...existing.paths, seg.path]
|
|
8431
8550
|
});
|
|
8432
8551
|
}
|
|
8552
|
+
const refused = this.refusedPrunes(walked, fromIndex);
|
|
8433
8553
|
for (const row of this.ledger.snapshot()) {
|
|
8434
8554
|
if (!walked.has(row.deviceId)) continue;
|
|
8435
8555
|
const disk = fromIndex.get(row.key);
|
|
8556
|
+
const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
|
|
8436
8557
|
if (disk === void 0) {
|
|
8437
|
-
if (
|
|
8558
|
+
if (additiveOnly) continue;
|
|
8438
8559
|
await this.ledger.forget(row.key);
|
|
8439
8560
|
continue;
|
|
8440
8561
|
}
|
|
8441
|
-
if (
|
|
8562
|
+
if (additiveOnly) {
|
|
8442
8563
|
const union = uniquePaths([...disk.paths, ...row.paths]);
|
|
8443
8564
|
if (!samePaths(union, row.paths)) await this.ledger.put({
|
|
8444
8565
|
...row,
|
|
@@ -8453,7 +8574,85 @@ var SegmentHourLedger = class {
|
|
|
8453
8574
|
await this.ledger.put(disk);
|
|
8454
8575
|
}
|
|
8455
8576
|
}
|
|
8577
|
+
/**
|
|
8578
|
+
* Which of this walk's deletions are REFUSED, and why.
|
|
8579
|
+
*
|
|
8580
|
+
* Two independent reasons, because the live fault of 2026-08-19 would have
|
|
8581
|
+
* escaped either one alone:
|
|
8582
|
+
*
|
|
8583
|
+
* - **the volume said no.** `PlacementService`'s marker probe knows the bytes
|
|
8584
|
+
* under a root are not the bytes we wrote. A walk of such a root found
|
|
8585
|
+
* nothing because nothing is mounted there, not because the footage is
|
|
8586
|
+
* gone.
|
|
8587
|
+
* - **the magnitude said no.** At BOOT the probe has not run yet — it runs at
|
|
8588
|
+
* the first writer attach — so on the morning the recorder respawned onto an
|
|
8589
|
+
* empty tmpfs the volume was not flagged and the prune went through: 873
|
|
8590
|
+
* hours to 32. A walk that deletes more than
|
|
8591
|
+
* {@link LEDGER_PRUNE_MAX_SHARE} of a device's held hours is refused on its
|
|
8592
|
+
* size alone.
|
|
8593
|
+
*
|
|
8594
|
+
* A refusal is one line per device (or per device+location), carrying the
|
|
8595
|
+
* delta, tagged with `deviceId` — a ledger that silently declined to prune and
|
|
8596
|
+
* a ledger with nothing to prune must never read the same.
|
|
8597
|
+
*/
|
|
8598
|
+
refusedPrunes(walked, fromIndex) {
|
|
8599
|
+
const heldByDevice = /* @__PURE__ */ new Map();
|
|
8600
|
+
const unusable = /* @__PURE__ */ new Map();
|
|
8601
|
+
for (const row of this.ledger.snapshot()) {
|
|
8602
|
+
if (!walked.has(row.deviceId)) continue;
|
|
8603
|
+
heldByDevice.set(row.deviceId, (heldByDevice.get(row.deviceId) ?? 0) + 1);
|
|
8604
|
+
if (this.isLocationUsable(row.locationId)) continue;
|
|
8605
|
+
const key = locationKey(row.deviceId, row.locationId);
|
|
8606
|
+
const seen = unusable.get(key);
|
|
8607
|
+
unusable.set(key, seen === void 0 ? {
|
|
8608
|
+
deviceId: row.deviceId,
|
|
8609
|
+
locationId: row.locationId,
|
|
8610
|
+
held: 1
|
|
8611
|
+
} : {
|
|
8612
|
+
...seen,
|
|
8613
|
+
held: seen.held + 1
|
|
8614
|
+
});
|
|
8615
|
+
}
|
|
8616
|
+
const onDiskByDevice = /* @__PURE__ */ new Map();
|
|
8617
|
+
for (const row of fromIndex.values()) onDiskByDevice.set(row.deviceId, (onDiskByDevice.get(row.deviceId) ?? 0) + 1);
|
|
8618
|
+
const devices = /* @__PURE__ */ new Set();
|
|
8619
|
+
for (const [deviceId, held] of heldByDevice) {
|
|
8620
|
+
if (held < 8) continue;
|
|
8621
|
+
const onDisk = onDiskByDevice.get(deviceId) ?? 0;
|
|
8622
|
+
if (onDisk >= held * .5) continue;
|
|
8623
|
+
devices.add(deviceId);
|
|
8624
|
+
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", {
|
|
8625
|
+
tags: { deviceId },
|
|
8626
|
+
meta: {
|
|
8627
|
+
held,
|
|
8628
|
+
onDisk,
|
|
8629
|
+
dropped: held - onDisk,
|
|
8630
|
+
maxShare: LEDGER_PRUNE_MAX_SHARE
|
|
8631
|
+
}
|
|
8632
|
+
});
|
|
8633
|
+
}
|
|
8634
|
+
const locations = /* @__PURE__ */ new Set();
|
|
8635
|
+
for (const [key, entry] of unusable) {
|
|
8636
|
+
if (devices.has(entry.deviceId)) continue;
|
|
8637
|
+
locations.add(key);
|
|
8638
|
+
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", {
|
|
8639
|
+
tags: { deviceId: entry.deviceId },
|
|
8640
|
+
meta: {
|
|
8641
|
+
locationId: entry.locationId,
|
|
8642
|
+
held: entry.held,
|
|
8643
|
+
onDisk: onDiskByDevice.get(entry.deviceId) ?? 0
|
|
8644
|
+
}
|
|
8645
|
+
});
|
|
8646
|
+
}
|
|
8647
|
+
return {
|
|
8648
|
+
devices,
|
|
8649
|
+
locations
|
|
8650
|
+
};
|
|
8651
|
+
}
|
|
8456
8652
|
};
|
|
8653
|
+
function locationKey(deviceId, locationId) {
|
|
8654
|
+
return `${deviceId}|${locationId}`;
|
|
8655
|
+
}
|
|
8457
8656
|
//#endregion
|
|
8458
8657
|
//#region src/recorder/addon/recording-export-provider.ts
|
|
8459
8658
|
/** Content type of every rendered export — the engine only ever writes MP4. */
|
|
@@ -8943,7 +9142,9 @@ var macrotask = () => new Promise((resolve) => {
|
|
|
8943
9142
|
* unnoticed for six weeks precisely because nothing said anything.
|
|
8944
9143
|
*/
|
|
8945
9144
|
async function recoverStagingOrphans(dir, input, deps) {
|
|
8946
|
-
|
|
9145
|
+
const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
9146
|
+
const yieldBetween = deps.yieldBetween ?? macrotask;
|
|
9147
|
+
let names = null;
|
|
8947
9148
|
try {
|
|
8948
9149
|
names = await deps.listDir(dir);
|
|
8949
9150
|
} catch (err) {
|
|
@@ -8951,13 +9152,14 @@ async function recoverStagingOrphans(dir, input, deps) {
|
|
|
8951
9152
|
dir,
|
|
8952
9153
|
error: err instanceof Error ? err.message : String(err)
|
|
8953
9154
|
} });
|
|
8954
|
-
return {
|
|
8955
|
-
found: 0,
|
|
8956
|
-
recovered: 0,
|
|
8957
|
-
failed: 0,
|
|
8958
|
-
aborted: false
|
|
8959
|
-
};
|
|
8960
9155
|
}
|
|
9156
|
+
await yieldBetween();
|
|
9157
|
+
if (names === null) return {
|
|
9158
|
+
found: 0,
|
|
9159
|
+
recovered: 0,
|
|
9160
|
+
failed: 0,
|
|
9161
|
+
aborted: false
|
|
9162
|
+
};
|
|
8961
9163
|
let playlistNames;
|
|
8962
9164
|
const body = await deps.readPlaylist(dir).catch(() => null);
|
|
8963
9165
|
if (body !== null) playlistNames = new Set(parseLivePlaylist(body).map((e) => e.segPath.replace(/^.*\//, "")));
|
|
@@ -8979,8 +9181,6 @@ async function recoverStagingOrphans(dir, input, deps) {
|
|
|
8979
9181
|
oldestMs: plan[0]?.startMs,
|
|
8980
9182
|
newestMs: plan[plan.length - 1]?.startMs
|
|
8981
9183
|
} });
|
|
8982
|
-
const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
8983
|
-
const yieldBetween = deps.yieldBetween ?? macrotask;
|
|
8984
9184
|
let recovered = 0;
|
|
8985
9185
|
let failed = 0;
|
|
8986
9186
|
let aborted = false;
|
|
@@ -9080,24 +9280,41 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9080
9280
|
locations: locations.map((l) => l.id),
|
|
9081
9281
|
bootMs: deps.bootMs
|
|
9082
9282
|
} });
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9283
|
+
/**
|
|
9284
|
+
* One directory listing on the walk, paced through the pass's own pacer.
|
|
9285
|
+
*
|
|
9286
|
+
* "A handful of listings" is what this walk was costed as, and on a WARM
|
|
9287
|
+
* share it is — 4.1 s for the whole thing. Cold, after a power-cycle, each
|
|
9288
|
+
* one is a ~9.4 s FUSE round-trip and 58 of them summed to 547 s with the
|
|
9289
|
+
* loop to themselves, while the controller re-pinned writers 118 times and
|
|
9290
|
+
* the first recording landed at t+714 s.
|
|
9291
|
+
*
|
|
9292
|
+
* Paced per LISTING and not per entry, because that is where the latency
|
|
9293
|
+
* is; and through the SAME `yieldBetween` the recovery uses, so there is one
|
|
9294
|
+
* pacer, one back-off state and one set of counters for the whole pass —
|
|
9295
|
+
* never a second scheduling authority (D167).
|
|
9296
|
+
*/
|
|
9297
|
+
const listPaced = async (dir) => {
|
|
9298
|
+
let entries = null;
|
|
9087
9299
|
try {
|
|
9088
|
-
|
|
9300
|
+
entries = await deps.listDir(dir);
|
|
9089
9301
|
} catch {
|
|
9090
|
-
|
|
9302
|
+
entries = null;
|
|
9091
9303
|
}
|
|
9304
|
+
await yieldBetween();
|
|
9305
|
+
return entries;
|
|
9306
|
+
};
|
|
9307
|
+
const walkStartedMs = Date.now();
|
|
9308
|
+
const work = [];
|
|
9309
|
+
for (const location of locations) {
|
|
9310
|
+
const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
|
|
9311
|
+
const deviceDirs = await listPaced(stagingRoot);
|
|
9312
|
+
if (deviceDirs === null) continue;
|
|
9092
9313
|
for (const deviceDir of deviceDirs) {
|
|
9093
9314
|
const deviceId = Number(deviceDir);
|
|
9094
9315
|
if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
profileDirs = await deps.listDir(`${stagingRoot}/${deviceDir}`);
|
|
9098
|
-
} catch {
|
|
9099
|
-
continue;
|
|
9100
|
-
}
|
|
9316
|
+
const profileDirs = await listPaced(`${stagingRoot}/${deviceDir}`);
|
|
9317
|
+
if (profileDirs === null) continue;
|
|
9101
9318
|
const segmentSeconds = await deps.segmentSecondsFor(deviceId);
|
|
9102
9319
|
for (const profile of profileDirs) {
|
|
9103
9320
|
if (!KNOWN_PROFILES.has(profile)) continue;
|
|
@@ -9111,6 +9328,7 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9111
9328
|
}
|
|
9112
9329
|
}
|
|
9113
9330
|
}
|
|
9331
|
+
const walkMs = Date.now() - walkStartedMs;
|
|
9114
9332
|
const queue = [...work];
|
|
9115
9333
|
const lane = async () => {
|
|
9116
9334
|
for (;;) {
|
|
@@ -9153,6 +9371,7 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9153
9371
|
failed,
|
|
9154
9372
|
aborted,
|
|
9155
9373
|
ms: Date.now() - startedMs,
|
|
9374
|
+
walkMs,
|
|
9156
9375
|
...pacer === null ? {} : { pacing: pacer.stats() }
|
|
9157
9376
|
} });
|
|
9158
9377
|
return {
|
|
@@ -9405,7 +9624,8 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
9405
9624
|
}
|
|
9406
9625
|
this.segmentHours = new SegmentHourLedger({
|
|
9407
9626
|
store: this.ctx.api.settingsStore,
|
|
9408
|
-
logger: this.ctx.logger
|
|
9627
|
+
logger: this.ctx.logger,
|
|
9628
|
+
isLocationUsable: (locationId) => this.placement?.isLocationUsable(locationId) ?? true
|
|
9409
9629
|
});
|
|
9410
9630
|
try {
|
|
9411
9631
|
await this.segmentHours.declare();
|
|
@@ -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.90",
|
|
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.62",
|
|
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.90",
|
|
40
40
|
scope: "default",
|
|
41
41
|
shareConfig: {
|
|
42
42
|
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.62",
|
|
85
85
|
scope: "default",
|
|
86
86
|
shareConfig: {
|
|
87
87
|
singleton: !0,
|
|
@@ -1225,6 +1225,11 @@ var TS_COPYABLE_AUDIO_CODECS = new Set(["aac"]);
|
|
|
1225
1225
|
* ffmpeg's native AAC encoder accepts 8 kHz, the ADTS header carries whatever
|
|
1226
1226
|
* rate came out, and `parseAdtsAudioParams` reads it back for the broker.
|
|
1227
1227
|
*/
|
|
1228
|
+
/**
|
|
1229
|
+
* One second of GOP at the fleet cadence, used when the derived profile names
|
|
1230
|
+
* neither gopFrames nor fps. Matches BASE_LIVE_EGRESS_PROFILE's intent.
|
|
1231
|
+
*/
|
|
1232
|
+
var DERIVED_DEFAULT_GOP_FRAMES = 25;
|
|
1228
1233
|
var DERIVED_AAC_RECODE = {
|
|
1229
1234
|
codec: "aac",
|
|
1230
1235
|
channels: 1
|
|
@@ -1280,10 +1285,15 @@ function resolveDerivedAudio(profileAudio, sourceAudioCodec) {
|
|
|
1280
1285
|
*/
|
|
1281
1286
|
function buildDerivedTranscodeArgs(input) {
|
|
1282
1287
|
const audio = resolveDerivedAudio(input.profile.audio, input.sourceAudioCodec);
|
|
1288
|
+
const video = input.profile.video.gopFrames !== void 0 ? input.profile.video : {
|
|
1289
|
+
...input.profile.video,
|
|
1290
|
+
gopFrames: input.profile.video.fps ?? DERIVED_DEFAULT_GOP_FRAMES
|
|
1291
|
+
};
|
|
1283
1292
|
return {
|
|
1284
1293
|
args: require_dist.buildFfmpegArgs(require_dist.invocationFromEncodeProfile({
|
|
1285
1294
|
profile: {
|
|
1286
1295
|
...input.profile,
|
|
1296
|
+
video,
|
|
1287
1297
|
audio: audio.audio
|
|
1288
1298
|
},
|
|
1289
1299
|
sourceCodec: "h264",
|
|
@@ -25254,11 +25264,16 @@ var TimelineSession = class {
|
|
|
25254
25264
|
if (!locate) return requested;
|
|
25255
25265
|
let withFootage = null;
|
|
25256
25266
|
for (const profile of profileSearchOrder(requested)) {
|
|
25257
|
-
|
|
25258
|
-
|
|
25259
|
-
|
|
25260
|
-
|
|
25261
|
-
|
|
25267
|
+
let loc;
|
|
25268
|
+
try {
|
|
25269
|
+
loc = await locate({
|
|
25270
|
+
deviceId: this.deps.deviceId,
|
|
25271
|
+
profile,
|
|
25272
|
+
epochMs
|
|
25273
|
+
});
|
|
25274
|
+
} catch {
|
|
25275
|
+
continue;
|
|
25276
|
+
}
|
|
25262
25277
|
if (loc.kind === "segment") return profile;
|
|
25263
25278
|
if (withFootage === null && loc.nearestEdgeMs !== null) withFootage = profile;
|
|
25264
25279
|
}
|
|
@@ -1220,6 +1220,11 @@ var TS_COPYABLE_AUDIO_CODECS = new Set(["aac"]);
|
|
|
1220
1220
|
* ffmpeg's native AAC encoder accepts 8 kHz, the ADTS header carries whatever
|
|
1221
1221
|
* rate came out, and `parseAdtsAudioParams` reads it back for the broker.
|
|
1222
1222
|
*/
|
|
1223
|
+
/**
|
|
1224
|
+
* One second of GOP at the fleet cadence, used when the derived profile names
|
|
1225
|
+
* neither gopFrames nor fps. Matches BASE_LIVE_EGRESS_PROFILE's intent.
|
|
1226
|
+
*/
|
|
1227
|
+
var DERIVED_DEFAULT_GOP_FRAMES = 25;
|
|
1223
1228
|
var DERIVED_AAC_RECODE = {
|
|
1224
1229
|
codec: "aac",
|
|
1225
1230
|
channels: 1
|
|
@@ -1275,10 +1280,15 @@ function resolveDerivedAudio(profileAudio, sourceAudioCodec) {
|
|
|
1275
1280
|
*/
|
|
1276
1281
|
function buildDerivedTranscodeArgs(input) {
|
|
1277
1282
|
const audio = resolveDerivedAudio(input.profile.audio, input.sourceAudioCodec);
|
|
1283
|
+
const video = input.profile.video.gopFrames !== void 0 ? input.profile.video : {
|
|
1284
|
+
...input.profile.video,
|
|
1285
|
+
gopFrames: input.profile.video.fps ?? DERIVED_DEFAULT_GOP_FRAMES
|
|
1286
|
+
};
|
|
1278
1287
|
return {
|
|
1279
1288
|
args: buildFfmpegArgs$1(invocationFromEncodeProfile({
|
|
1280
1289
|
profile: {
|
|
1281
1290
|
...input.profile,
|
|
1291
|
+
video,
|
|
1282
1292
|
audio: audio.audio
|
|
1283
1293
|
},
|
|
1284
1294
|
sourceCodec: "h264",
|
|
@@ -25249,11 +25259,16 @@ var TimelineSession = class {
|
|
|
25249
25259
|
if (!locate) return requested;
|
|
25250
25260
|
let withFootage = null;
|
|
25251
25261
|
for (const profile of profileSearchOrder(requested)) {
|
|
25252
|
-
|
|
25253
|
-
|
|
25254
|
-
|
|
25255
|
-
|
|
25256
|
-
|
|
25262
|
+
let loc;
|
|
25263
|
+
try {
|
|
25264
|
+
loc = await locate({
|
|
25265
|
+
deviceId: this.deps.deviceId,
|
|
25266
|
+
profile,
|
|
25267
|
+
epochMs
|
|
25268
|
+
});
|
|
25269
|
+
} catch {
|
|
25270
|
+
continue;
|
|
25271
|
+
}
|
|
25257
25272
|
if (loc.kind === "segment") return profile;
|
|
25258
25273
|
if (withFootage === null && loc.nearestEdgeMs !== null) withFootage = profile;
|
|
25259
25274
|
}
|
|
@@ -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-B7ERJocD.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.99",
|
|
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",
|