@camstack/addon-pipeline 1.2.199 → 1.2.201
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/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-O1uBusV2.mjs → default-detection-model-fUMLQHB2.mjs} +1 -1
- package/dist/{default-detection-model-CMe44Kid.js → default-detection-model-nKgB-_mg.js} +1 -1
- package/dist/detection-pipeline/index.js +5 -5
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-DmCrC2yx.js → dist-Bd875pfj.js} +912 -606
- package/dist/{dist-B23u1tmN.mjs → dist-D2jcEuZD.mjs} +865 -607
- package/dist/{lazy-sharp-DBHsD2lh.js → lazy-sharp-B4KrLp0u.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-D1MLn6oV.mjs → node-CmfXSgr3.mjs} +420 -381
- package/dist/{node-DCSTbt6C.js → node-dwhLC9Fh.js} +423 -378
- package/dist/pipeline-runner/index.js +8 -7
- package/dist/pipeline-runner/index.mjs +7 -6
- package/dist/{process-memory-9lvvhHym.js → process-memory-BDRJK5Vq.js} +1 -1
- package/dist/{process-memory-P24NTDb1.mjs → process-memory-CH5qO7AX.mjs} +1 -1
- package/dist/recorder/index.js +1208 -1015
- package/dist/recorder/index.mjs +1203 -1010
- package/dist/restream-intent-B1Difcnq.js +313 -0
- package/dist/restream-intent-DWe2kInY.mjs +248 -0
- package/dist/{prebuffer-DmBYRQWg.mjs → retire-root-keys-Csb8z2f3.js} +12 -64
- package/dist/{prebuffer-DqdeLSsk.js → retire-root-keys-DZvlso2l.mjs} +1 -99
- package/dist/{segment-demux-js-Bm0woPkn.mjs → segment-demux-js-BHTuEBhC.mjs} +1 -1
- package/dist/{segment-demux-js-Ck_F5bsO.js → segment-demux-js-cmfLROII.js} +1 -1
- package/dist/session-decode/decode-worker-child.js +25 -5
- package/dist/session-decode/decode-worker-child.mjs +24 -4
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Sq93hI-g.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DZcCQR5t.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CrzkEz67.mjs +26 -0
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dj_qoc1e.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-cYr6Swud.mjs} +1 -1
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-COt259tU.mjs → hostInit-CZt5Lq_M.mjs} +3 -3
- package/dist/stream-broker/index.js +181 -48
- package/dist/stream-broker/index.mjs +172 -39
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-c1r1Yddg.js → worker-protocol--PcFsRrE.js} +1 -1
- package/dist/{worker-protocol-BxpGZ0Dt.mjs → worker-protocol-D4GyRNnY.mjs} +1 -1
- package/package.json +5 -1
- package/dist/restream-intent-B4BXZra7.mjs +0 -72
- package/dist/restream-intent-Cv9x3jmu.js +0 -89
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BcjLrxhF.mjs +0 -26
package/dist/recorder/index.js
CHANGED
|
@@ -2,12 +2,12 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
6
|
-
const require_node = require("../node-
|
|
5
|
+
const require_dist = require("../dist-Bd875pfj.js");
|
|
6
|
+
const require_node = require("../node-dwhLC9Fh.js");
|
|
7
7
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
8
|
-
const require_restream_intent = require("../restream-intent-
|
|
8
|
+
const require_restream_intent = require("../restream-intent-B1Difcnq.js");
|
|
9
9
|
const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
|
|
10
|
-
const
|
|
10
|
+
const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
|
|
11
11
|
let node_crypto = require("node:crypto");
|
|
12
12
|
let node_fs = require("node:fs");
|
|
13
13
|
let node_path = require("node:path");
|
|
@@ -1517,14 +1517,17 @@ var RecordingIndex = class {
|
|
|
1517
1517
|
/**
|
|
1518
1518
|
* The physical-device key of a location root: `realpath` of the resolved path,
|
|
1519
1519
|
* or the resolved path string itself when realpath throws (root missing).
|
|
1520
|
+
*
|
|
1521
|
+
* ONE derivation, shared with the orchestrator's same-type root refusal (D389)
|
|
1522
|
+
* — two implementations of "is this the same disk" is two answers waiting to
|
|
1523
|
+
* disagree. This side deliberately DROPS the `resolved` flag: an unresolvable
|
|
1524
|
+
* root falls back to the lexical path, which can only over-partition (treat one
|
|
1525
|
+
* disk as two) and never merge two different disks, the safe failure mode for a
|
|
1526
|
+
* data-loss guard. The orchestrator needs the flag, because a refusal must not
|
|
1527
|
+
* be built on a read that failed.
|
|
1520
1528
|
*/
|
|
1521
1529
|
function physicalRootKey(root, realpath = node_fs.realpathSync) {
|
|
1522
|
-
|
|
1523
|
-
try {
|
|
1524
|
-
return realpath(resolved);
|
|
1525
|
-
} catch {
|
|
1526
|
-
return resolved;
|
|
1527
|
-
}
|
|
1530
|
+
return require_node.physicalRootOf(root, realpath).key;
|
|
1528
1531
|
}
|
|
1529
1532
|
/**
|
|
1530
1533
|
* The eviction DOMAIN of `locationId`: the set of location ids (INCLUDING
|
|
@@ -1539,211 +1542,578 @@ function resolveEvictionDomain(locations, locationId, realpath = node_fs.realpat
|
|
|
1539
1542
|
for (const l of locations) if (physicalRootKey(l.root, realpath) === selfKey) domain.add(l.id);
|
|
1540
1543
|
return domain;
|
|
1541
1544
|
}
|
|
1542
|
-
|
|
1543
|
-
//#region src/recorder/relocate-engine.ts
|
|
1545
|
+
var RECORDING_LOCATION_TYPES = new Set(["recordings", "recordingsLow"]);
|
|
1544
1546
|
/**
|
|
1545
|
-
*
|
|
1546
|
-
*
|
|
1547
|
-
*
|
|
1548
|
-
*
|
|
1549
|
-
* Mechanics, per HOUR (D291): throttled copy + size-verify of every file in the
|
|
1550
|
-
* hour → re-point the DURABLE hour row → delete the sources → re-point the RAM
|
|
1551
|
-
* index. The hour is the batch because the durable index is hour-granular, and
|
|
1552
|
-
* the sources outlive the durable write on purpose: between the copy and the
|
|
1553
|
-
* delete both locations hold the hour, so no crash can leave the index naming a
|
|
1554
|
-
* location without the bytes. The copy is `copy-if-absent` (an equal-size
|
|
1555
|
-
* destination is adopted, not re-copied), so cancel/crash/re-run RESUMES
|
|
1556
|
-
* instead of restarting, and a cancel inside an hour leaves that hour WHOLLY
|
|
1557
|
-
* readable at the source. Throttling is file-level pacing (segments are
|
|
1558
|
-
* ~1–8 MB / ~4 s, so per-file granularity bounds the rate tightly enough) —
|
|
1559
|
-
* the drain is a background chore that must never starve live writers.
|
|
1560
|
-
*
|
|
1561
|
-
* Runs INSIDE the recorder process — container uid, same-uid files, no host
|
|
1562
|
-
* chown trap (see docs/history 2026-07-29).
|
|
1563
|
-
*
|
|
1564
|
-
* ── Phase 2 (D116, multi-location) ────────────────────────────────────────
|
|
1565
|
-
* Two additions the OPERATOR-ARMED rebalance needs:
|
|
1566
|
-
*
|
|
1567
|
-
* - A move is scoped to one CAMERA (and optionally to specific profiles),
|
|
1568
|
-
* not just to a whole disk. The rebalance's unit is a (camera, profile)
|
|
1569
|
-
* pile, because that is the unit the placement plan assigns.
|
|
1570
|
-
* - Concurrent starts QUEUE instead of being refused. The engine is still
|
|
1571
|
-
* single-flight — two movers on one disk thrash both — but a rebalance
|
|
1572
|
-
* enqueues one job per pile, and refusing the second turned a
|
|
1573
|
-
* fifteen-camera rebalance into fifteen manual retries. Jobs run FIFO; a
|
|
1574
|
-
* job cancelled while queued never runs, and a job that FAILS does not
|
|
1575
|
-
* strand the queue behind it.
|
|
1547
|
+
* The recordings CLASS a profile belongs to. The one place this rule is
|
|
1548
|
+
* written: `resolvePlacement`, the planner's demand type and the placement
|
|
1549
|
+
* census must agree on it, and three copies of `profile === 'low' ? … : …` is
|
|
1550
|
+
* how they stop agreeing.
|
|
1576
1551
|
*/
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
/** Progress callback cadence (files) — cheap enough to fire often. */
|
|
1581
|
-
var PROGRESS_EVERY_FILES = 25;
|
|
1582
|
-
/** Free space a destination must keep when it declares no `config.minFreePercent`
|
|
1583
|
-
* — the same default `roots.ts` documents for placement. */
|
|
1584
|
-
var DEFAULT_MIN_FREE_PERCENT$1 = 10;
|
|
1585
|
-
/** Prefix every destination-full error carries, so the coordinator can report
|
|
1586
|
-
* "drain stalled: destination full" instead of a raw ENOSPC string. */
|
|
1587
|
-
var DESTINATION_FULL_PREFIX = "destination full";
|
|
1552
|
+
function placementTypeForProfile(profile) {
|
|
1553
|
+
return profile === "low" ? "recordingsLow" : "recordings";
|
|
1554
|
+
}
|
|
1588
1555
|
/**
|
|
1589
|
-
*
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
1592
|
-
*
|
|
1593
|
-
* or an eviction, so that a transient read error cannot erase an operator's
|
|
1594
|
-
* index of real footage. A per-path ENOENT on a path derived from the row is
|
|
1595
|
-
* strong evidence about THAT path (D296) and weak evidence about a mount: a
|
|
1596
|
-
* root that vanished answers ENOENT for every path it is asked about, and looks
|
|
1597
|
-
* exactly like a location that is genuinely empty. The bound is what makes the
|
|
1598
|
-
* difference survivable — past it the mover keeps moving, keeps skipping, and
|
|
1599
|
-
* REPORTS, so an operator gets a reconcile to act on instead of an archive that
|
|
1600
|
-
* quietly emptied itself.
|
|
1556
|
+
* Narrow a resolved location list to the ones placement may write to: the
|
|
1557
|
+
* enabled ones — ALL of them, together. A location the operator has not opted
|
|
1558
|
+
* in (`enabled === false`) is still READ, age-swept and drained — it is simply
|
|
1559
|
+
* never a write target, exactly like a read-only one.
|
|
1601
1560
|
*
|
|
1602
|
-
*
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1561
|
+
* When no enabled location is writable EVERY location serves instead, loudly.
|
|
1562
|
+
* The orchestrator refuses to leave a recordings class without an enabled
|
|
1563
|
+
* location, so this is reachable only through a hand-edited store — and a
|
|
1564
|
+
* recorder that writes nowhere is worse than one that writes under protest.
|
|
1605
1565
|
*/
|
|
1606
|
-
|
|
1607
|
-
|
|
1566
|
+
function placementPool(locations) {
|
|
1567
|
+
const writable = locations.filter((l) => locationMayBeWritten(l));
|
|
1568
|
+
if (writable.length > 0) return {
|
|
1569
|
+
locations: writable,
|
|
1570
|
+
fellBackToDisabled: false
|
|
1571
|
+
};
|
|
1608
1572
|
return {
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
fromLocationId: j.fromLocationId,
|
|
1612
|
-
toLocationId: j.toLocationId,
|
|
1613
|
-
deviceId: j.deviceId,
|
|
1614
|
-
entities: [...j.entities],
|
|
1615
|
-
filesMoved: j.filesMoved,
|
|
1616
|
-
bytesMoved: j.bytesMoved,
|
|
1617
|
-
filesTotal: j.filesTotal,
|
|
1618
|
-
rowsReconciled: j.rowsReconciled,
|
|
1619
|
-
rowsForgotten: j.rowsForgotten,
|
|
1620
|
-
startedAt: j.startedAt,
|
|
1621
|
-
finishedAt: j.finishedAt,
|
|
1622
|
-
error: j.error
|
|
1573
|
+
locations: locations.filter((l) => locationMayBeRead(l)),
|
|
1574
|
+
fellBackToDisabled: true
|
|
1623
1575
|
};
|
|
1624
1576
|
}
|
|
1625
1577
|
/**
|
|
1626
|
-
* The
|
|
1578
|
+
* The state question, put to the ONE interpreter.
|
|
1627
1579
|
*
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1580
|
+
* The legacy mirrors are handed over too, so a location built by a caller that
|
|
1581
|
+
* has not moved to `recordingsLocationOfMode` yet is folded by the SAME rule
|
|
1582
|
+
* that folds an unmigrated persisted row (`mode` absent → `readOnly` → `enabled`
|
|
1583
|
+
* → active). There is still exactly one interpretation; the mirrors and this
|
|
1584
|
+
* argument disappear together next release.
|
|
1631
1585
|
*/
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1586
|
+
function stateOf(location) {
|
|
1587
|
+
return {
|
|
1588
|
+
...location.mode !== void 0 ? { mode: location.mode } : {},
|
|
1589
|
+
...location.enabled !== void 0 ? { enabled: location.enabled } : {},
|
|
1590
|
+
config: location.readOnly === true ? { readOnly: true } : {}
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
/** The resolved state of a location, for a message or a log line. */
|
|
1594
|
+
function locationMode(location) {
|
|
1595
|
+
return require_dist.resolveLocationMode(stateOf(location));
|
|
1596
|
+
}
|
|
1597
|
+
/** May the recorder write here? THE policy, never a local re-derivation. */
|
|
1598
|
+
function locationMayBeWritten(location) {
|
|
1599
|
+
return require_dist.mayWriteToLocation(stateOf(location));
|
|
1600
|
+
}
|
|
1639
1601
|
/**
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
1643
|
-
* Split out of {@link moveFile} because the durable index re-point has to
|
|
1644
|
-
* happen while BOTH locations still hold the bytes — see
|
|
1645
|
-
* `SegmentHourLedger.repointSegments`.
|
|
1646
|
-
*
|
|
1647
|
-
* **`recordedBytes` is never the verify's reference.** It used to be, and it
|
|
1648
|
-
* cannot be: `fs.copyFile` truncates and replaces, so the destination always
|
|
1649
|
-
* equals the source, and comparing that result against a number the LEDGER
|
|
1650
|
-
* remembers can only ever fail when the ledger is wrong. On 2026-08-30 that
|
|
1651
|
-
* reported a byte-perfect copy as corruption and killed a 110 749-file drain
|
|
1652
|
-
* on its 12 440th file:
|
|
1653
|
-
*
|
|
1654
|
-
* relocate verify failed: …/1787926410000-9966-274432.m4s
|
|
1655
|
-
* is 783801B, expected 274432B
|
|
1656
|
-
*
|
|
1657
|
-
* Both files measured 783801 B on the hub. What is faithful is "the
|
|
1658
|
-
* destination equals the source", and that is what is asserted here; the
|
|
1659
|
-
* recorded number survives only as the reference for a destination whose
|
|
1660
|
-
* source is already GONE, where there is nothing else left to compare against.
|
|
1602
|
+
* A resolved location, with every derived mirror written from the ONE state.
|
|
1603
|
+
* Every `RecordingsLocation` in the recorder comes from here.
|
|
1661
1604
|
*/
|
|
1662
|
-
|
|
1663
|
-
const dstBytes = await fileSize(dst);
|
|
1664
|
-
const srcBytes = await fileSize(src);
|
|
1665
|
-
if (dstBytes !== null && dstBytes === (srcBytes ?? recordedBytes)) return {
|
|
1666
|
-
copied: 0,
|
|
1667
|
-
srcBytes: dstBytes
|
|
1668
|
-
};
|
|
1669
|
-
if (srcBytes === null) throw new RelocateSourceMissingError(src);
|
|
1670
|
-
await node_fs.promises.mkdir(node_path.default.dirname(dst), { recursive: true });
|
|
1671
|
-
await node_fs.promises.copyFile(src, dst);
|
|
1672
|
-
const after = await node_fs.promises.stat(dst);
|
|
1673
|
-
if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
|
|
1605
|
+
function recordingsLocationOfMode(base, mode) {
|
|
1674
1606
|
return {
|
|
1675
|
-
|
|
1676
|
-
|
|
1607
|
+
...base,
|
|
1608
|
+
mode,
|
|
1609
|
+
readOnly: !require_dist.modeMayWrite(mode),
|
|
1610
|
+
enabled: require_dist.modeMayWrite(mode)
|
|
1677
1611
|
};
|
|
1678
1612
|
}
|
|
1679
|
-
/**
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
} catch {
|
|
1684
|
-
return null;
|
|
1685
|
-
}
|
|
1613
|
+
/** May the recorder read here? A `disabled` location may not — and that is an
|
|
1614
|
+
* operator CHOICE, reported as unavailable, never as an unknown location. */
|
|
1615
|
+
function locationMayBeRead(location) {
|
|
1616
|
+
return require_dist.mayReadLocation(stateOf(location));
|
|
1686
1617
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1618
|
+
/**
|
|
1619
|
+
* Route a recording profile to a storage location, for a (camera, profile) the
|
|
1620
|
+
* PLANNER has not placed yet — the first attach of a new camera, before the
|
|
1621
|
+
* 30 s pass runs.
|
|
1622
|
+
*
|
|
1623
|
+
* The rule, in order:
|
|
1624
|
+
* 1. the location's declared `type` — `low` prefers `recordingsLow`, every
|
|
1625
|
+
* other profile prefers `recordings`; when the preferred type has only
|
|
1626
|
+
* read-only instances, any writable recordings-type location serves;
|
|
1627
|
+
* 2. among those, the one with the most MEASURED headroom
|
|
1628
|
+
* (`min(free − minFree floor, maxUsedGb − used)`) — the same signal the
|
|
1629
|
+
* planner balances on, so the bootstrap pick and the plan agree instead of
|
|
1630
|
+
* the plan having to undo the pick at the next tick;
|
|
1631
|
+
* 3. ties, and a pool where nothing was measured, fall to the
|
|
1632
|
+
* lexicographically first id — deterministic, never "whichever came back
|
|
1633
|
+
* first from the cap".
|
|
1634
|
+
*
|
|
1635
|
+
* There is no preferred instance any more (D383): every enabled location of a
|
|
1636
|
+
* class is a write target, and which one a camera gets is a capacity question,
|
|
1637
|
+
* not a flag. Throws when NO writable location exists — recording cannot start,
|
|
1638
|
+
* and that must be loud.
|
|
1639
|
+
*/
|
|
1640
|
+
function resolvePlacement(locations, profile) {
|
|
1641
|
+
if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
|
|
1642
|
+
const writable = locations.filter((l) => locationMayBeWritten(l));
|
|
1643
|
+
if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
|
|
1644
|
+
const preferType = profile === "low" ? "recordingsLow" : "recordings";
|
|
1645
|
+
const preferred = writable.filter((l) => l.type === preferType);
|
|
1646
|
+
return emptiestOf(preferred.length > 0 ? preferred : writable);
|
|
1647
|
+
}
|
|
1648
|
+
/** The candidate with the most measured headroom; ties (and an unmeasured
|
|
1649
|
+
* pool, where every headroom is absent) by lexicographic id. */
|
|
1650
|
+
function emptiestOf(candidates) {
|
|
1651
|
+
return candidates.toSorted((a, b) => {
|
|
1652
|
+
const byHeadroom = (b.headroomBytes ?? 0) - (a.headroomBytes ?? 0);
|
|
1653
|
+
return byHeadroom !== 0 ? byHeadroom : a.id.localeCompare(b.id);
|
|
1654
|
+
})[0];
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Attribute a discovered segment on an aliased physical root by its profile,
|
|
1658
|
+
* not by whichever logical location happened to be hydrated last. Stock
|
|
1659
|
+
* `recordings` and `recordingsLow` deliberately share a root: high/mid belong
|
|
1660
|
+
* to `recordings`, while low belongs to `recordingsLow`.
|
|
1661
|
+
*/
|
|
1662
|
+
function locationForHydratedProfile(aliases, profile) {
|
|
1663
|
+
const preferredType = profile === "low" ? "recordingsLow" : "recordings";
|
|
1664
|
+
const preferred = aliases.filter((location) => location.type === preferredType).toSorted((a, b) => a.id.localeCompare(b.id))[0];
|
|
1665
|
+
const fallback = aliases.toSorted((a, b) => a.id.localeCompare(b.id))[0];
|
|
1666
|
+
if (preferred) return preferred;
|
|
1667
|
+
if (fallback) return fallback;
|
|
1668
|
+
throw new Error("recorder: no location aliases to attribute hydrated profile");
|
|
1669
|
+
}
|
|
1670
|
+
function aliasesByRoot(locations) {
|
|
1671
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1672
|
+
for (const location of locations) {
|
|
1673
|
+
const aliases = grouped.get(location.root);
|
|
1674
|
+
if (aliases) aliases.push(location);
|
|
1675
|
+
else grouped.set(location.root, [location]);
|
|
1699
1676
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1677
|
+
return grouped;
|
|
1678
|
+
}
|
|
1679
|
+
/** One numeric key lifted out of a location's untyped `config` record, or
|
|
1680
|
+
* nothing at all when it is absent or not a finite number. Absent is a real
|
|
1681
|
+
* answer here: the caller falls back to a documented default, and a garbage
|
|
1682
|
+
* value must never become a capacity limit. */
|
|
1683
|
+
function numericConfig(config, key, field) {
|
|
1684
|
+
const raw = config[key];
|
|
1685
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return {};
|
|
1686
|
+
return { [field]: raw };
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Resolve every recordings storage location (`recordings` + `recordingsLow`)
|
|
1690
|
+
* to `{ id, root }`. Returns an empty list when the storage cap is unreachable
|
|
1691
|
+
* or no recordings location exists — callers degrade rather than crash.
|
|
1692
|
+
*/
|
|
1693
|
+
async function resolveRecordingsLocations(api, logger) {
|
|
1694
|
+
try {
|
|
1695
|
+
const recordings = (await api.storage.listLocations.query({})).filter((l) => RECORDING_LOCATION_TYPES.has(l.type));
|
|
1696
|
+
return Promise.all(recordings.map(async (l) => recordingsLocationOfMode({
|
|
1697
|
+
id: l.id,
|
|
1698
|
+
type: l.type,
|
|
1699
|
+
root: await api.storage.resolve.query({
|
|
1700
|
+
location: l.id,
|
|
1701
|
+
relativePath: ""
|
|
1702
|
+
}),
|
|
1703
|
+
...numericConfig(l.config, "minFreePercent", "minFreePercent"),
|
|
1704
|
+
...numericConfig(l.config, "maxUsedGb", "maxUsedGb")
|
|
1705
|
+
}, require_dist.resolveLocationMode(l))));
|
|
1706
|
+
} catch (err) {
|
|
1707
|
+
logger.warn("recorder: listLocations failed — no recordings locations resolved", { meta: { error: require_dist.errMsg(err) } });
|
|
1708
|
+
return [];
|
|
1721
1709
|
}
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Hydrate the v2 `RecordingIndex` for one device from every recordings location
|
|
1713
|
+
* by recursively walking `<root>/<deviceId>/` for `*.m4s` segments and feeding
|
|
1714
|
+
* the location-relative paths (`<deviceId>/<profile>/Y/M/D/H/<file>.m4s`, which
|
|
1715
|
+
* `parseSegmentPath` understands) to `index.hydrateDevice`. A missing device dir
|
|
1716
|
+
* (no footage yet) and any per-location failure are warned + skipped
|
|
1717
|
+
* (best-effort), so one bad volume never blocks the rest of the hydrate.
|
|
1718
|
+
*/
|
|
1719
|
+
async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
|
|
1720
|
+
const yieldBetween = options.yieldBetween ?? macrotask$3;
|
|
1721
|
+
const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
|
|
1722
|
+
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
1723
|
+
const deviceDir = node_path.default.join(root, String(deviceId));
|
|
1724
|
+
const startedMs = Date.now();
|
|
1725
|
+
let entries;
|
|
1726
|
+
try {
|
|
1727
|
+
entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
|
|
1728
|
+
} catch (err) {
|
|
1729
|
+
if (err.code === "ENOENT") {
|
|
1730
|
+
for (const location of aliases) index.hydrateDevice(deviceId, location.id, []);
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
logger.warn("recorder: hydrateDevice walk failed for location", {
|
|
1734
|
+
tags: { deviceId },
|
|
1735
|
+
meta: {
|
|
1736
|
+
locationIds: aliases.map((location) => location.id),
|
|
1737
|
+
error: require_dist.errMsg(err)
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
const walkedMs = Date.now();
|
|
1743
|
+
const locationForProfile = /* @__PURE__ */ new Map();
|
|
1744
|
+
const pathsByLocation = /* @__PURE__ */ new Map();
|
|
1745
|
+
let handled = 0;
|
|
1746
|
+
for (const entry of entries) {
|
|
1747
|
+
if (!entry.endsWith(".m4s")) continue;
|
|
1748
|
+
const relative = `${deviceId}/${entry.split(node_path.default.sep).join("/")}`;
|
|
1749
|
+
const profile = relative.split("/")[1] ?? "";
|
|
1750
|
+
let locationId = locationForProfile.get(profile);
|
|
1751
|
+
if (locationId === void 0) {
|
|
1752
|
+
locationId = locationForHydratedProfile(aliases, profile).id;
|
|
1753
|
+
locationForProfile.set(profile, locationId);
|
|
1754
|
+
}
|
|
1755
|
+
const paths = pathsByLocation.get(locationId);
|
|
1756
|
+
if (paths) paths.push(relative);
|
|
1757
|
+
else pathsByLocation.set(locationId, [relative]);
|
|
1758
|
+
handled += 1;
|
|
1759
|
+
if (handled % chunkSize === 0) await yieldBetween();
|
|
1760
|
+
}
|
|
1761
|
+
for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
|
|
1762
|
+
let indexed = 0;
|
|
1763
|
+
const reuseBefore = index.hydrateReuseStats();
|
|
1764
|
+
for (const [locationId, relPaths] of pathsByLocation) {
|
|
1765
|
+
index.hydrateDevice(deviceId, locationId, relPaths);
|
|
1766
|
+
indexed += relPaths.length;
|
|
1767
|
+
}
|
|
1768
|
+
const reuseAfter = index.hydrateReuseStats();
|
|
1769
|
+
const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
|
|
1770
|
+
const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
|
|
1771
|
+
logger.info("recorder: hydrated a device from one recordings root", {
|
|
1772
|
+
tags: { deviceId },
|
|
1773
|
+
meta: {
|
|
1774
|
+
root,
|
|
1775
|
+
entries: entries.length,
|
|
1776
|
+
indexed,
|
|
1777
|
+
reused,
|
|
1778
|
+
allocated,
|
|
1779
|
+
locationIds: [...pathsByLocation.keys()],
|
|
1780
|
+
walkMs: walkedMs - startedMs,
|
|
1781
|
+
ms: Date.now() - startedMs
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
/** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
|
|
1787
|
+
var HYDRATE_CHUNK = 2e4;
|
|
1788
|
+
var macrotask$3 = () => new Promise((resolve) => {
|
|
1789
|
+
setImmediate(resolve);
|
|
1790
|
+
});
|
|
1791
|
+
/** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
|
|
1792
|
+
function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
1793
|
+
const d = new Date(hourStartMs);
|
|
1794
|
+
const p2 = (n) => String(n).padStart(2, "0");
|
|
1795
|
+
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
1796
|
+
}
|
|
1797
|
+
var HOUR_MS$7 = 36e5;
|
|
1798
|
+
/**
|
|
1799
|
+
* Hydrate ONLY the hours a window spans, instead of walking the archive.
|
|
1800
|
+
*
|
|
1801
|
+
* `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
|
|
1802
|
+
* subtree — ">1M files and takes minutes" per its own comment. Until it lands,
|
|
1803
|
+
* every read of past footage sees an empty index, which the server reports as
|
|
1804
|
+
* "no footage": measured after a deploy, a camera-day held 7 segments and grew
|
|
1805
|
+
* only at the rate of NEW writes for ~9 minutes.
|
|
1806
|
+
*
|
|
1807
|
+
* The path convention makes the window addressable directly: one SHALLOW
|
|
1808
|
+
* `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
|
|
1809
|
+
* background full walk still runs, for the consumers that need the whole
|
|
1810
|
+
* archive (footprint, eviction domains, retention).
|
|
1811
|
+
*
|
|
1812
|
+
* Hours already walked are skipped, so a drag across an hour costs nothing the
|
|
1813
|
+
* second time.
|
|
1814
|
+
*
|
|
1815
|
+
* ## The ledger is the first stop for a CLOSED hour (D287)
|
|
1816
|
+
*
|
|
1817
|
+
* Boot no longer replays the archive into the index, so a ledger-known hour
|
|
1818
|
+
* arrives here `unknown` and pays ONE point read of its hour row instead of a
|
|
1819
|
+
* `readdir` — measured at ~650 ms per hour directory on the live share under
|
|
1820
|
+
* load, against the array the scrub feeder is competing for. Hours the ledger
|
|
1821
|
+
* does not know, and every hour if the read fails, fall through to the walk
|
|
1822
|
+
* exactly as before: the failure direction is toward the disk, never toward an
|
|
1823
|
+
* hour that renders empty because nobody read it.
|
|
1824
|
+
*
|
|
1825
|
+
* **The live hour is never served from the ledger.** It is the one hour whose
|
|
1826
|
+
* durable row is knowably behind the disk: a segment finalized after the last
|
|
1827
|
+
* persist and before a crash is on the array and not in the row. Boot used to
|
|
1828
|
+
* mark every ledger hour `hydrated`, which made that tail invisible to
|
|
1829
|
+
* playback and the timeline FOREVER, not merely under-reported. Excluding the
|
|
1830
|
+
* live hour costs exactly one hour-directory read per process — the walk marks
|
|
1831
|
+
* it, so the second reader pays nothing — and it is what turns a lost tail
|
|
1832
|
+
* back into a recoverable one.
|
|
1833
|
+
*
|
|
1834
|
+
* A CLOSED hour already walked is skipped, which is what makes that exclusion
|
|
1835
|
+
* affordable: without it, every window touching the live hour would re-walk
|
|
1836
|
+
* all of its hours. The past is immutable, so re-reading a closed hour
|
|
1837
|
+
* directory can only ever return what the index already holds; eviction
|
|
1838
|
+
* removes rows through the index directly.
|
|
1839
|
+
*/
|
|
1840
|
+
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
|
|
1841
|
+
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1842
|
+
const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
|
|
1843
|
+
if (ledger !== void 0) {
|
|
1844
|
+
const startedMs = Date.now();
|
|
1845
|
+
const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
|
|
1846
|
+
let segments = 0;
|
|
1847
|
+
let served = 0;
|
|
1848
|
+
for (const hour of hours) {
|
|
1849
|
+
if (hour.hourStartMs >= liveHour) continue;
|
|
1850
|
+
index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
1851
|
+
segments += hour.paths.length;
|
|
1852
|
+
served += 1;
|
|
1853
|
+
}
|
|
1854
|
+
if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
|
|
1855
|
+
tags: { deviceId },
|
|
1856
|
+
meta: {
|
|
1857
|
+
hours: served,
|
|
1858
|
+
segments,
|
|
1859
|
+
ms: Date.now() - startedMs
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1863
|
+
}
|
|
1864
|
+
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
1865
|
+
const deviceDir = node_path.default.join(root, String(deviceId));
|
|
1866
|
+
let profiles;
|
|
1867
|
+
try {
|
|
1868
|
+
profiles = (await node_fs.promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
|
|
1869
|
+
} catch (err) {
|
|
1870
|
+
if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
|
|
1871
|
+
tags: { deviceId },
|
|
1872
|
+
meta: {
|
|
1873
|
+
locationIds: aliases.map((location) => location.id),
|
|
1874
|
+
error: require_dist.errMsg(err)
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
continue;
|
|
1878
|
+
}
|
|
1879
|
+
const first = Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7;
|
|
1880
|
+
const reuseBefore = index.hydrateReuseStats();
|
|
1881
|
+
let hoursRead = 0;
|
|
1882
|
+
let repaired = 0;
|
|
1883
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
|
|
1884
|
+
if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
|
|
1885
|
+
for (const profile of profiles) {
|
|
1886
|
+
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
1887
|
+
let files;
|
|
1888
|
+
try {
|
|
1889
|
+
files = await node_fs.promises.readdir(node_path.default.join(root, rel));
|
|
1890
|
+
} catch {
|
|
1891
|
+
continue;
|
|
1892
|
+
}
|
|
1893
|
+
hoursRead += 1;
|
|
1894
|
+
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
1895
|
+
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
1896
|
+
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
1897
|
+
if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$7 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
|
|
1898
|
+
repaired += 1;
|
|
1899
|
+
await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
index.markHydrated(deviceId, fromMs, toMs);
|
|
1904
|
+
if (hoursRead === 0) continue;
|
|
1905
|
+
const reuseAfter = index.hydrateReuseStats();
|
|
1906
|
+
const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
|
|
1907
|
+
const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
|
|
1908
|
+
logger.info("recorder: hydrated a window from one recordings root", {
|
|
1909
|
+
tags: { deviceId },
|
|
1910
|
+
meta: {
|
|
1911
|
+
root,
|
|
1912
|
+
hoursRead,
|
|
1913
|
+
repaired,
|
|
1914
|
+
reused,
|
|
1915
|
+
allocated
|
|
1745
1916
|
}
|
|
1746
1917
|
});
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
//#endregion
|
|
1921
|
+
//#region src/recorder/relocate-engine.ts
|
|
1922
|
+
/**
|
|
1923
|
+
* Footage relocation engine (entity-routing spec, Phase 4) — the mover half
|
|
1924
|
+
* of disk decommissioning: mark the old location read-only (Phase 2), then
|
|
1925
|
+
* relocate its footage here, then delete the location.
|
|
1926
|
+
*
|
|
1927
|
+
* Mechanics, per HOUR (D291): throttled copy + size-verify of every file in the
|
|
1928
|
+
* hour → re-point the DURABLE hour row → delete the sources → re-point the RAM
|
|
1929
|
+
* index. The hour is the batch because the durable index is hour-granular, and
|
|
1930
|
+
* the sources outlive the durable write on purpose: between the copy and the
|
|
1931
|
+
* delete both locations hold the hour, so no crash can leave the index naming a
|
|
1932
|
+
* location without the bytes. The copy is `copy-if-absent` (an equal-size
|
|
1933
|
+
* destination is adopted, not re-copied), so cancel/crash/re-run RESUMES
|
|
1934
|
+
* instead of restarting, and a cancel inside an hour leaves that hour WHOLLY
|
|
1935
|
+
* readable at the source. Throttling is file-level pacing (segments are
|
|
1936
|
+
* ~1–8 MB / ~4 s, so per-file granularity bounds the rate tightly enough) —
|
|
1937
|
+
* the drain is a background chore that must never starve live writers.
|
|
1938
|
+
*
|
|
1939
|
+
* Runs INSIDE the recorder process — container uid, same-uid files, no host
|
|
1940
|
+
* chown trap (see docs/history 2026-07-29).
|
|
1941
|
+
*
|
|
1942
|
+
* ── Phase 2 (D116, multi-location) ────────────────────────────────────────
|
|
1943
|
+
* Two additions the OPERATOR-ARMED rebalance needs:
|
|
1944
|
+
*
|
|
1945
|
+
* - A move is scoped to one CAMERA (and optionally to specific profiles),
|
|
1946
|
+
* not just to a whole disk. The rebalance's unit is a (camera, profile)
|
|
1947
|
+
* pile, because that is the unit the placement plan assigns.
|
|
1948
|
+
* - Concurrent starts QUEUE instead of being refused. The engine is still
|
|
1949
|
+
* single-flight — two movers on one disk thrash both — but a rebalance
|
|
1950
|
+
* enqueues one job per pile, and refusing the second turned a
|
|
1951
|
+
* fifteen-camera rebalance into fifteen manual retries. Jobs run FIFO; a
|
|
1952
|
+
* job cancelled while queued never runs, and a job that FAILS does not
|
|
1953
|
+
* strand the queue behind it.
|
|
1954
|
+
*/
|
|
1955
|
+
var DEFAULT_THROTTLE_MBPS = 40;
|
|
1956
|
+
/** The durable index's bucket, and therefore this mover's batch. */
|
|
1957
|
+
var HOUR_MS$6 = 36e5;
|
|
1958
|
+
/** Progress callback cadence (files) — cheap enough to fire often. */
|
|
1959
|
+
var PROGRESS_EVERY_FILES = 25;
|
|
1960
|
+
/** Free space a destination must keep when it declares no `config.minFreePercent`
|
|
1961
|
+
* — the same default `roots.ts` documents for placement. */
|
|
1962
|
+
var DEFAULT_MIN_FREE_PERCENT$1 = 10;
|
|
1963
|
+
/** Prefix every destination-full error carries, so the coordinator can report
|
|
1964
|
+
* "drain stalled: destination full" instead of a raw ENOSPC string. */
|
|
1965
|
+
var DESTINATION_FULL_PREFIX = "destination full";
|
|
1966
|
+
/**
|
|
1967
|
+
* Ghost rows one job may FORGET, as a share of its own census — and the floor
|
|
1968
|
+
* below which the share is not the bound.
|
|
1969
|
+
*
|
|
1970
|
+
* D148 is why this is bounded at all: the ledger forgets only on a trusted walk
|
|
1971
|
+
* or an eviction, so that a transient read error cannot erase an operator's
|
|
1972
|
+
* index of real footage. A per-path ENOENT on a path derived from the row is
|
|
1973
|
+
* strong evidence about THAT path (D296) and weak evidence about a mount: a
|
|
1974
|
+
* root that vanished answers ENOENT for every path it is asked about, and looks
|
|
1975
|
+
* exactly like a location that is genuinely empty. The bound is what makes the
|
|
1976
|
+
* difference survivable — past it the mover keeps moving, keeps skipping, and
|
|
1977
|
+
* REPORTS, so an operator gets a reconcile to act on instead of an archive that
|
|
1978
|
+
* quietly emptied itself.
|
|
1979
|
+
*
|
|
1980
|
+
* 17.5 % of `recordingsLow:default` was ghosts on 2026-08-30 (11 074 of
|
|
1981
|
+
* 63 346), so a quarter admits a real reconcile of that size in one pass while
|
|
1982
|
+
* refusing a whole-location erase.
|
|
1983
|
+
*/
|
|
1984
|
+
var GHOST_FORGET_SHARE = .25;
|
|
1985
|
+
function snapshot(j) {
|
|
1986
|
+
return {
|
|
1987
|
+
jobId: j.jobId,
|
|
1988
|
+
state: j.state,
|
|
1989
|
+
fromLocationId: j.fromLocationId,
|
|
1990
|
+
toLocationId: j.toLocationId,
|
|
1991
|
+
deviceId: j.deviceId,
|
|
1992
|
+
entities: [...j.entities],
|
|
1993
|
+
filesMoved: j.filesMoved,
|
|
1994
|
+
bytesMoved: j.bytesMoved,
|
|
1995
|
+
filesTotal: j.filesTotal,
|
|
1996
|
+
rowsReconciled: j.rowsReconciled,
|
|
1997
|
+
rowsForgotten: j.rowsForgotten,
|
|
1998
|
+
startedAt: j.startedAt,
|
|
1999
|
+
finishedAt: j.finishedAt,
|
|
2000
|
+
error: j.error
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* The row's source file is not on disk.
|
|
2005
|
+
*
|
|
2006
|
+
* Carries `code: 'ENOENT'` because that is how every caller here already tells
|
|
2007
|
+
* a ghost row from a real failure, and {@link copyVerified} now discovers the
|
|
2008
|
+
* absence with a `stat` rather than by letting `copyFile` raise it.
|
|
2009
|
+
*/
|
|
2010
|
+
var RelocateSourceMissingError = class extends Error {
|
|
2011
|
+
code = "ENOENT";
|
|
2012
|
+
constructor(src) {
|
|
2013
|
+
super(`relocate source missing: ${src}`);
|
|
2014
|
+
this.name = "RelocateSourceMissingError";
|
|
2015
|
+
}
|
|
2016
|
+
};
|
|
2017
|
+
/**
|
|
2018
|
+
* One file's COPY: copy-if-absent → verify the destination against the SOURCE'S
|
|
2019
|
+
* OWN SIZE. The source is left in place.
|
|
2020
|
+
*
|
|
2021
|
+
* Split out of {@link moveFile} because the durable index re-point has to
|
|
2022
|
+
* happen while BOTH locations still hold the bytes — see
|
|
2023
|
+
* `SegmentHourLedger.repointSegments`.
|
|
2024
|
+
*
|
|
2025
|
+
* **`recordedBytes` is never the verify's reference.** It used to be, and it
|
|
2026
|
+
* cannot be: `fs.copyFile` truncates and replaces, so the destination always
|
|
2027
|
+
* equals the source, and comparing that result against a number the LEDGER
|
|
2028
|
+
* remembers can only ever fail when the ledger is wrong. On 2026-08-30 that
|
|
2029
|
+
* reported a byte-perfect copy as corruption and killed a 110 749-file drain
|
|
2030
|
+
* on its 12 440th file:
|
|
2031
|
+
*
|
|
2032
|
+
* relocate verify failed: …/1787926410000-9966-274432.m4s
|
|
2033
|
+
* is 783801B, expected 274432B
|
|
2034
|
+
*
|
|
2035
|
+
* Both files measured 783801 B on the hub. What is faithful is "the
|
|
2036
|
+
* destination equals the source", and that is what is asserted here; the
|
|
2037
|
+
* recorded number survives only as the reference for a destination whose
|
|
2038
|
+
* source is already GONE, where there is nothing else left to compare against.
|
|
2039
|
+
*/
|
|
2040
|
+
async function copyVerified(src, dst, recordedBytes) {
|
|
2041
|
+
const dstBytes = await fileSize(dst);
|
|
2042
|
+
const srcBytes = await fileSize(src);
|
|
2043
|
+
if (dstBytes !== null && dstBytes === (srcBytes ?? recordedBytes)) return {
|
|
2044
|
+
copied: 0,
|
|
2045
|
+
srcBytes: dstBytes
|
|
2046
|
+
};
|
|
2047
|
+
if (srcBytes === null) throw new RelocateSourceMissingError(src);
|
|
2048
|
+
await node_fs.promises.mkdir(node_path.default.dirname(dst), { recursive: true });
|
|
2049
|
+
await node_fs.promises.copyFile(src, dst);
|
|
2050
|
+
const after = await node_fs.promises.stat(dst);
|
|
2051
|
+
if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
|
|
2052
|
+
return {
|
|
2053
|
+
copied: srcBytes,
|
|
2054
|
+
srcBytes
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
/** A file's size, or `null` when it is not there. */
|
|
2058
|
+
async function fileSize(p) {
|
|
2059
|
+
try {
|
|
2060
|
+
return (await node_fs.promises.stat(p)).size;
|
|
2061
|
+
} catch {
|
|
2062
|
+
return null;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
var RelocateEngine = class {
|
|
2066
|
+
deps;
|
|
2067
|
+
jobs = /* @__PURE__ */ new Map();
|
|
2068
|
+
/** Job ids waiting for the mover, in enqueue order. */
|
|
2069
|
+
queue = [];
|
|
2070
|
+
/** Set while a job is executing — the single-flight latch. */
|
|
2071
|
+
draining = false;
|
|
2072
|
+
constructor(deps) {
|
|
2073
|
+
this.deps = deps;
|
|
2074
|
+
}
|
|
2075
|
+
list() {
|
|
2076
|
+
return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Cancel a running OR queued job. A running job stops after its current
|
|
2080
|
+
* file; a queued job is terminated where it stands and never runs — which is
|
|
2081
|
+
* why this returns true for both, and why the cancelled job still gets its
|
|
2082
|
+
* `onFinished` audit row.
|
|
2083
|
+
*/
|
|
2084
|
+
cancel(jobId) {
|
|
2085
|
+
const job = this.jobs.get(jobId);
|
|
2086
|
+
if (!job) return false;
|
|
2087
|
+
if (job.state === "running") {
|
|
2088
|
+
job.cancelRequested = true;
|
|
2089
|
+
return true;
|
|
2090
|
+
}
|
|
2091
|
+
if (job.state !== "queued") return false;
|
|
2092
|
+
job.cancelRequested = true;
|
|
2093
|
+
job.state = "cancelled";
|
|
2094
|
+
job.finishedAt = this.deps.now();
|
|
2095
|
+
const at = this.queue.indexOf(jobId);
|
|
2096
|
+
if (at >= 0) this.queue.splice(at, 1);
|
|
2097
|
+
this.deps.onFinished?.(snapshot(job));
|
|
2098
|
+
return true;
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Enqueue a relocation and return its job id. Throws only on an input the
|
|
2102
|
+
* mover could never satisfy (unknown/identical/read-only locations) —
|
|
2103
|
+
* validated SYNCHRONOUSLY so a caller arming a rebalance learns about a bad
|
|
2104
|
+
* target before any job runs.
|
|
2105
|
+
*
|
|
2106
|
+
* Concurrency: the returned job may be `queued` rather than `running`. The
|
|
2107
|
+
* engine still moves one job at a time.
|
|
2108
|
+
*/
|
|
2109
|
+
start(input) {
|
|
2110
|
+
const locs = this.deps.locations();
|
|
2111
|
+
const from = locs.find((l) => l.id === input.fromLocationId);
|
|
2112
|
+
const to = locs.find((l) => l.id === input.toLocationId);
|
|
2113
|
+
if (!from) throw new Error(`unknown source location: ${input.fromLocationId}`);
|
|
2114
|
+
if (!to) throw new Error(`unknown target location: ${input.toLocationId}`);
|
|
2115
|
+
if (from.id === to.id) throw new Error("source and target locations are identical");
|
|
2116
|
+
if (!locationMayBeWritten(to)) throw new Error(`target location is read-only (mode: ${locationMode(to)}): ${to.id}`);
|
|
1747
2117
|
const job = {
|
|
1748
2118
|
jobId: this.deps.newId(),
|
|
1749
2119
|
state: "queued",
|
|
@@ -2281,7 +2651,7 @@ function profilesOfClass(footageClass) {
|
|
|
2281
2651
|
function groupByHour(rows) {
|
|
2282
2652
|
const out = /* @__PURE__ */ new Map();
|
|
2283
2653
|
for (const row of rows) {
|
|
2284
|
-
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$
|
|
2654
|
+
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$6)}`;
|
|
2285
2655
|
const found = out.get(key);
|
|
2286
2656
|
if (found) found.push(row);
|
|
2287
2657
|
else out.set(key, [row]);
|
|
@@ -2470,7 +2840,7 @@ var SegmentStore = class SegmentStore {
|
|
|
2470
2840
|
else byBucket.set(bucketDir, [r]);
|
|
2471
2841
|
}
|
|
2472
2842
|
const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
|
|
2473
|
-
const yieldBetween = this.deps.yieldBetween ?? macrotask$
|
|
2843
|
+
const yieldBetween = this.deps.yieldBetween ?? macrotask$2;
|
|
2474
2844
|
let bucketsRemoved = 0;
|
|
2475
2845
|
let handled = 0;
|
|
2476
2846
|
for (const [bucketDir, bucketRows] of byBucket) {
|
|
@@ -2618,7 +2988,7 @@ var SegmentStore = class SegmentStore {
|
|
|
2618
2988
|
return held.every((p) => doomed.has(p));
|
|
2619
2989
|
}
|
|
2620
2990
|
};
|
|
2621
|
-
var macrotask$
|
|
2991
|
+
var macrotask$2 = () => new Promise((resolve) => {
|
|
2622
2992
|
setImmediate(resolve);
|
|
2623
2993
|
});
|
|
2624
2994
|
//#endregion
|
|
@@ -3130,6 +3500,12 @@ function intersect(a, b) {
|
|
|
3130
3500
|
* The cap's `locationId` is used directly as the storage `location` (identity); see Plan 2d
|
|
3131
3501
|
* notes. Wiring this class to the cap + the SegmentStore happens in Plan 2e.
|
|
3132
3502
|
*/
|
|
3503
|
+
/**
|
|
3504
|
+
* One class, two cap surfaces: `storage-evictable` (what may be deleted under
|
|
3505
|
+
* pressure) and `storage-occupancy` (what is HELD, D388). They live together
|
|
3506
|
+
* because they read the same archive, and they are separate CAPS because they
|
|
3507
|
+
* are separate quantities — see `getOccupancy`.
|
|
3508
|
+
*/
|
|
3133
3509
|
var StorageEvictableProvider = class {
|
|
3134
3510
|
deps;
|
|
3135
3511
|
orphanLane;
|
|
@@ -3147,9 +3523,9 @@ var StorageEvictableProvider = class {
|
|
|
3147
3523
|
* reported evictable nor selected as victims). */
|
|
3148
3524
|
domainOf(locationId) {
|
|
3149
3525
|
const domain = this.deps.resolveDomain?.(locationId) ?? new Set([locationId]);
|
|
3150
|
-
if (!this.deps.
|
|
3526
|
+
if (!this.deps.isEvictionFrozen) return domain;
|
|
3151
3527
|
const writable = /* @__PURE__ */ new Set();
|
|
3152
|
-
for (const id of domain) if (!this.deps.
|
|
3528
|
+
for (const id of domain) if (!this.deps.isEvictionFrozen(id)) writable.add(id);
|
|
3153
3529
|
return writable;
|
|
3154
3530
|
}
|
|
3155
3531
|
/**
|
|
@@ -3168,6 +3544,41 @@ var StorageEvictableProvider = class {
|
|
|
3168
3544
|
}
|
|
3169
3545
|
return { bytes: (await archive.accountingForLocations(this.domainOf(input.locationId))).bytes };
|
|
3170
3546
|
}
|
|
3547
|
+
/**
|
|
3548
|
+
* The `storage-occupancy` answer (D388) — a DIFFERENT quantity from
|
|
3549
|
+
* {@link getEvictableUsage}, and the difference is the whole reason the cap
|
|
3550
|
+
* exists:
|
|
3551
|
+
*
|
|
3552
|
+
* - it is the location's OWN bytes, not its eviction domain's. Two aliases
|
|
3553
|
+
* of one root report their own halves instead of the same combined total
|
|
3554
|
+
* twice;
|
|
3555
|
+
* - it counts a location whose eviction policy is `never` (a `readonly` or
|
|
3556
|
+
* `disabled` disk), which `getEvictableUsage` deliberately reports as 0.
|
|
3557
|
+
* Those are the disks an operator retiring hardware is staring at.
|
|
3558
|
+
*
|
|
3559
|
+
* A location this recorder holds nothing on is OMITTED, never reported as
|
|
3560
|
+
* zero, and an archive it cannot measure yields NO reports at all — unknown
|
|
3561
|
+
* is the truth when nothing could be read.
|
|
3562
|
+
*/
|
|
3563
|
+
async getOccupancy(input) {
|
|
3564
|
+
const archive = this.deps.archive();
|
|
3565
|
+
if (archive === null) {
|
|
3566
|
+
this.deps.logger.warn("recorder: occupancy unavailable — no footage archive to measure, reporting NOTHING (a location with no report reads as unknown, never as empty)", { meta: { locationIds: [...input.locationIds] } });
|
|
3567
|
+
return [];
|
|
3568
|
+
}
|
|
3569
|
+
const measuredAtMs = this.deps.now?.() ?? Date.now();
|
|
3570
|
+
const out = [];
|
|
3571
|
+
for (const locationId of input.locationIds) {
|
|
3572
|
+
const acct = await archive.accountingForLocations(new Set([locationId]));
|
|
3573
|
+
if (acct.bytes <= 0) continue;
|
|
3574
|
+
out.push({
|
|
3575
|
+
locationId,
|
|
3576
|
+
ownedBytes: acct.bytes,
|
|
3577
|
+
measuredAtMs
|
|
3578
|
+
});
|
|
3579
|
+
}
|
|
3580
|
+
return out;
|
|
3581
|
+
}
|
|
3171
3582
|
async evict(input) {
|
|
3172
3583
|
const archive = this.deps.archive();
|
|
3173
3584
|
if (archive === null) {
|
|
@@ -3434,7 +3845,7 @@ async function profileHasFootage(root, deviceId, profile) {
|
|
|
3434
3845
|
//#endregion
|
|
3435
3846
|
//#region src/recorder/addon/calendar-index.ts
|
|
3436
3847
|
var DAY_MS$3 = 864e5;
|
|
3437
|
-
var HOUR_MS$
|
|
3848
|
+
var HOUR_MS$5 = 36e5;
|
|
3438
3849
|
/**
|
|
3439
3850
|
* UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
|
|
3440
3851
|
*
|
|
@@ -3472,8 +3883,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
|
|
|
3472
3883
|
* deploy gap). Unknown hours still fall through to one directory read.
|
|
3473
3884
|
*/
|
|
3474
3885
|
function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
|
|
3475
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
3476
|
-
const hourEnd = hour + HOUR_MS$
|
|
3886
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
|
|
3887
|
+
const hourEnd = hour + HOUR_MS$5;
|
|
3477
3888
|
if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
|
|
3478
3889
|
const rows = [];
|
|
3479
3890
|
for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
|
|
@@ -3630,7 +4041,7 @@ var CalendarIndex = class {
|
|
|
3630
4041
|
}));
|
|
3631
4042
|
const days = /* @__PURE__ */ new Set();
|
|
3632
4043
|
for (const { day, hours } of perDay) for (const h of hours) {
|
|
3633
|
-
const at = day + h * HOUR_MS$
|
|
4044
|
+
const at = day + h * HOUR_MS$5;
|
|
3634
4045
|
if (at < fromMs || at >= toMs) continue;
|
|
3635
4046
|
days.add(localDayStart(at, tzOffsetMinutes));
|
|
3636
4047
|
}
|
|
@@ -3692,7 +4103,7 @@ var CalendarIndex = class {
|
|
|
3692
4103
|
const day = utcDayOf(utcDayMs);
|
|
3693
4104
|
for (let h = 0; h < 24; h++) {
|
|
3694
4105
|
await this.awaitQuiet();
|
|
3695
|
-
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$
|
|
4106
|
+
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$5, true);
|
|
3696
4107
|
}
|
|
3697
4108
|
}
|
|
3698
4109
|
/**
|
|
@@ -3713,7 +4124,7 @@ var CalendarIndex = class {
|
|
|
3713
4124
|
*/
|
|
3714
4125
|
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
3715
4126
|
if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
|
|
3716
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
4127
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
|
|
3717
4128
|
const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
|
|
3718
4129
|
if (fromIndex !== null && fromIndex !== void 0) return {
|
|
3719
4130
|
segments: fromIndex,
|
|
@@ -3721,7 +4132,7 @@ var CalendarIndex = class {
|
|
|
3721
4132
|
};
|
|
3722
4133
|
const key = `${deviceId}:${profile}:${hour}`;
|
|
3723
4134
|
const now = this.deps.now?.() ?? Date.now();
|
|
3724
|
-
const isCurrentHour = hour === Math.floor(now / HOUR_MS$
|
|
4135
|
+
const isCurrentHour = hour === Math.floor(now / HOUR_MS$5) * HOUR_MS$5;
|
|
3725
4136
|
const cached = this.hourSegs.get(key);
|
|
3726
4137
|
if (cached !== void 0) {
|
|
3727
4138
|
if (!isCurrentHour) return {
|
|
@@ -3785,7 +4196,7 @@ var CalendarIndex = class {
|
|
|
3785
4196
|
*/
|
|
3786
4197
|
async dayRanges(deviceId, profile, utcDayMs) {
|
|
3787
4198
|
const day = utcDayOf(utcDayMs);
|
|
3788
|
-
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$
|
|
4199
|
+
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$5), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
|
|
3789
4200
|
}
|
|
3790
4201
|
/**
|
|
3791
4202
|
* Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
|
|
@@ -3799,9 +4210,9 @@ var CalendarIndex = class {
|
|
|
3799
4210
|
*/
|
|
3800
4211
|
async rangesIn(deviceId, profile, fromMs, toMs) {
|
|
3801
4212
|
if (toMs <= fromMs) return [];
|
|
3802
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
4213
|
+
const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
|
|
3803
4214
|
const hours = [];
|
|
3804
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
4215
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$5) hours.push(hour);
|
|
3805
4216
|
const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
|
|
3806
4217
|
const ranges = [];
|
|
3807
4218
|
for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
|
|
@@ -3950,7 +4361,7 @@ var DEFAULT_DEVICE_CONFIG = {
|
|
|
3950
4361
|
};
|
|
3951
4362
|
/** Build the handle. `declare()` MUST run once at boot before any read/write. */
|
|
3952
4363
|
function recordingConfigsState(store, logger) {
|
|
3953
|
-
return new
|
|
4364
|
+
return new require_retire_root_keys.RowMapStore({
|
|
3954
4365
|
spec: RECORDING_CONFIGS_SPEC,
|
|
3955
4366
|
store,
|
|
3956
4367
|
logger
|
|
@@ -4329,7 +4740,7 @@ function handleStoreCensusAction(census, logger, input) {
|
|
|
4329
4740
|
}
|
|
4330
4741
|
//#endregion
|
|
4331
4742
|
//#region src/recorder/addon/directory-handler.ts
|
|
4332
|
-
var HOUR_MS$
|
|
4743
|
+
var HOUR_MS$4 = 36e5;
|
|
4333
4744
|
/** A window wider than this is refused: it is a client bug, and answering it
|
|
4334
4745
|
* would walk the archive on the read path — the thing this replaces. */
|
|
4335
4746
|
var MAX_WINDOW_MS = 32 * 864e5;
|
|
@@ -4376,9 +4787,9 @@ function withDirectoryRoute(deps, next) {
|
|
|
4376
4787
|
return;
|
|
4377
4788
|
}
|
|
4378
4789
|
const rows = [];
|
|
4379
|
-
const firstHour = Math.floor(fromMs / HOUR_MS$
|
|
4790
|
+
const firstHour = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
|
|
4380
4791
|
let hydrated = true;
|
|
4381
|
-
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$
|
|
4792
|
+
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$4) {
|
|
4382
4793
|
const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
|
|
4383
4794
|
if (!complete) hydrated = false;
|
|
4384
4795
|
for (const s of segments) {
|
|
@@ -4564,21 +4975,16 @@ async function wipeClassSubtrees(input) {
|
|
|
4564
4975
|
}
|
|
4565
4976
|
//#endregion
|
|
4566
4977
|
//#region src/recorder/addon/location-discard-policy.ts
|
|
4567
|
-
/**
|
|
4568
|
-
* Server-side eligibility for discarding a frozen location's leftover
|
|
4569
|
-
* class subtree. Same rules as the admin-ui card: frozen, known class,
|
|
4570
|
-
* writable sibling of the same type. The last writable of a type is
|
|
4571
|
-
* refused here even if the UI is bypassed.
|
|
4572
|
-
*/
|
|
4573
4978
|
var DISCARD_SUBTREES = {
|
|
4574
4979
|
eventMedia: ["events"],
|
|
4575
4980
|
recordingsLow: ["low"],
|
|
4576
4981
|
recordings: ["high", "mid"]
|
|
4577
4982
|
};
|
|
4983
|
+
/** Frozen = the system will not remove this footage on its own: eviction
|
|
4984
|
+
* policy `never` (D385), i.e. `readonly` or `disabled`. A `drain` location is
|
|
4985
|
+
* NOT frozen — the ratchet is already removing it. */
|
|
4578
4986
|
function locationIsFrozen(location) {
|
|
4579
|
-
|
|
4580
|
-
const readOnly = location.config["readOnly"] === true;
|
|
4581
|
-
return !enabled || readOnly;
|
|
4987
|
+
return require_dist.evictionPolicyOfLocation(location) === "never";
|
|
4582
4988
|
}
|
|
4583
4989
|
function discardEligible(location, siblings) {
|
|
4584
4990
|
if (!(location.type in DISCARD_SUBTREES)) return false;
|
|
@@ -4586,9 +4992,7 @@ function discardEligible(location, siblings) {
|
|
|
4586
4992
|
return siblings.some((s) => {
|
|
4587
4993
|
if (s.id === location.id) return false;
|
|
4588
4994
|
if (s.type !== location.type) return false;
|
|
4589
|
-
|
|
4590
|
-
const readOnly = s.config["readOnly"] === true;
|
|
4591
|
-
return enabled && !readOnly;
|
|
4995
|
+
return require_dist.mayWriteToLocation(s);
|
|
4592
4996
|
});
|
|
4593
4997
|
}
|
|
4594
4998
|
function assertDiscardAllowed(location, all, localNodeId) {
|
|
@@ -4797,6 +5201,20 @@ function createFootageDiscardJob(deps) {
|
|
|
4797
5201
|
};
|
|
4798
5202
|
}
|
|
4799
5203
|
//#endregion
|
|
5204
|
+
//#region src/recorder/addon/location-resolve.ts
|
|
5205
|
+
/**
|
|
5206
|
+
* A location this process may READ from.
|
|
5207
|
+
*
|
|
5208
|
+
* Throws with a message that names WHICH of the two it is — never one message
|
|
5209
|
+
* for both. The caller does not have to branch: the message is the product.
|
|
5210
|
+
*/
|
|
5211
|
+
function requireReadableLocation(locations, locationId, context) {
|
|
5212
|
+
const location = locations.find((candidate) => candidate.id === locationId);
|
|
5213
|
+
if (location === void 0) throw new Error(`${context}: unknown location ${locationId}`);
|
|
5214
|
+
if (!locationMayBeRead(location)) throw new Error(`${context}: recordings location ${locationId} is UNAVAILABLE — the operator switched it off (mode: ${location.mode}). Its footage is still on disk; set the location back to readonly or active to read it.`);
|
|
5215
|
+
return location;
|
|
5216
|
+
}
|
|
5217
|
+
//#endregion
|
|
4800
5218
|
//#region src/recorder/addon/export-dense-map.ts
|
|
4801
5219
|
/**
|
|
4802
5220
|
* Shortest clip worth keeping, in ms.
|
|
@@ -5241,7 +5659,7 @@ var RECORDING_EXPORTS_SPEC = {
|
|
|
5241
5659
|
};
|
|
5242
5660
|
/** Build the handle. `declare()` MUST run once at boot before any read/write. */
|
|
5243
5661
|
function recordingExportsState(store, logger) {
|
|
5244
|
-
return new
|
|
5662
|
+
return new require_retire_root_keys.RowMapStore({
|
|
5245
5663
|
spec: RECORDING_EXPORTS_SPEC,
|
|
5246
5664
|
store,
|
|
5247
5665
|
logger
|
|
@@ -6784,8 +7202,7 @@ async function evictVictims(deps, deviceId, victims) {
|
|
|
6784
7202
|
function resolveSegmentFile(deps, deviceId, profile, startMs) {
|
|
6785
7203
|
const seg = deps.index.segmentStartingAt(deviceId, profile, startMs);
|
|
6786
7204
|
if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
|
|
6787
|
-
const loc = deps.locations()
|
|
6788
|
-
if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
|
|
7205
|
+
const loc = requireReadableLocation(deps.locations(), seg.locationId, "recording");
|
|
6789
7206
|
return {
|
|
6790
7207
|
seg,
|
|
6791
7208
|
abs: node_path.default.join(loc.root, seg.path)
|
|
@@ -7058,7 +7475,7 @@ function buildRecordingProvider(deps) {
|
|
|
7058
7475
|
}
|
|
7059
7476
|
const locationUsages = await Promise.all([...byPhysical.values()].map(async (group) => {
|
|
7060
7477
|
const first = group[0];
|
|
7061
|
-
const cap = group.some((l) => l
|
|
7478
|
+
const cap = group.some((l) => locationMayBeWritten(l)) ? await deps.capacity(first.root) : {
|
|
7062
7479
|
availableBytes: null,
|
|
7063
7480
|
totalBytes: null
|
|
7064
7481
|
};
|
|
@@ -7576,528 +7993,162 @@ var RENDER_TIMEOUT_MS = 3e4;
|
|
|
7576
7993
|
*/
|
|
7577
7994
|
var FOOTAGE_NOT_READY = "footage-not-yet-finalized";
|
|
7578
7995
|
/**
|
|
7579
|
-
* GIF for the widest notifier reach (every backend that takes an image takes a
|
|
7580
|
-
* gif); `fps` decimates and lanczos downscales.
|
|
7581
|
-
*/
|
|
7582
|
-
async function renderGif(renderDeps, input) {
|
|
7583
|
-
const out = await renderFootage(renderDeps, input, "gif", (playlistPath, outPath) => [
|
|
7584
|
-
"-y",
|
|
7585
|
-
"-i",
|
|
7586
|
-
playlistPath,
|
|
7587
|
-
"-vf",
|
|
7588
|
-
`fps=${input.fps},scale=${input.maxWidth}:-2:flags=lanczos`,
|
|
7589
|
-
"-loop",
|
|
7590
|
-
"0",
|
|
7591
|
-
"-f",
|
|
7592
|
-
"gif",
|
|
7593
|
-
outPath
|
|
7594
|
-
]);
|
|
7595
|
-
return {
|
|
7596
|
-
gifBase64: out.bytes.toString("base64"),
|
|
7597
|
-
fromMs: out.fromMs,
|
|
7598
|
-
toMs: out.toMs
|
|
7599
|
-
};
|
|
7600
|
-
}
|
|
7601
|
-
/**
|
|
7602
|
-
* MP4 clip — the "send a clip, not a gif" attachment. Re-encodes rather than
|
|
7603
|
-
* stream-copying: a concat of segments whose encoder parameters differ (a
|
|
7604
|
-
* profile switch, a broker redial) produces an mp4 no player will scrub, and a
|
|
7605
|
-
* notification attachment that will not play is worse than none. `faststart`
|
|
7606
|
-
* puts the moov up front so a chat client can play it without the whole file.
|
|
7607
|
-
*/
|
|
7608
|
-
async function renderClip(renderDeps, input) {
|
|
7609
|
-
const out = await renderFootage(renderDeps, input, "mp4", (playlistPath, outPath) => [
|
|
7610
|
-
"-y",
|
|
7611
|
-
"-i",
|
|
7612
|
-
playlistPath,
|
|
7613
|
-
"-vf",
|
|
7614
|
-
`scale=${input.maxWidth}:-2:flags=lanczos`,
|
|
7615
|
-
"-c:v",
|
|
7616
|
-
"libx264",
|
|
7617
|
-
"-preset",
|
|
7618
|
-
"veryfast",
|
|
7619
|
-
"-crf",
|
|
7620
|
-
"28",
|
|
7621
|
-
"-profile:v",
|
|
7622
|
-
"main",
|
|
7623
|
-
"-pix_fmt",
|
|
7624
|
-
"yuv420p",
|
|
7625
|
-
"-movflags",
|
|
7626
|
-
"+faststart",
|
|
7627
|
-
"-an",
|
|
7628
|
-
"-f",
|
|
7629
|
-
"mp4",
|
|
7630
|
-
outPath
|
|
7631
|
-
]);
|
|
7632
|
-
return {
|
|
7633
|
-
clipBase64: out.bytes.toString("base64"),
|
|
7634
|
-
mime: "video/mp4",
|
|
7635
|
-
fromMs: out.fromMs,
|
|
7636
|
-
toMs: out.toMs,
|
|
7637
|
-
bytes: out.bytes.byteLength
|
|
7638
|
-
};
|
|
7639
|
-
}
|
|
7640
|
-
/** The shared playlist → ffmpeg → bytes pipeline. */
|
|
7641
|
-
async function renderFootage(renderDeps, input, ext, buildArgs) {
|
|
7642
|
-
const now = renderDeps.now ?? Date.now;
|
|
7643
|
-
const fromMs = input.aroundMs - input.preRollSec * 1e3;
|
|
7644
|
-
const toMs = input.aroundMs + input.postRollSec * 1e3;
|
|
7645
|
-
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) throw new Error(`invalid ${ext} window [${fromMs}, ${toMs}) for device ${input.deviceId} — aroundMs=${input.aroundMs} preRollSec=${input.preRollSec} postRollSec=${input.postRollSec}`);
|
|
7646
|
-
const renderId = `${ext}-${now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
7647
|
-
const availableEnd = latestFinalizedEnd(renderDeps, input.deviceId);
|
|
7648
|
-
if (availableEnd === null || availableEnd < toMs) throw new Error(`${FOOTAGE_NOT_READY}: [${fromMs}, ${toMs}) for device ${input.deviceId} — finalized footage ends at ${availableEnd ?? "never"}`);
|
|
7649
|
-
const window = {
|
|
7650
|
-
fromMs,
|
|
7651
|
-
toMs
|
|
7652
|
-
};
|
|
7653
|
-
const playlist = await writeExportSourcePlaylist({
|
|
7654
|
-
deps: renderDeps.deps,
|
|
7655
|
-
exportsDir: renderDeps.exportsDir,
|
|
7656
|
-
exportId: renderId,
|
|
7657
|
-
deviceId: input.deviceId,
|
|
7658
|
-
profile: "low",
|
|
7659
|
-
fromMs: window.fromMs,
|
|
7660
|
-
toMs: window.toMs
|
|
7661
|
-
});
|
|
7662
|
-
if (playlist === null) throw new Error(`no footage covers [${window.fromMs}, ${window.toMs}) for device ${input.deviceId}`);
|
|
7663
|
-
const outPath = node_path.default.join(node_path.default.dirname(playlist.path), `${renderId}.${ext}`);
|
|
7664
|
-
try {
|
|
7665
|
-
await runFfmpeg(renderDeps, buildArgs(playlist.path, outPath), ext);
|
|
7666
|
-
const bytes = await node_fs.promises.readFile(outPath);
|
|
7667
|
-
if (bytes.byteLength === 0) throw new Error(`${ext} render produced an empty file`);
|
|
7668
|
-
return {
|
|
7669
|
-
bytes,
|
|
7670
|
-
fromMs: window.fromMs,
|
|
7671
|
-
toMs: window.toMs
|
|
7672
|
-
};
|
|
7673
|
-
} finally {
|
|
7674
|
-
await node_fs.promises.rm(playlist.path, { force: true }).catch(() => {});
|
|
7675
|
-
await node_fs.promises.rm(playlist.concatPath, { force: true }).catch(() => {});
|
|
7676
|
-
await node_fs.promises.rm(outPath, { force: true }).catch(() => {});
|
|
7677
|
-
}
|
|
7678
|
-
}
|
|
7679
|
-
/**
|
|
7680
|
-
* The end of the newest FINALIZED `low` segment, or null when none exists.
|
|
7681
|
-
*
|
|
7682
|
-
* One scalar off the tail of the live sorted view — it used to COPY the
|
|
7683
|
-
* device's whole `low` archive to fold a maximum out of it, per render.
|
|
7684
|
-
*/
|
|
7685
|
-
function latestFinalizedEnd(renderDeps, deviceId) {
|
|
7686
|
-
return renderDeps.deps.index.latestEnd(deviceId, "low");
|
|
7687
|
-
}
|
|
7688
|
-
/**
|
|
7689
|
-
* Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
|
|
7690
|
-
*
|
|
7691
|
-
* The previous shape leaked on two paths: a `spawn` error (`ENOENT`, a bad
|
|
7692
|
-
* path) rejected while a process could still be alive, and a `close` with a
|
|
7693
|
-
* non-zero code rejected without ever reaping stdio. These renders fire on
|
|
7694
|
-
* every notification with a gif or clip, so a leak here is a slow accumulation
|
|
7695
|
-
* of ffmpeg processes on the recording node — the kind that shows up days later
|
|
7696
|
-
* as "the hub got slow" (operator, 2026-07-30).
|
|
7697
|
-
*
|
|
7698
|
-
* `settle` is idempotent and always kills first: killing an already-exited pid
|
|
7699
|
-
* throws ESRCH, which is swallowed, so the cost of being wrong is nothing while
|
|
7700
|
-
* the cost of NOT killing is a zombie per render.
|
|
7701
|
-
*/
|
|
7702
|
-
function runFfmpeg(renderDeps, args, ext) {
|
|
7703
|
-
return new Promise((resolve, reject) => {
|
|
7704
|
-
const proc = renderDeps.spawn(renderDeps.ffmpegPath ?? "ffmpeg", args);
|
|
7705
|
-
let done = false;
|
|
7706
|
-
let timer = null;
|
|
7707
|
-
const settle = (err) => {
|
|
7708
|
-
if (done) return;
|
|
7709
|
-
done = true;
|
|
7710
|
-
if (timer !== null) {
|
|
7711
|
-
clearTimeout(timer);
|
|
7712
|
-
timer = null;
|
|
7713
|
-
}
|
|
7714
|
-
if (proc.exitCode === null && proc.signalCode === null) try {
|
|
7715
|
-
proc.kill("SIGKILL");
|
|
7716
|
-
} catch {}
|
|
7717
|
-
try {
|
|
7718
|
-
proc.stderr?.removeAllListeners();
|
|
7719
|
-
proc.stdout?.destroy();
|
|
7720
|
-
proc.stderr?.destroy();
|
|
7721
|
-
} catch {}
|
|
7722
|
-
if (err === null) resolve();
|
|
7723
|
-
else reject(err);
|
|
7724
|
-
};
|
|
7725
|
-
timer = setTimeout(() => settle(/* @__PURE__ */ new Error(`${ext} render timed out`)), RENDER_TIMEOUT_MS);
|
|
7726
|
-
let stderrTail = "";
|
|
7727
|
-
proc.stderr?.on("data", (c) => {
|
|
7728
|
-
stderrTail = (stderrTail + c.toString()).slice(-400);
|
|
7729
|
-
});
|
|
7730
|
-
proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
|
|
7731
|
-
proc.on("close", (code) => {
|
|
7732
|
-
settle(code === 0 ? null : /* @__PURE__ */ new Error(`ffmpeg ${ext} render exited ${code}: ${stderrTail}`));
|
|
7733
|
-
});
|
|
7734
|
-
});
|
|
7735
|
-
}
|
|
7736
|
-
//#endregion
|
|
7737
|
-
//#region src/recorder/addon/roots.ts
|
|
7738
|
-
/**
|
|
7739
|
-
* Storage-root resolution + boot hydrate for the recorder addon.
|
|
7740
|
-
*
|
|
7741
|
-
* Recordings live in the managed `recordings` / `recordingsLow` storage
|
|
7742
|
-
* locations (operator-configurable, multi-disk-ready), resolved through the
|
|
7743
|
-
* `storage` cap. The v2 `RecordingIndex` is hydrated by walking each location
|
|
7744
|
-
* root's `<deviceId>/` subtree on disk — segment paths encode start/dur/bytes,
|
|
7745
|
-
* so the in-memory index can never drift from disk. (We walk the filesystem
|
|
7746
|
-
* directly instead of `storage.list` because that cap lists only a directory's
|
|
7747
|
-
* IMMEDIATE children, not the `<deviceId>/<profile>/Y/M/D/H/*.m4s` tree — using
|
|
7748
|
-
* it dropped the entire index to ~empty on every restart/rescan; the recorder
|
|
7749
|
-
* already does local fs I/O for stat/move on these same roots.)
|
|
7750
|
-
*/
|
|
7751
|
-
/** The two recordings location types the recorder addon writes/reads. */
|
|
7752
|
-
var PLACEMENT_LOCATION_TYPES = ["recordings", "recordingsLow"];
|
|
7753
|
-
var RECORDING_LOCATION_TYPES = new Set(PLACEMENT_LOCATION_TYPES);
|
|
7754
|
-
/**
|
|
7755
|
-
* The recordings CLASS a profile belongs to. The one place this rule is
|
|
7756
|
-
* written: `resolvePlacement`, the planner's demand type and the repoint check
|
|
7757
|
-
* must agree on it, and three copies of `profile === 'low' ? … : …` is how they
|
|
7758
|
-
* stop agreeing.
|
|
7759
|
-
*/
|
|
7760
|
-
function placementTypeForProfile(profile) {
|
|
7761
|
-
return profile === "low" ? "recordingsLow" : "recordings";
|
|
7762
|
-
}
|
|
7763
|
-
/** The profile that routes to each class — the probe `authorityLocations` asks
|
|
7764
|
-
* `resolvePlacement` with, so the answer is derived from the same function the
|
|
7765
|
-
* unassigned path uses rather than from a second reading of `isDefault`. */
|
|
7766
|
-
var CLASS_PROBE_PROFILE = {
|
|
7767
|
-
recordings: "high",
|
|
7768
|
-
recordingsLow: "low"
|
|
7769
|
-
};
|
|
7770
|
-
/**
|
|
7771
|
-
* Where each recordings class writes when NOTHING has planned it — the location
|
|
7772
|
-
* `resolvePlacement` picks, which is the type default unless the default is
|
|
7773
|
-
* gone, read-only, or absent from the pool.
|
|
7774
|
-
*
|
|
7775
|
-
* This is the AUTHORITY a stored assignment is measured against (D298). A
|
|
7776
|
-
* repoint (`storage.setDefaultLocations`, an operator making another disk the
|
|
7777
|
-
* default, a default deleted) changes exactly this map, and a stored assignment
|
|
7778
|
-
* that survives that change is a plan that outlived the pool it was planned
|
|
7779
|
-
* for.
|
|
7780
|
-
*
|
|
7781
|
-
* A class with nothing writable is ABSENT from the map, not guessed at: `place`
|
|
7782
|
-
* already throws loudly on that path, and an authority nobody can compute must
|
|
7783
|
-
* invalidate nothing.
|
|
7784
|
-
*/
|
|
7785
|
-
function authorityLocations(pool) {
|
|
7786
|
-
const out = /* @__PURE__ */ new Map();
|
|
7787
|
-
for (const type of PLACEMENT_LOCATION_TYPES) try {
|
|
7788
|
-
out.set(type, resolvePlacement(pool, CLASS_PROBE_PROFILE[type]).id);
|
|
7789
|
-
} catch {}
|
|
7790
|
-
return out;
|
|
7791
|
-
}
|
|
7792
|
-
/**
|
|
7793
|
-
* Narrow a resolved location list to the ones placement may write to: the
|
|
7794
|
-
* operator-enabled ones. A location the operator has not opted in
|
|
7795
|
-
* (`enabled === false`) is still READ, age-swept and drained — it is simply
|
|
7796
|
-
* never a write target, exactly like a read-only one.
|
|
7797
|
-
*
|
|
7798
|
-
* When no enabled location is writable the type DEFAULTS serve instead, loudly:
|
|
7799
|
-
* the default of a type is guaranteed enabled by the orchestrator, so this can
|
|
7800
|
-
* only happen through a hand-edited store — and a recorder that writes nowhere
|
|
7801
|
-
* is worse than one that writes to the default and complains.
|
|
7802
|
-
*/
|
|
7803
|
-
function placementPool(locations) {
|
|
7804
|
-
const enabled = locations.filter((l) => l.enabled !== false);
|
|
7805
|
-
if (enabled.some((l) => !l.readOnly)) return {
|
|
7806
|
-
locations: enabled,
|
|
7807
|
-
fellBackToDefault: false
|
|
7808
|
-
};
|
|
7809
|
-
const defaults = locations.filter((l) => l.isDefault === true);
|
|
7810
|
-
if (defaults.some((l) => !l.readOnly)) return {
|
|
7811
|
-
locations: defaults,
|
|
7812
|
-
fellBackToDefault: true
|
|
7813
|
-
};
|
|
7814
|
-
return {
|
|
7815
|
-
locations,
|
|
7816
|
-
fellBackToDefault: true
|
|
7817
|
-
};
|
|
7818
|
-
}
|
|
7819
|
-
/**
|
|
7820
|
-
* Route a recording profile to its storage location by the location's declared
|
|
7821
|
-
* `type`: the `low` profile prefers the `recordingsLow` location, every other
|
|
7822
|
-
* profile the `recordings` location. READ-ONLY locations are never write
|
|
7823
|
-
* targets (the drain model): when the preferred type has only read-only
|
|
7824
|
-
* instances, any writable recordings-type location serves. Throws when NO
|
|
7825
|
-
* writable location exists — recording cannot start, and that must be loud.
|
|
7826
|
-
*/
|
|
7827
|
-
function resolvePlacement(locations, profile) {
|
|
7828
|
-
if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
|
|
7829
|
-
const writable = locations.filter((l) => !l.readOnly);
|
|
7830
|
-
if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
|
|
7831
|
-
const preferType = profile === "low" ? "recordingsLow" : "recordings";
|
|
7832
|
-
const preferred = writable.filter((l) => l.type === preferType);
|
|
7833
|
-
return preferred.find((l) => l.isDefault) ?? preferred[0] ?? writable.find((l) => l.isDefault) ?? writable[0];
|
|
7834
|
-
}
|
|
7835
|
-
/**
|
|
7836
|
-
* Attribute a discovered segment on an aliased physical root by its profile,
|
|
7837
|
-
* not by whichever logical location happened to be hydrated last. Stock
|
|
7838
|
-
* `recordings` and `recordingsLow` deliberately share a root: high/mid belong
|
|
7839
|
-
* to `recordings`, while low belongs to `recordingsLow`.
|
|
7840
|
-
*/
|
|
7841
|
-
function locationForHydratedProfile(aliases, profile) {
|
|
7842
|
-
const preferredType = profile === "low" ? "recordingsLow" : "recordings";
|
|
7843
|
-
const preferred = aliases.filter((location) => location.type === preferredType).toSorted((a, b) => a.id.localeCompare(b.id))[0];
|
|
7844
|
-
const fallback = aliases.toSorted((a, b) => a.id.localeCompare(b.id))[0];
|
|
7845
|
-
if (preferred) return preferred;
|
|
7846
|
-
if (fallback) return fallback;
|
|
7847
|
-
throw new Error("recorder: no location aliases to attribute hydrated profile");
|
|
7848
|
-
}
|
|
7849
|
-
function aliasesByRoot(locations) {
|
|
7850
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
7851
|
-
for (const location of locations) {
|
|
7852
|
-
const aliases = grouped.get(location.root);
|
|
7853
|
-
if (aliases) aliases.push(location);
|
|
7854
|
-
else grouped.set(location.root, [location]);
|
|
7855
|
-
}
|
|
7856
|
-
return grouped;
|
|
7857
|
-
}
|
|
7858
|
-
/** One numeric key lifted out of a location's untyped `config` record, or
|
|
7859
|
-
* nothing at all when it is absent or not a finite number. Absent is a real
|
|
7860
|
-
* answer here: the caller falls back to a documented default, and a garbage
|
|
7861
|
-
* value must never become a capacity limit. */
|
|
7862
|
-
function numericConfig(config, key, field) {
|
|
7863
|
-
const raw = config[key];
|
|
7864
|
-
if (typeof raw !== "number" || !Number.isFinite(raw)) return {};
|
|
7865
|
-
return { [field]: raw };
|
|
7866
|
-
}
|
|
7867
|
-
/**
|
|
7868
|
-
* Resolve every recordings storage location (`recordings` + `recordingsLow`)
|
|
7869
|
-
* to `{ id, root }`. Returns an empty list when the storage cap is unreachable
|
|
7870
|
-
* or no recordings location exists — callers degrade rather than crash.
|
|
7996
|
+
* GIF for the widest notifier reach (every backend that takes an image takes a
|
|
7997
|
+
* gif); `fps` decimates and lanczos downscales.
|
|
7871
7998
|
*/
|
|
7872
|
-
async function
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
|
|
7881
|
-
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
return [];
|
|
7891
|
-
}
|
|
7999
|
+
async function renderGif(renderDeps, input) {
|
|
8000
|
+
const out = await renderFootage(renderDeps, input, "gif", (playlistPath, outPath) => [
|
|
8001
|
+
"-y",
|
|
8002
|
+
"-i",
|
|
8003
|
+
playlistPath,
|
|
8004
|
+
"-vf",
|
|
8005
|
+
`fps=${input.fps},scale=${input.maxWidth}:-2:flags=lanczos`,
|
|
8006
|
+
"-loop",
|
|
8007
|
+
"0",
|
|
8008
|
+
"-f",
|
|
8009
|
+
"gif",
|
|
8010
|
+
outPath
|
|
8011
|
+
]);
|
|
8012
|
+
return {
|
|
8013
|
+
gifBase64: out.bytes.toString("base64"),
|
|
8014
|
+
fromMs: out.fromMs,
|
|
8015
|
+
toMs: out.toMs
|
|
8016
|
+
};
|
|
7892
8017
|
}
|
|
7893
8018
|
/**
|
|
7894
|
-
*
|
|
7895
|
-
*
|
|
7896
|
-
*
|
|
7897
|
-
*
|
|
7898
|
-
*
|
|
7899
|
-
* (best-effort), so one bad volume never blocks the rest of the hydrate.
|
|
8019
|
+
* MP4 clip — the "send a clip, not a gif" attachment. Re-encodes rather than
|
|
8020
|
+
* stream-copying: a concat of segments whose encoder parameters differ (a
|
|
8021
|
+
* profile switch, a broker redial) produces an mp4 no player will scrub, and a
|
|
8022
|
+
* notification attachment that will not play is worse than none. `faststart`
|
|
8023
|
+
* puts the moov up front so a chat client can play it without the whole file.
|
|
7900
8024
|
*/
|
|
7901
|
-
async function
|
|
7902
|
-
const
|
|
7903
|
-
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
let locationId = locationForProfile.get(profile);
|
|
7933
|
-
if (locationId === void 0) {
|
|
7934
|
-
locationId = locationForHydratedProfile(aliases, profile).id;
|
|
7935
|
-
locationForProfile.set(profile, locationId);
|
|
7936
|
-
}
|
|
7937
|
-
const paths = pathsByLocation.get(locationId);
|
|
7938
|
-
if (paths) paths.push(relative);
|
|
7939
|
-
else pathsByLocation.set(locationId, [relative]);
|
|
7940
|
-
handled += 1;
|
|
7941
|
-
if (handled % chunkSize === 0) await yieldBetween();
|
|
7942
|
-
}
|
|
7943
|
-
for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
|
|
7944
|
-
let indexed = 0;
|
|
7945
|
-
const reuseBefore = index.hydrateReuseStats();
|
|
7946
|
-
for (const [locationId, relPaths] of pathsByLocation) {
|
|
7947
|
-
index.hydrateDevice(deviceId, locationId, relPaths);
|
|
7948
|
-
indexed += relPaths.length;
|
|
7949
|
-
}
|
|
7950
|
-
const reuseAfter = index.hydrateReuseStats();
|
|
7951
|
-
const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
|
|
7952
|
-
const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
|
|
7953
|
-
logger.info("recorder: hydrated a device from one recordings root", {
|
|
7954
|
-
tags: { deviceId },
|
|
7955
|
-
meta: {
|
|
7956
|
-
root,
|
|
7957
|
-
entries: entries.length,
|
|
7958
|
-
indexed,
|
|
7959
|
-
reused,
|
|
7960
|
-
allocated,
|
|
7961
|
-
locationIds: [...pathsByLocation.keys()],
|
|
7962
|
-
walkMs: walkedMs - startedMs,
|
|
7963
|
-
ms: Date.now() - startedMs
|
|
7964
|
-
}
|
|
7965
|
-
});
|
|
7966
|
-
}
|
|
8025
|
+
async function renderClip(renderDeps, input) {
|
|
8026
|
+
const out = await renderFootage(renderDeps, input, "mp4", (playlistPath, outPath) => [
|
|
8027
|
+
"-y",
|
|
8028
|
+
"-i",
|
|
8029
|
+
playlistPath,
|
|
8030
|
+
"-vf",
|
|
8031
|
+
`scale=${input.maxWidth}:-2:flags=lanczos`,
|
|
8032
|
+
"-c:v",
|
|
8033
|
+
"libx264",
|
|
8034
|
+
"-preset",
|
|
8035
|
+
"veryfast",
|
|
8036
|
+
"-crf",
|
|
8037
|
+
"28",
|
|
8038
|
+
"-profile:v",
|
|
8039
|
+
"main",
|
|
8040
|
+
"-pix_fmt",
|
|
8041
|
+
"yuv420p",
|
|
8042
|
+
"-movflags",
|
|
8043
|
+
"+faststart",
|
|
8044
|
+
"-an",
|
|
8045
|
+
"-f",
|
|
8046
|
+
"mp4",
|
|
8047
|
+
outPath
|
|
8048
|
+
]);
|
|
8049
|
+
return {
|
|
8050
|
+
clipBase64: out.bytes.toString("base64"),
|
|
8051
|
+
mime: "video/mp4",
|
|
8052
|
+
fromMs: out.fromMs,
|
|
8053
|
+
toMs: out.toMs,
|
|
8054
|
+
bytes: out.bytes.byteLength
|
|
8055
|
+
};
|
|
7967
8056
|
}
|
|
7968
|
-
/**
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
const
|
|
7976
|
-
|
|
7977
|
-
|
|
8057
|
+
/** The shared playlist → ffmpeg → bytes pipeline. */
|
|
8058
|
+
async function renderFootage(renderDeps, input, ext, buildArgs) {
|
|
8059
|
+
const now = renderDeps.now ?? Date.now;
|
|
8060
|
+
const fromMs = input.aroundMs - input.preRollSec * 1e3;
|
|
8061
|
+
const toMs = input.aroundMs + input.postRollSec * 1e3;
|
|
8062
|
+
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) throw new Error(`invalid ${ext} window [${fromMs}, ${toMs}) for device ${input.deviceId} — aroundMs=${input.aroundMs} preRollSec=${input.preRollSec} postRollSec=${input.postRollSec}`);
|
|
8063
|
+
const renderId = `${ext}-${now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
8064
|
+
const availableEnd = latestFinalizedEnd(renderDeps, input.deviceId);
|
|
8065
|
+
if (availableEnd === null || availableEnd < toMs) throw new Error(`${FOOTAGE_NOT_READY}: [${fromMs}, ${toMs}) for device ${input.deviceId} — finalized footage ends at ${availableEnd ?? "never"}`);
|
|
8066
|
+
const window = {
|
|
8067
|
+
fromMs,
|
|
8068
|
+
toMs
|
|
8069
|
+
};
|
|
8070
|
+
const playlist = await writeExportSourcePlaylist({
|
|
8071
|
+
deps: renderDeps.deps,
|
|
8072
|
+
exportsDir: renderDeps.exportsDir,
|
|
8073
|
+
exportId: renderId,
|
|
8074
|
+
deviceId: input.deviceId,
|
|
8075
|
+
profile: "low",
|
|
8076
|
+
fromMs: window.fromMs,
|
|
8077
|
+
toMs: window.toMs
|
|
8078
|
+
});
|
|
8079
|
+
if (playlist === null) throw new Error(`no footage covers [${window.fromMs}, ${window.toMs}) for device ${input.deviceId}`);
|
|
8080
|
+
const outPath = node_path.default.join(node_path.default.dirname(playlist.path), `${renderId}.${ext}`);
|
|
8081
|
+
try {
|
|
8082
|
+
await runFfmpeg(renderDeps, buildArgs(playlist.path, outPath), ext);
|
|
8083
|
+
const bytes = await node_fs.promises.readFile(outPath);
|
|
8084
|
+
if (bytes.byteLength === 0) throw new Error(`${ext} render produced an empty file`);
|
|
8085
|
+
return {
|
|
8086
|
+
bytes,
|
|
8087
|
+
fromMs: window.fromMs,
|
|
8088
|
+
toMs: window.toMs
|
|
8089
|
+
};
|
|
8090
|
+
} finally {
|
|
8091
|
+
await node_fs.promises.rm(playlist.path, { force: true }).catch(() => {});
|
|
8092
|
+
await node_fs.promises.rm(playlist.concatPath, { force: true }).catch(() => {});
|
|
8093
|
+
await node_fs.promises.rm(outPath, { force: true }).catch(() => {});
|
|
8094
|
+
}
|
|
7978
8095
|
}
|
|
7979
|
-
var HOUR_MS$4 = 36e5;
|
|
7980
8096
|
/**
|
|
7981
|
-
*
|
|
7982
|
-
*
|
|
7983
|
-
* `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
|
|
7984
|
-
* subtree — ">1M files and takes minutes" per its own comment. Until it lands,
|
|
7985
|
-
* every read of past footage sees an empty index, which the server reports as
|
|
7986
|
-
* "no footage": measured after a deploy, a camera-day held 7 segments and grew
|
|
7987
|
-
* only at the rate of NEW writes for ~9 minutes.
|
|
7988
|
-
*
|
|
7989
|
-
* The path convention makes the window addressable directly: one SHALLOW
|
|
7990
|
-
* `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
|
|
7991
|
-
* background full walk still runs, for the consumers that need the whole
|
|
7992
|
-
* archive (footprint, eviction domains, retention).
|
|
7993
|
-
*
|
|
7994
|
-
* Hours already walked are skipped, so a drag across an hour costs nothing the
|
|
7995
|
-
* second time.
|
|
7996
|
-
*
|
|
7997
|
-
* ## The ledger is the first stop for a CLOSED hour (D287)
|
|
7998
|
-
*
|
|
7999
|
-
* Boot no longer replays the archive into the index, so a ledger-known hour
|
|
8000
|
-
* arrives here `unknown` and pays ONE point read of its hour row instead of a
|
|
8001
|
-
* `readdir` — measured at ~650 ms per hour directory on the live share under
|
|
8002
|
-
* load, against the array the scrub feeder is competing for. Hours the ledger
|
|
8003
|
-
* does not know, and every hour if the read fails, fall through to the walk
|
|
8004
|
-
* exactly as before: the failure direction is toward the disk, never toward an
|
|
8005
|
-
* hour that renders empty because nobody read it.
|
|
8006
|
-
*
|
|
8007
|
-
* **The live hour is never served from the ledger.** It is the one hour whose
|
|
8008
|
-
* durable row is knowably behind the disk: a segment finalized after the last
|
|
8009
|
-
* persist and before a crash is on the array and not in the row. Boot used to
|
|
8010
|
-
* mark every ledger hour `hydrated`, which made that tail invisible to
|
|
8011
|
-
* playback and the timeline FOREVER, not merely under-reported. Excluding the
|
|
8012
|
-
* live hour costs exactly one hour-directory read per process — the walk marks
|
|
8013
|
-
* it, so the second reader pays nothing — and it is what turns a lost tail
|
|
8014
|
-
* back into a recoverable one.
|
|
8097
|
+
* The end of the newest FINALIZED `low` segment, or null when none exists.
|
|
8015
8098
|
*
|
|
8016
|
-
*
|
|
8017
|
-
*
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8035
|
-
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8099
|
+
* One scalar off the tail of the live sorted view — it used to COPY the
|
|
8100
|
+
* device's whole `low` archive to fold a maximum out of it, per render.
|
|
8101
|
+
*/
|
|
8102
|
+
function latestFinalizedEnd(renderDeps, deviceId) {
|
|
8103
|
+
return renderDeps.deps.index.latestEnd(deviceId, "low");
|
|
8104
|
+
}
|
|
8105
|
+
/**
|
|
8106
|
+
* Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
|
|
8107
|
+
*
|
|
8108
|
+
* The previous shape leaked on two paths: a `spawn` error (`ENOENT`, a bad
|
|
8109
|
+
* path) rejected while a process could still be alive, and a `close` with a
|
|
8110
|
+
* non-zero code rejected without ever reaping stdio. These renders fire on
|
|
8111
|
+
* every notification with a gif or clip, so a leak here is a slow accumulation
|
|
8112
|
+
* of ffmpeg processes on the recording node — the kind that shows up days later
|
|
8113
|
+
* as "the hub got slow" (operator, 2026-07-30).
|
|
8114
|
+
*
|
|
8115
|
+
* `settle` is idempotent and always kills first: killing an already-exited pid
|
|
8116
|
+
* throws ESRCH, which is swallowed, so the cost of being wrong is nothing while
|
|
8117
|
+
* the cost of NOT killing is a zombie per render.
|
|
8118
|
+
*/
|
|
8119
|
+
function runFfmpeg(renderDeps, args, ext) {
|
|
8120
|
+
return new Promise((resolve, reject) => {
|
|
8121
|
+
const proc = renderDeps.spawn(renderDeps.ffmpegPath ?? "ffmpeg", args);
|
|
8122
|
+
let done = false;
|
|
8123
|
+
let timer = null;
|
|
8124
|
+
const settle = (err) => {
|
|
8125
|
+
if (done) return;
|
|
8126
|
+
done = true;
|
|
8127
|
+
if (timer !== null) {
|
|
8128
|
+
clearTimeout(timer);
|
|
8129
|
+
timer = null;
|
|
8042
8130
|
}
|
|
8131
|
+
if (proc.exitCode === null && proc.signalCode === null) try {
|
|
8132
|
+
proc.kill("SIGKILL");
|
|
8133
|
+
} catch {}
|
|
8134
|
+
try {
|
|
8135
|
+
proc.stderr?.removeAllListeners();
|
|
8136
|
+
proc.stdout?.destroy();
|
|
8137
|
+
proc.stderr?.destroy();
|
|
8138
|
+
} catch {}
|
|
8139
|
+
if (err === null) resolve();
|
|
8140
|
+
else reject(err);
|
|
8141
|
+
};
|
|
8142
|
+
timer = setTimeout(() => settle(/* @__PURE__ */ new Error(`${ext} render timed out`)), RENDER_TIMEOUT_MS);
|
|
8143
|
+
let stderrTail = "";
|
|
8144
|
+
proc.stderr?.on("data", (c) => {
|
|
8145
|
+
stderrTail = (stderrTail + c.toString()).slice(-400);
|
|
8043
8146
|
});
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
const deviceDir = node_path.default.join(root, String(deviceId));
|
|
8048
|
-
let profiles;
|
|
8049
|
-
try {
|
|
8050
|
-
profiles = (await node_fs.promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
|
|
8051
|
-
} catch (err) {
|
|
8052
|
-
if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
|
|
8053
|
-
tags: { deviceId },
|
|
8054
|
-
meta: {
|
|
8055
|
-
locationIds: aliases.map((location) => location.id),
|
|
8056
|
-
error: require_dist.errMsg(err)
|
|
8057
|
-
}
|
|
8058
|
-
});
|
|
8059
|
-
continue;
|
|
8060
|
-
}
|
|
8061
|
-
const first = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
|
|
8062
|
-
const reuseBefore = index.hydrateReuseStats();
|
|
8063
|
-
let hoursRead = 0;
|
|
8064
|
-
let repaired = 0;
|
|
8065
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$4) {
|
|
8066
|
-
if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$4) === "hydrated") continue;
|
|
8067
|
-
for (const profile of profiles) {
|
|
8068
|
-
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
8069
|
-
let files;
|
|
8070
|
-
try {
|
|
8071
|
-
files = await node_fs.promises.readdir(node_path.default.join(root, rel));
|
|
8072
|
-
} catch {
|
|
8073
|
-
continue;
|
|
8074
|
-
}
|
|
8075
|
-
hoursRead += 1;
|
|
8076
|
-
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
8077
|
-
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
8078
|
-
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
8079
|
-
if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$4 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
|
|
8080
|
-
repaired += 1;
|
|
8081
|
-
await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
|
|
8082
|
-
}
|
|
8083
|
-
}
|
|
8084
|
-
}
|
|
8085
|
-
index.markHydrated(deviceId, fromMs, toMs);
|
|
8086
|
-
if (hoursRead === 0) continue;
|
|
8087
|
-
const reuseAfter = index.hydrateReuseStats();
|
|
8088
|
-
const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
|
|
8089
|
-
const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
|
|
8090
|
-
logger.info("recorder: hydrated a window from one recordings root", {
|
|
8091
|
-
tags: { deviceId },
|
|
8092
|
-
meta: {
|
|
8093
|
-
root,
|
|
8094
|
-
hoursRead,
|
|
8095
|
-
repaired,
|
|
8096
|
-
reused,
|
|
8097
|
-
allocated
|
|
8098
|
-
}
|
|
8147
|
+
proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
|
|
8148
|
+
proc.on("close", (code) => {
|
|
8149
|
+
settle(code === 0 ? null : /* @__PURE__ */ new Error(`ffmpeg ${ext} render exited ${code}: ${stderrTail}`));
|
|
8099
8150
|
});
|
|
8100
|
-
}
|
|
8151
|
+
});
|
|
8101
8152
|
}
|
|
8102
8153
|
//#endregion
|
|
8103
8154
|
//#region src/durable/durable-ledger.ts
|
|
@@ -10438,47 +10489,17 @@ Object.freeze({
|
|
|
10438
10489
|
},
|
|
10439
10490
|
"setDeviceConfig": { "config": { "__nested__": { "bands": [] } } }
|
|
10440
10491
|
},
|
|
10441
|
-
"storage": { "upsertLocation": {
|
|
10442
|
-
"isDefault": false,
|
|
10443
|
-
"isSystem": false
|
|
10444
|
-
} },
|
|
10492
|
+
"storage": { "upsertLocation": { "isSystem": false } },
|
|
10445
10493
|
"storage-provider": {
|
|
10446
|
-
"beginDownload": { "location": { "__nested__": {
|
|
10447
|
-
|
|
10448
|
-
|
|
10449
|
-
} } },
|
|
10450
|
-
"
|
|
10451
|
-
|
|
10452
|
-
|
|
10453
|
-
} } },
|
|
10454
|
-
"
|
|
10455
|
-
"isDefault": false,
|
|
10456
|
-
"isSystem": false
|
|
10457
|
-
} } },
|
|
10458
|
-
"exists": { "location": { "__nested__": {
|
|
10459
|
-
"isDefault": false,
|
|
10460
|
-
"isSystem": false
|
|
10461
|
-
} } },
|
|
10462
|
-
"getAvailableSpace": { "location": { "__nested__": {
|
|
10463
|
-
"isDefault": false,
|
|
10464
|
-
"isSystem": false
|
|
10465
|
-
} } },
|
|
10466
|
-
"list": { "location": { "__nested__": {
|
|
10467
|
-
"isDefault": false,
|
|
10468
|
-
"isSystem": false
|
|
10469
|
-
} } },
|
|
10470
|
-
"read": { "location": { "__nested__": {
|
|
10471
|
-
"isDefault": false,
|
|
10472
|
-
"isSystem": false
|
|
10473
|
-
} } },
|
|
10474
|
-
"resolve": { "location": { "__nested__": {
|
|
10475
|
-
"isDefault": false,
|
|
10476
|
-
"isSystem": false
|
|
10477
|
-
} } },
|
|
10478
|
-
"write": { "location": { "__nested__": {
|
|
10479
|
-
"isDefault": false,
|
|
10480
|
-
"isSystem": false
|
|
10481
|
-
} } }
|
|
10494
|
+
"beginDownload": { "location": { "__nested__": { "isSystem": false } } },
|
|
10495
|
+
"beginUpload": { "location": { "__nested__": { "isSystem": false } } },
|
|
10496
|
+
"delete": { "location": { "__nested__": { "isSystem": false } } },
|
|
10497
|
+
"exists": { "location": { "__nested__": { "isSystem": false } } },
|
|
10498
|
+
"getAvailableSpace": { "location": { "__nested__": { "isSystem": false } } },
|
|
10499
|
+
"list": { "location": { "__nested__": { "isSystem": false } } },
|
|
10500
|
+
"read": { "location": { "__nested__": { "isSystem": false } } },
|
|
10501
|
+
"resolve": { "location": { "__nested__": { "isSystem": false } } },
|
|
10502
|
+
"write": { "location": { "__nested__": { "isSystem": false } } }
|
|
10482
10503
|
},
|
|
10483
10504
|
"stream-broker": {
|
|
10484
10505
|
"produceEventMedia": {
|
|
@@ -10552,6 +10573,7 @@ Object.freeze({
|
|
|
10552
10573
|
"listTargetKinds",
|
|
10553
10574
|
"listTargets"
|
|
10554
10575
|
],
|
|
10576
|
+
"storage-occupancy": ["getOccupancy"],
|
|
10555
10577
|
"storage-provider": ["list"],
|
|
10556
10578
|
"turn-provider": ["getTurnServers"],
|
|
10557
10579
|
"user-passkeys": ["listPasskeys"]
|
|
@@ -10665,13 +10687,12 @@ var LedgerWalkReconciler = class {
|
|
|
10665
10687
|
const apply = request.apply === true;
|
|
10666
10688
|
const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
|
|
10667
10689
|
if (location === void 0) return this.refusedReport(request.locationId, apply, "location-unknown");
|
|
10668
|
-
if ((location
|
|
10690
|
+
if (locationMayBeWritten(location)) {
|
|
10669
10691
|
this.deps.logger.warn("recorder: ledger walk REFUSED — the source is still a write target, so a directory listing is a lower bound, not the truth", { meta: {
|
|
10670
10692
|
locationId: location.id,
|
|
10671
10693
|
root: location.root,
|
|
10672
|
-
|
|
10673
|
-
|
|
10674
|
-
remedy: "set the location readOnly (or disable it) and re-run; a LIVE location is reconciled by the mover, under its bound (D318)"
|
|
10694
|
+
mode: location.mode,
|
|
10695
|
+
remedy: "switch the location to readonly / drain / disabled and re-run; a LIVE location is reconciled by the mover, under its bound (D318)"
|
|
10675
10696
|
} });
|
|
10676
10697
|
return this.refusedReport(location.id, apply, "source-writable");
|
|
10677
10698
|
}
|
|
@@ -11025,6 +11046,91 @@ async function readLocationCapacity(root, statfs) {
|
|
|
11025
11046
|
};
|
|
11026
11047
|
}
|
|
11027
11048
|
}
|
|
11049
|
+
/**
|
|
11050
|
+
* Coalesces location-change doorbells into refreshes, and converges on its own
|
|
11051
|
+
* whether or not any doorbell ever arrives.
|
|
11052
|
+
*/
|
|
11053
|
+
var LocationRefresher = class {
|
|
11054
|
+
deps;
|
|
11055
|
+
timer = null;
|
|
11056
|
+
running = false;
|
|
11057
|
+
/** A doorbell rang while a refresh was in flight — run exactly one more. */
|
|
11058
|
+
rearm = false;
|
|
11059
|
+
/** When the last refresh STARTED. `null` ⇒ none since this object was made. */
|
|
11060
|
+
lastStartedAt = null;
|
|
11061
|
+
disposed = false;
|
|
11062
|
+
constructor(deps) {
|
|
11063
|
+
this.deps = deps;
|
|
11064
|
+
}
|
|
11065
|
+
get now() {
|
|
11066
|
+
return this.deps.now?.() ?? Date.now();
|
|
11067
|
+
}
|
|
11068
|
+
get debounceMs() {
|
|
11069
|
+
return this.deps.debounceMs ?? 2e3;
|
|
11070
|
+
}
|
|
11071
|
+
get backstopMs() {
|
|
11072
|
+
return this.deps.backstopMs ?? 3e5;
|
|
11073
|
+
}
|
|
11074
|
+
/**
|
|
11075
|
+
* The doorbell. Never awaits the refresh: this runs on the event bus, and a
|
|
11076
|
+
* subscriber that blocks on I/O holds up every other consumer of the bus.
|
|
11077
|
+
*/
|
|
11078
|
+
ring(locationId, reason) {
|
|
11079
|
+
if (this.disposed) return;
|
|
11080
|
+
this.deps.logger.info("recorder: the storage location set changed — re-resolving where recordings may be written", { meta: {
|
|
11081
|
+
locationId,
|
|
11082
|
+
reason,
|
|
11083
|
+
coalesceMs: this.debounceMs
|
|
11084
|
+
} });
|
|
11085
|
+
if (this.timer !== null) return;
|
|
11086
|
+
this.timer = setTimeout(() => {
|
|
11087
|
+
this.timer = null;
|
|
11088
|
+
this.run("storage location change");
|
|
11089
|
+
}, this.debounceMs);
|
|
11090
|
+
}
|
|
11091
|
+
/**
|
|
11092
|
+
* The backstop, called from a pass that already runs on a timer. Refreshes
|
|
11093
|
+
* only when nothing has refreshed for {@link LOCATION_BACKSTOP_MS} — a
|
|
11094
|
+
* doorbell that already produced one resets the clock, so the two paths
|
|
11095
|
+
* never both pay.
|
|
11096
|
+
*/
|
|
11097
|
+
async convergeIfDue() {
|
|
11098
|
+
if (this.disposed) return;
|
|
11099
|
+
const last = this.lastStartedAt;
|
|
11100
|
+
if (last !== null && this.now - last < this.backstopMs) return;
|
|
11101
|
+
await this.run("periodic convergence");
|
|
11102
|
+
}
|
|
11103
|
+
/** Stop the pending doorbell. Nothing in flight is cancelled — it is idempotent. */
|
|
11104
|
+
dispose() {
|
|
11105
|
+
this.disposed = true;
|
|
11106
|
+
if (this.timer !== null) {
|
|
11107
|
+
clearTimeout(this.timer);
|
|
11108
|
+
this.timer = null;
|
|
11109
|
+
}
|
|
11110
|
+
}
|
|
11111
|
+
async run(trigger) {
|
|
11112
|
+
if (this.running) {
|
|
11113
|
+
this.rearm = true;
|
|
11114
|
+
return;
|
|
11115
|
+
}
|
|
11116
|
+
this.running = true;
|
|
11117
|
+
this.lastStartedAt = this.now;
|
|
11118
|
+
try {
|
|
11119
|
+
await this.deps.refresh();
|
|
11120
|
+
} catch (err) {
|
|
11121
|
+
this.deps.logger.warn("recorder: could not re-resolve storage locations — the previously resolved set stands", { meta: {
|
|
11122
|
+
trigger,
|
|
11123
|
+
error: require_dist.errMsg(err)
|
|
11124
|
+
} });
|
|
11125
|
+
} finally {
|
|
11126
|
+
this.running = false;
|
|
11127
|
+
}
|
|
11128
|
+
if (this.rearm && !this.disposed) {
|
|
11129
|
+
this.rearm = false;
|
|
11130
|
+
await this.run("change during refresh");
|
|
11131
|
+
}
|
|
11132
|
+
}
|
|
11133
|
+
};
|
|
11028
11134
|
//#endregion
|
|
11029
11135
|
//#region src/recorder/addon/ops-log-store.ts
|
|
11030
11136
|
/**
|
|
@@ -11084,7 +11190,7 @@ var RECORDING_OPS_LOG_SPEC = {
|
|
|
11084
11190
|
};
|
|
11085
11191
|
/** Build the handle. `declare()` MUST run once at boot before any read/write. */
|
|
11086
11192
|
function recordingOpsLogState(store, logger) {
|
|
11087
|
-
return new
|
|
11193
|
+
return new require_retire_root_keys.RowMapStore({
|
|
11088
11194
|
spec: RECORDING_OPS_LOG_SPEC,
|
|
11089
11195
|
store,
|
|
11090
11196
|
logger
|
|
@@ -11179,6 +11285,72 @@ function createRecordingOpsLogSink(deps) {
|
|
|
11179
11285
|
};
|
|
11180
11286
|
}
|
|
11181
11287
|
//#endregion
|
|
11288
|
+
//#region src/recorder/addon/pending-writer-moves.ts
|
|
11289
|
+
/**
|
|
11290
|
+
* The gap between the PLAN and the DISK, for as long as it stands.
|
|
11291
|
+
*
|
|
11292
|
+
* Placement applies at writer-attach boundaries only: the `.rec-tmp` staging
|
|
11293
|
+
* dir lives UNDER the chosen root so the finalize relocate is a
|
|
11294
|
+
* same-filesystem rename, and repointing a live writer would turn every
|
|
11295
|
+
* segment into the EXDEV copy+unlink fallback — 2× disk writes on the hot path
|
|
11296
|
+
* (the F1 regression). So a plan reaches a camera when it next attaches.
|
|
11297
|
+
*
|
|
11298
|
+
* For a camera on a schedule that is the next band edge, minutes away. For a
|
|
11299
|
+
* CONTINUOUSLY recording camera there is no next attach: its writer outlives
|
|
11300
|
+
* every tick, and its assignment can be re-planned onto a new disk forever
|
|
11301
|
+
* without one byte moving. That is the state this module makes visible (D387).
|
|
11302
|
+
* The alternative — detaching live writers to apply a plan — buys the move at
|
|
11303
|
+
* the cost of a recording gap on every affected camera at once, which is
|
|
11304
|
+
* exactly the wrong trade on the day an operator adds a disk to relieve
|
|
11305
|
+
* pressure.
|
|
11306
|
+
*/
|
|
11307
|
+
/** Is `dir` inside `root` (or the root itself)? A prefix compare that cannot
|
|
11308
|
+
* match `/recordings-2` against `/recordings`. */
|
|
11309
|
+
function isUnder(dir, root) {
|
|
11310
|
+
const rel = node_path.default.relative(root, dir);
|
|
11311
|
+
return rel === "" || !rel.startsWith("..") && !node_path.default.isAbsolute(rel);
|
|
11312
|
+
}
|
|
11313
|
+
/** The location whose root contains `dir` — the DEEPEST one, so a location
|
|
11314
|
+
* nested inside another is attributed to the nested one. */
|
|
11315
|
+
function locationOf(dir, locations) {
|
|
11316
|
+
let best = null;
|
|
11317
|
+
for (const location of locations) {
|
|
11318
|
+
if (!isUnder(dir, location.root)) continue;
|
|
11319
|
+
if (best === null || location.root.length > best.root.length) best = location;
|
|
11320
|
+
}
|
|
11321
|
+
return best?.id ?? null;
|
|
11322
|
+
}
|
|
11323
|
+
/**
|
|
11324
|
+
* Every live writer the plan wants somewhere else.
|
|
11325
|
+
*
|
|
11326
|
+
* A writer whose key the plan says nothing about is NOT pending: the pass
|
|
11327
|
+
* plans a work SUBSET, and "not in this pass" must never read as "planned
|
|
11328
|
+
* elsewhere". A writer under no known root is reported with `writingTo: null`
|
|
11329
|
+
* rather than dropped — a directory outside every resolved location is its own
|
|
11330
|
+
* fault, and a silent skip would hide it.
|
|
11331
|
+
*/
|
|
11332
|
+
function pendingWriterMoves(writers, planned, locations) {
|
|
11333
|
+
const out = [];
|
|
11334
|
+
for (const writer of writers) {
|
|
11335
|
+
const plannedFor = planned.get(`${String(writer.deviceId)}:${writer.profile}`);
|
|
11336
|
+
if (plannedFor === void 0) continue;
|
|
11337
|
+
const writingTo = locationOf(writer.outDir, locations);
|
|
11338
|
+
if (writingTo === plannedFor) continue;
|
|
11339
|
+
out.push({
|
|
11340
|
+
deviceId: writer.deviceId,
|
|
11341
|
+
profile: writer.profile,
|
|
11342
|
+
writingTo,
|
|
11343
|
+
plannedFor
|
|
11344
|
+
});
|
|
11345
|
+
}
|
|
11346
|
+
return out.toSorted((a, b) => a.deviceId - b.deviceId || a.profile.localeCompare(b.profile));
|
|
11347
|
+
}
|
|
11348
|
+
/** A stable signature of a pending set, so the report is latched on WHAT is
|
|
11349
|
+
* pending rather than repeated every 30 s tick. */
|
|
11350
|
+
function pendingMovesSignature(moves) {
|
|
11351
|
+
return moves.map((m) => `${String(m.deviceId)}:${m.profile}>${m.plannedFor}`).join(",");
|
|
11352
|
+
}
|
|
11353
|
+
//#endregion
|
|
11182
11354
|
//#region src/recorder/addon/periodic-pass.ts
|
|
11183
11355
|
function startPeriodicPass(deps) {
|
|
11184
11356
|
const now = () => deps.now?.() ?? Date.now();
|
|
@@ -11301,6 +11473,30 @@ function poolFor(demand, candidates) {
|
|
|
11301
11473
|
function poolKey(pool) {
|
|
11302
11474
|
return pool.map((c) => c.locationId).toSorted().join("|");
|
|
11303
11475
|
}
|
|
11476
|
+
/**
|
|
11477
|
+
* What one demand with NO measured rate weighs: the median of the rates that
|
|
11478
|
+
* WERE measured, and one unit when nothing in the pool was measured at all.
|
|
11479
|
+
*
|
|
11480
|
+
* Never zero, and that is the whole point (D387). `dailyBytes` is 0 for every
|
|
11481
|
+
* camera the recorder's RAM index has not accounted for — after D287 that is
|
|
11482
|
+
* most of a fleet at any moment — and a zero-byte demand is invisible to every
|
|
11483
|
+
* balance calculation here. Phase C reaches for the SMALLEST demand on an
|
|
11484
|
+
* overloaded location, which is one of those; moving 0 bytes moves neither side
|
|
11485
|
+
* towards its target, so the strict-improvement check fails and the rebalance
|
|
11486
|
+
* `break`s on its first iteration. A single measured camera in the pool was
|
|
11487
|
+
* enough to make every unmeasured one weightless and wedge the whole pass — on
|
|
11488
|
+
* a live hub that left 19 assignments pinned to an 89%-full disk with an empty
|
|
11489
|
+
* 4 TB one next to it, silently, on every 30 s tick.
|
|
11490
|
+
*
|
|
11491
|
+
* The median (rather than the mean) so one 24/7 4K camera does not decide what
|
|
11492
|
+
* an unknown camera is assumed to cost.
|
|
11493
|
+
*/
|
|
11494
|
+
function weightOfUnmeasured(demands) {
|
|
11495
|
+
const measured = demands.map((d) => d.dailyBytes).filter((bytes) => bytes > 0).toSorted((a, b) => a - b);
|
|
11496
|
+
if (measured.length === 0) return 1;
|
|
11497
|
+
const mid = Math.floor(measured.length / 2);
|
|
11498
|
+
return measured.length % 2 === 1 ? measured[mid] : (measured[mid - 1] + measured[mid]) / 2;
|
|
11499
|
+
}
|
|
11304
11500
|
/** Normalised weight per location: headroom share, or an even split when no
|
|
11305
11501
|
* location reports any headroom (nothing is known yet — never divide by 0). */
|
|
11306
11502
|
function weightsFor(pool) {
|
|
@@ -11350,8 +11546,8 @@ function planPlacement(input) {
|
|
|
11350
11546
|
});
|
|
11351
11547
|
}
|
|
11352
11548
|
for (const { pool, demands } of pools.values()) {
|
|
11353
|
-
const
|
|
11354
|
-
const bytesOf = (d) =>
|
|
11549
|
+
const unmeasured = weightOfUnmeasured(demands);
|
|
11550
|
+
const bytesOf = (d) => d.dailyBytes > 0 ? d.dailyBytes : unmeasured;
|
|
11355
11551
|
const totalBytes = demands.reduce((sum, d) => sum + bytesOf(d), 0);
|
|
11356
11552
|
const weights = weightsFor(pool);
|
|
11357
11553
|
const slots = pool.map((c) => ({
|
|
@@ -11496,19 +11692,16 @@ var RecordingPlacementBlobSchema = require_dist.object({
|
|
|
11496
11692
|
/** locationId → the volume id last verified at that location's root. */
|
|
11497
11693
|
volumeIds: require_dist.record(require_dist.string(), require_dist.string()).default({}),
|
|
11498
11694
|
/**
|
|
11499
|
-
*
|
|
11500
|
-
*
|
|
11501
|
-
*
|
|
11502
|
-
*
|
|
11503
|
-
* as a repoint: shipping this field must not invalidate assignments that were
|
|
11504
|
-
* made under an authority nothing wrote down. The first pass stamps it and
|
|
11505
|
-
* invalidates nothing; every later change of it is a repoint.
|
|
11695
|
+
* DEAD KEY (D383) — the class authority a pass planned against, back when a
|
|
11696
|
+
* class had a default distinct from its pool. Declared so an existing blob
|
|
11697
|
+
* round-trips unchanged and so its removal is a decision on the record rather
|
|
11698
|
+
* than a silent strip. Never read, never written.
|
|
11506
11699
|
*/
|
|
11507
11700
|
defaultLocations: require_dist.record(require_dist.string(), require_dist.string()).default({}),
|
|
11508
11701
|
/** When the blob was last written (operator-facing debugging only). */
|
|
11509
11702
|
updatedAt: require_dist.number().optional()
|
|
11510
11703
|
});
|
|
11511
|
-
/** Cold-start value: nothing assigned, nothing claimed
|
|
11704
|
+
/** Cold-start value: nothing assigned, nothing claimed. */
|
|
11512
11705
|
var EMPTY_PLACEMENT = {
|
|
11513
11706
|
assignments: {},
|
|
11514
11707
|
pins: {},
|
|
@@ -11557,31 +11750,27 @@ async function replaceAssignments(state, base, assignments) {
|
|
|
11557
11750
|
});
|
|
11558
11751
|
}
|
|
11559
11752
|
/**
|
|
11560
|
-
* The assignment record a pass leaves behind: the stored one
|
|
11561
|
-
*
|
|
11562
|
-
*
|
|
11753
|
+
* The assignment record a pass leaves behind: the stored one with the plan
|
|
11754
|
+
* merged over it. **Nothing is ever removed** — a pass that plans a subset
|
|
11755
|
+
* cannot delete a key it did not mention.
|
|
11563
11756
|
*
|
|
11564
|
-
* PURE, and shared by the write and the
|
|
11757
|
+
* PURE, and shared by the write and the placement census — a report computed
|
|
11565
11758
|
* from a second reading of the same rule is a report that can describe a record
|
|
11566
11759
|
* the write did not produce.
|
|
11567
11760
|
*/
|
|
11568
11761
|
function applyPlacementPass(base, pass) {
|
|
11569
11762
|
const next = new Map(Object.entries(base));
|
|
11570
|
-
for (const [key, locationId] of pass.repointed) next.set(key, locationId);
|
|
11571
11763
|
for (const [key, locationId] of pass.planned) next.set(key, locationId);
|
|
11572
11764
|
return next;
|
|
11573
11765
|
}
|
|
11574
|
-
/** Persist one work-scoped pass:
|
|
11575
|
-
*
|
|
11766
|
+
/** Persist one work-scoped pass: merge its plan over the stored record. One
|
|
11767
|
+
* write. */
|
|
11576
11768
|
async function writePlacementPass(state, base, pass) {
|
|
11577
11769
|
const assignments = {};
|
|
11578
11770
|
for (const [key, locationId] of applyPlacementPass(base.assignments, pass)) assignments[key] = locationId;
|
|
11579
|
-
const defaultLocations = {};
|
|
11580
|
-
for (const [type, locationId] of pass.defaultLocations) defaultLocations[type] = locationId;
|
|
11581
11771
|
await state.set({
|
|
11582
11772
|
...base,
|
|
11583
11773
|
assignments,
|
|
11584
|
-
defaultLocations,
|
|
11585
11774
|
updatedAt: Date.now()
|
|
11586
11775
|
});
|
|
11587
11776
|
}
|
|
@@ -11830,14 +12019,6 @@ var NO_PLAN = {
|
|
|
11830
12019
|
assignments: /* @__PURE__ */ new Map(),
|
|
11831
12020
|
changes: []
|
|
11832
12021
|
};
|
|
11833
|
-
/** Is the authority already stamped, exactly? A pass that would change the
|
|
11834
|
-
* stamp always writes, even when it moved nothing: an unstamped authority is
|
|
11835
|
-
* an undetectable repoint. */
|
|
11836
|
-
function sameStamp(stamped, authority) {
|
|
11837
|
-
if (stamped.size !== authority.size) return false;
|
|
11838
|
-
for (const [type, locationId] of authority) if (stamped.get(type) !== locationId) return false;
|
|
11839
|
-
return true;
|
|
11840
|
-
}
|
|
11841
12022
|
/** Operator pins win over the planner for high/mid of the pinned camera. */
|
|
11842
12023
|
function overlayPins(planned, pins) {
|
|
11843
12024
|
const next = new Map(planned);
|
|
@@ -11865,14 +12046,11 @@ var PlacementService = class {
|
|
|
11865
12046
|
/** The fair tie-break the PURE planner defers to (D37). Advisory state, and
|
|
11866
12047
|
* consulted only for a tie the capacity signal cannot separate. */
|
|
11867
12048
|
rotation = new PlacementRotation();
|
|
11868
|
-
/** The
|
|
11869
|
-
* announced yet
|
|
11870
|
-
|
|
11871
|
-
/** When the
|
|
11872
|
-
|
|
11873
|
-
/** Repoint episodes already announced (`type:from->to`). Cleared the moment a
|
|
11874
|
-
* pass sees no repoint at all — the next one is a NEW episode. */
|
|
11875
|
-
repointAnnounced = /* @__PURE__ */ new Set();
|
|
12049
|
+
/** The census last announced, as a stable signature. `null` ⇒ nothing
|
|
12050
|
+
* announced yet. */
|
|
12051
|
+
censusSignature = null;
|
|
12052
|
+
/** When the census was last announced. */
|
|
12053
|
+
censusReportedAt = null;
|
|
11876
12054
|
constructor(deps) {
|
|
11877
12055
|
this.deps = deps;
|
|
11878
12056
|
}
|
|
@@ -11907,15 +12085,14 @@ var PlacementService = class {
|
|
|
11907
12085
|
async place(deviceId, profile) {
|
|
11908
12086
|
const pool = this.pool();
|
|
11909
12087
|
this.reprobeAfterBackoff(pool);
|
|
11910
|
-
const
|
|
11911
|
-
const resolved = await this.assignedLocation(deviceId, profile, pool, authority) ?? resolvePlacement(pool, profile);
|
|
12088
|
+
const resolved = await this.assignedLocation(deviceId, profile, pool) ?? resolvePlacement(await this.withHeadroom(pool), profile);
|
|
11912
12089
|
const usable = await this.ensureUsable(resolved, pool, profile, deviceId);
|
|
11913
12090
|
if (this.everyLocationUnusable(pool)) {
|
|
11914
12091
|
this.reportEveryLocationUnusable(deviceId, profile, resolved, pool);
|
|
11915
12092
|
return resolved;
|
|
11916
12093
|
}
|
|
11917
12094
|
this.allUnusableReported.delete(deviceId);
|
|
11918
|
-
await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id
|
|
12095
|
+
await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id);
|
|
11919
12096
|
return usable;
|
|
11920
12097
|
}
|
|
11921
12098
|
/**
|
|
@@ -11999,35 +12176,30 @@ var PlacementService = class {
|
|
|
11999
12176
|
* to it, so an unknown flag is the only thing that counts as opted in.
|
|
12000
12177
|
*/
|
|
12001
12178
|
pool() {
|
|
12002
|
-
const { locations,
|
|
12003
|
-
if (
|
|
12179
|
+
const { locations, fellBackToDisabled } = placementPool(this.deps.locations());
|
|
12180
|
+
if (fellBackToDisabled) this.deps.logger.warn("recorder placement: NO recordings location is enabled for writing — writing to a DISABLED location under protest. Enable one in Storage.", { meta: { locationIds: locations.map((l) => l.id) } });
|
|
12004
12181
|
return locations;
|
|
12005
12182
|
}
|
|
12006
12183
|
/**
|
|
12007
12184
|
* The stored assignment for one (camera, profile), when it is still valid.
|
|
12008
12185
|
*
|
|
12009
|
-
*
|
|
12186
|
+
* Two things invalidate it, and under the operator's model (D383) there is
|
|
12187
|
+
* nothing else that can:
|
|
12010
12188
|
*
|
|
12011
|
-
* 1. the location is absent from the current
|
|
12012
|
-
*
|
|
12013
|
-
*
|
|
12014
|
-
*
|
|
12189
|
+
* 1. the location is absent from the current POOL — deleted, or the
|
|
12190
|
+
* operator disabled it. The pool is the single write authority, so an
|
|
12191
|
+
* assignment naming a location outside it is not a divergence to report,
|
|
12192
|
+
* it is unrepresentable;
|
|
12193
|
+
* 2. the location is read-only (an operator drain).
|
|
12015
12194
|
*
|
|
12016
|
-
*
|
|
12017
|
-
*
|
|
12018
|
-
*
|
|
12019
|
-
*
|
|
12020
|
-
*
|
|
12021
|
-
*
|
|
12022
|
-
* authority. On 2026-08-29 that left two of thirteen cameras writing to the
|
|
12023
|
-
* disk the fleet was migrating off, silently (D298).
|
|
12024
|
-
*
|
|
12025
|
-
* The invalidation is a TRANSITION, not a policy: it fires only while the
|
|
12026
|
-
* stamped authority disagrees with the live one, which the next placement pass
|
|
12027
|
-
* ends by stamping. After that the plan is the authority again and a
|
|
12028
|
-
* deliberate spread is honoured — and reported (`reportDivergence`).
|
|
12195
|
+
* There used to be a third — "the class was REPOINTED under it", measured
|
|
12196
|
+
* against the class default stamped in the blob (D301). It existed only
|
|
12197
|
+
* because the default was a SECOND write authority layered over the pool and
|
|
12198
|
+
* the two could disagree. With the default deleted the disagreement cannot be
|
|
12199
|
+
* expressed: a location leaving the pool already invalidates every assignment
|
|
12200
|
+
* naming it, and the planner then moves those keys with `reason: 'gone'`.
|
|
12029
12201
|
*/
|
|
12030
|
-
async assignedLocation(deviceId, profile, pool
|
|
12202
|
+
async assignedLocation(deviceId, profile, pool) {
|
|
12031
12203
|
let blob;
|
|
12032
12204
|
try {
|
|
12033
12205
|
blob = await readPlacementState(this.deps.state);
|
|
@@ -12049,23 +12221,28 @@ var PlacementService = class {
|
|
|
12049
12221
|
}
|
|
12050
12222
|
if (assignedId === void 0) return null;
|
|
12051
12223
|
const location = pool.find((l) => l.id === assignedId && !l.readOnly) ?? null;
|
|
12052
|
-
if (location === null)
|
|
12053
|
-
const type = placementTypeForProfile(profile);
|
|
12054
|
-
const target = authority.get(type);
|
|
12055
|
-
const stamped = blob.defaultLocations[type];
|
|
12056
|
-
if (target === void 0 || stamped === void 0) return location;
|
|
12057
|
-
if (stamped === target || location.id === target) return location;
|
|
12058
|
-
this.deps.logger.warn("recorder placement: stored assignment INVALIDATED — this recordings class was repointed and the assignment still names the location it was repointed away from", {
|
|
12224
|
+
if (location === null) this.deps.logger.info("recorder placement: stored assignment INVALIDATED — its location is not a write target any more (disabled, read-only, or gone). Resolving fresh from the pool.", {
|
|
12059
12225
|
tags: { deviceId },
|
|
12060
12226
|
meta: {
|
|
12061
12227
|
profile,
|
|
12062
|
-
assignedLocationId:
|
|
12063
|
-
|
|
12064
|
-
repointedTo: target,
|
|
12065
|
-
locationType: type
|
|
12228
|
+
assignedLocationId: assignedId,
|
|
12229
|
+
poolLocationIds: pool.filter((l) => !l.readOnly).map((l) => l.id)
|
|
12066
12230
|
}
|
|
12067
12231
|
});
|
|
12068
|
-
return
|
|
12232
|
+
return location;
|
|
12233
|
+
}
|
|
12234
|
+
/** The pool, with each member's measured headroom attached. One capacity read
|
|
12235
|
+
* per location; used only on the bootstrap path, never per segment. */
|
|
12236
|
+
async withHeadroom(pool) {
|
|
12237
|
+
const candidates = await this.candidatesFor(pool);
|
|
12238
|
+
const byId = new Map(candidates.map((c) => [c.locationId, c.headroomBytes]));
|
|
12239
|
+
return pool.map((location) => {
|
|
12240
|
+
const headroomBytes = byId.get(location.id);
|
|
12241
|
+
return headroomBytes === void 0 ? location : {
|
|
12242
|
+
...location,
|
|
12243
|
+
headroomBytes
|
|
12244
|
+
};
|
|
12245
|
+
});
|
|
12069
12246
|
}
|
|
12070
12247
|
/**
|
|
12071
12248
|
* Re-plan the (camera, profile) pairs in `work` over the current locations.
|
|
@@ -12087,7 +12264,6 @@ var PlacementService = class {
|
|
|
12087
12264
|
*/
|
|
12088
12265
|
async recompute(work) {
|
|
12089
12266
|
const pool = this.pool();
|
|
12090
|
-
const authority = authorityLocations(pool);
|
|
12091
12267
|
const candidates = await this.candidatesFor(pool);
|
|
12092
12268
|
const demands = work.map((item) => ({
|
|
12093
12269
|
key: assignmentKey(item.deviceId, item.profile),
|
|
@@ -12104,17 +12280,7 @@ var PlacementService = class {
|
|
|
12104
12280
|
} });
|
|
12105
12281
|
return NO_PLAN;
|
|
12106
12282
|
}
|
|
12107
|
-
const
|
|
12108
|
-
const repoints = this.detectRepoints(blob.assignments, stamped, authority, blob.pins);
|
|
12109
|
-
if (repoints.length === 0) this.repointAnnounced.clear();
|
|
12110
|
-
for (const repoint of repoints) this.announceRepoint(repoint);
|
|
12111
|
-
const repointed = /* @__PURE__ */ new Map();
|
|
12112
|
-
for (const repoint of repoints) for (const key of repoint.keys) repointed.set(key, repoint.to);
|
|
12113
|
-
const current = applyPlacementPass(blob.assignments, {
|
|
12114
|
-
repointed,
|
|
12115
|
-
planned: /* @__PURE__ */ new Map(),
|
|
12116
|
-
defaultLocations: authority
|
|
12117
|
-
});
|
|
12283
|
+
const current = new Map(Object.entries(blob.assignments));
|
|
12118
12284
|
const evacuate = new Set(candidates.filter((c) => c.headroomBytes <= 0).map((c) => c.locationId));
|
|
12119
12285
|
for (const location of pool) if (location.readOnly) evacuate.add(location.id);
|
|
12120
12286
|
const plan = planPlacement({
|
|
@@ -12124,13 +12290,9 @@ var PlacementService = class {
|
|
|
12124
12290
|
evacuate,
|
|
12125
12291
|
breakTie: (tied) => this.rotation.next(tied)
|
|
12126
12292
|
});
|
|
12127
|
-
const pass = {
|
|
12128
|
-
|
|
12129
|
-
|
|
12130
|
-
defaultLocations: authority
|
|
12131
|
-
};
|
|
12132
|
-
this.reportDivergence(applyPlacementPass(blob.assignments, pass), authority);
|
|
12133
|
-
if (plan.changes.length === 0 && repointed.size === 0 && sameStamp(stamped, authority)) return plan;
|
|
12293
|
+
const pass = { planned: overlayPins(plan.assignments, blob.pins) };
|
|
12294
|
+
this.reportCensus(applyPlacementPass(blob.assignments, pass), pool);
|
|
12295
|
+
if (plan.changes.length === 0) return plan;
|
|
12134
12296
|
for (const change of plan.changes) {
|
|
12135
12297
|
const deviceId = Number(change.key.split(":")[0]);
|
|
12136
12298
|
this.deps.logger.info("recorder placement: plan moves a profile to another location", {
|
|
@@ -12154,129 +12316,60 @@ var PlacementService = class {
|
|
|
12154
12316
|
return plan;
|
|
12155
12317
|
}
|
|
12156
12318
|
/**
|
|
12157
|
-
*
|
|
12158
|
-
*
|
|
12159
|
-
*
|
|
12160
|
-
* PURE. A class is repointed when the blob stamped an authority for it and
|
|
12161
|
-
* the authority now is a different location. A class with NO stamp is not a
|
|
12162
|
-
* repoint — it is the first pass after this shipped, and inferring a repoint
|
|
12163
|
-
* from an absent stamp would throw away every deliberate assignment on the
|
|
12164
|
-
* first tick after a deploy.
|
|
12165
|
-
*/
|
|
12166
|
-
detectRepoints(stored, stamped, authority, pins) {
|
|
12167
|
-
const out = [];
|
|
12168
|
-
for (const [type, to] of authority) {
|
|
12169
|
-
const from = stamped.get(type);
|
|
12170
|
-
if (from === void 0 || from === to) continue;
|
|
12171
|
-
const keys = [];
|
|
12172
|
-
for (const [key, locationId] of Object.entries(stored)) {
|
|
12173
|
-
const [devicePart, profilePart] = key.split(":");
|
|
12174
|
-
if (placementTypeForProfile(profilePart ?? "") !== type) continue;
|
|
12175
|
-
if (locationId === to) continue;
|
|
12176
|
-
if (type === "recordings" && devicePart !== void 0 && pins[devicePart] !== void 0) continue;
|
|
12177
|
-
keys.push(key);
|
|
12178
|
-
}
|
|
12179
|
-
out.push({
|
|
12180
|
-
type,
|
|
12181
|
-
from,
|
|
12182
|
-
to,
|
|
12183
|
-
keys
|
|
12184
|
-
});
|
|
12185
|
-
}
|
|
12186
|
-
return out;
|
|
12187
|
-
}
|
|
12188
|
-
/**
|
|
12189
|
-
* One line PER CAMERA (a repoint is asked per-camera: "did 3839 move?"), plus
|
|
12190
|
-
* one audit row per class. A repoint is a rare operator event, so the volume
|
|
12191
|
-
* is one burst of fleet-size, not a stream.
|
|
12192
|
-
*
|
|
12193
|
-
* Announced ONCE per episode: the detection is a comparison against the
|
|
12194
|
-
* stamp, so a write that fails leaves the stamp behind and the next tick sees
|
|
12195
|
-
* the same repoint again. Without the latch, a settings door that accepts
|
|
12196
|
-
* reads and refuses writes would repeat the whole burst every 30 s.
|
|
12197
|
-
*/
|
|
12198
|
-
announceRepoint(repoint) {
|
|
12199
|
-
const episode = `${repoint.type}:${repoint.from}->${repoint.to}`;
|
|
12200
|
-
if (this.repointAnnounced.has(episode)) return;
|
|
12201
|
-
this.repointAnnounced.add(episode);
|
|
12202
|
-
for (const key of repoint.keys) {
|
|
12203
|
-
const deviceId = Number(key.split(":")[0]);
|
|
12204
|
-
this.deps.logger.warn("recorder placement: recordings class REPOINTED — moving this assignment onto the class`s new authority location", {
|
|
12205
|
-
...Number.isFinite(deviceId) ? { tags: { deviceId } } : {},
|
|
12206
|
-
meta: {
|
|
12207
|
-
key,
|
|
12208
|
-
locationType: repoint.type,
|
|
12209
|
-
repointedFrom: repoint.from,
|
|
12210
|
-
repointedTo: repoint.to
|
|
12211
|
-
}
|
|
12212
|
-
});
|
|
12213
|
-
}
|
|
12214
|
-
this.deps.opsLog.append({
|
|
12215
|
-
op: "relocate",
|
|
12216
|
-
reason: "operator",
|
|
12217
|
-
deviceId: null,
|
|
12218
|
-
itemsAffected: repoint.keys.length,
|
|
12219
|
-
bytesReclaimed: 0,
|
|
12220
|
-
actor: "system",
|
|
12221
|
-
detail: `placement: ${repoint.type} repointed ${repoint.from} → ${repoint.to}; ${repoint.keys.length.toString()} assignment(s) moved (${repoint.keys.join(", ")})`
|
|
12222
|
-
});
|
|
12223
|
-
}
|
|
12224
|
-
/**
|
|
12225
|
-
* Say which (camera, profile) the plan writes somewhere other than its class
|
|
12226
|
-
* authority — per camera, WITH its denominator (D277).
|
|
12319
|
+
* WHERE THE FLEET WRITES — one line per (class, location), always with its
|
|
12320
|
+
* denominator (D277).
|
|
12227
12321
|
*
|
|
12228
|
-
* This
|
|
12229
|
-
*
|
|
12230
|
-
*
|
|
12231
|
-
*
|
|
12232
|
-
*
|
|
12233
|
-
*
|
|
12234
|
-
*
|
|
12322
|
+
* This replaced the class-authority divergence report (D301) when the default
|
|
12323
|
+
* was deleted: with every enabled location of a class a legitimate target,
|
|
12324
|
+
* "this camera does not write to THE location" is not a fact any more, while
|
|
12325
|
+
* "recordings: 9 of 13 on recordings:default, 4 on recordings:hgst" is
|
|
12326
|
+
* exactly the question an operator asks after enabling a second disk. A pool
|
|
12327
|
+
* member holding ZERO assignments is listed too — an enabled disk nothing
|
|
12328
|
+
* lands on is the failure this whole model exists to make visible.
|
|
12235
12329
|
*
|
|
12236
|
-
* Fires on a CHANGE of the
|
|
12237
|
-
* {@link
|
|
12238
|
-
* until it stops being all-aligned, and again when it recovers.
|
|
12330
|
+
* Fires on a CHANGE of the distribution, and otherwise at most once per
|
|
12331
|
+
* {@link PLACEMENT_CENSUS_REPORT_MS}.
|
|
12239
12332
|
*/
|
|
12240
|
-
|
|
12241
|
-
const
|
|
12333
|
+
reportCensus(assignments, pool) {
|
|
12334
|
+
const counts = /* @__PURE__ */ new Map();
|
|
12335
|
+
const perClass = /* @__PURE__ */ new Map();
|
|
12336
|
+
for (const location of pool) if (!location.readOnly) counts.set(location.id, 0);
|
|
12242
12337
|
for (const [key, locationId] of assignments) {
|
|
12243
|
-
|
|
12244
|
-
const
|
|
12245
|
-
|
|
12246
|
-
divergent.push({
|
|
12247
|
-
key,
|
|
12248
|
-
deviceId: Number(key.split(":")[0]),
|
|
12249
|
-
profile,
|
|
12250
|
-
locationId,
|
|
12251
|
-
authorityLocationId
|
|
12252
|
-
});
|
|
12338
|
+
counts.set(locationId, (counts.get(locationId) ?? 0) + 1);
|
|
12339
|
+
const type = placementTypeForProfile(key.split(":")[1] ?? "");
|
|
12340
|
+
perClass.set(type, (perClass.get(type) ?? 0) + 1);
|
|
12253
12341
|
}
|
|
12254
|
-
const
|
|
12255
|
-
const
|
|
12342
|
+
const typeOf = new Map(pool.map((location) => [location.id, location.type]));
|
|
12343
|
+
const lines = [...counts.entries()].toSorted(([a], [b]) => a.localeCompare(b)).map(([locationId, count]) => {
|
|
12344
|
+
const type = typeOf.get(locationId) ?? locationId.split(":")[0] ?? locationId;
|
|
12345
|
+
return {
|
|
12346
|
+
locationId,
|
|
12347
|
+
type,
|
|
12348
|
+
assignments: count,
|
|
12349
|
+
ofClass: perClass.get(type) ?? 0
|
|
12350
|
+
};
|
|
12351
|
+
});
|
|
12352
|
+
const signature = lines.map((line) => `${line.locationId}=${String(line.assignments)}`).join(",");
|
|
12256
12353
|
const now = this.now;
|
|
12257
|
-
const due = this.
|
|
12258
|
-
if (signature ===
|
|
12259
|
-
this.
|
|
12260
|
-
this.
|
|
12261
|
-
if (
|
|
12262
|
-
|
|
12263
|
-
|
|
12354
|
+
const due = this.censusReportedAt === null || now - this.censusReportedAt >= 36e5;
|
|
12355
|
+
if (signature === this.censusSignature && !due) return;
|
|
12356
|
+
this.censusSignature = signature;
|
|
12357
|
+
this.censusReportedAt = now;
|
|
12358
|
+
if (lines.length === 0) return;
|
|
12359
|
+
this.deps.logger.info("recorder placement: census — which locations the fleet writes to", { meta: { locations: lines.map((line) => ({
|
|
12360
|
+
locationId: line.locationId,
|
|
12361
|
+
type: line.type,
|
|
12362
|
+
assignments: line.assignments,
|
|
12363
|
+
ofClass: line.ofClass
|
|
12364
|
+
})) } });
|
|
12365
|
+
for (const line of lines) {
|
|
12366
|
+
if (line.assignments > 0) continue;
|
|
12367
|
+
this.deps.logger.warn("recorder placement: this location is ENABLED but holds no assignment — nothing writes to it", { meta: {
|
|
12368
|
+
locationId: line.locationId,
|
|
12369
|
+
type: line.type,
|
|
12370
|
+
ofClass: line.ofClass
|
|
12371
|
+
} });
|
|
12264
12372
|
}
|
|
12265
|
-
this.deps.logger.warn("recorder placement: assignments write somewhere other than their class authority location", { meta: {
|
|
12266
|
-
divergent: divergent.length,
|
|
12267
|
-
assignments: assignments.size,
|
|
12268
|
-
authority: Object.fromEntries(authority)
|
|
12269
|
-
} });
|
|
12270
|
-
for (const entry of divergent) this.deps.logger.warn("recorder placement: this profile does NOT write to its class authority location", {
|
|
12271
|
-
...Number.isFinite(entry.deviceId) ? { tags: { deviceId: entry.deviceId } } : {},
|
|
12272
|
-
meta: {
|
|
12273
|
-
profile: entry.profile,
|
|
12274
|
-
locationId: entry.locationId,
|
|
12275
|
-
authorityLocationId: entry.authorityLocationId,
|
|
12276
|
-
divergent: divergent.length,
|
|
12277
|
-
assignments: assignments.size
|
|
12278
|
-
}
|
|
12279
|
-
});
|
|
12280
12373
|
}
|
|
12281
12374
|
/** Bound one addon-store round trip. See {@link PLACEMENT_STORE_TIMEOUT_MS}. */
|
|
12282
12375
|
bounded(promise, label) {
|
|
@@ -12366,7 +12459,7 @@ var PlacementService = class {
|
|
|
12366
12459
|
}
|
|
12367
12460
|
if (location.maxUsedGb !== void 0) {
|
|
12368
12461
|
const used = await this.deps.usedOnLocation?.(location.id) ?? 0;
|
|
12369
|
-
limits.push(location.maxUsedGb
|
|
12462
|
+
limits.push(require_dist.gbToBytes(location.maxUsedGb) - used);
|
|
12370
12463
|
}
|
|
12371
12464
|
out.push({
|
|
12372
12465
|
locationId: location.id,
|
|
@@ -12457,13 +12550,13 @@ var PlacementService = class {
|
|
|
12457
12550
|
* Persist the assignment and audit it — but only when it actually changed.
|
|
12458
12551
|
* The steady state (every attach after the first) writes nothing.
|
|
12459
12552
|
*
|
|
12460
|
-
* The per-attach line carries
|
|
12461
|
-
*
|
|
12462
|
-
*
|
|
12463
|
-
*
|
|
12464
|
-
*
|
|
12553
|
+
* The per-attach line carries the camera's location and the one it replaced,
|
|
12554
|
+
* tagged with `deviceId`, so "where does 3839 write, and did it move?" is
|
|
12555
|
+
* answerable per camera. Which locations the FLEET is spread over is the
|
|
12556
|
+
* census's job (D383) — there is no class authority for a single line to be
|
|
12557
|
+
* measured against any more.
|
|
12465
12558
|
*/
|
|
12466
|
-
async recordDecision(deviceId, profile, location, verbatim
|
|
12559
|
+
async recordDecision(deviceId, profile, location, verbatim) {
|
|
12467
12560
|
const key = assignmentKey(deviceId, profile);
|
|
12468
12561
|
let previous;
|
|
12469
12562
|
try {
|
|
@@ -12484,8 +12577,6 @@ var PlacementService = class {
|
|
|
12484
12577
|
locationId: location.id,
|
|
12485
12578
|
root: location.root,
|
|
12486
12579
|
previousLocationId: previous ?? null,
|
|
12487
|
-
authorityLocationId,
|
|
12488
|
-
divergent: authorityLocationId !== null && authorityLocationId !== location.id,
|
|
12489
12580
|
reason: verbatim ? "resolved" : "failover"
|
|
12490
12581
|
}
|
|
12491
12582
|
});
|
|
@@ -12613,7 +12704,7 @@ function effectiveBands(config) {
|
|
|
12613
12704
|
*/
|
|
12614
12705
|
function resolveBandBufferMs(band) {
|
|
12615
12706
|
return {
|
|
12616
|
-
preMs:
|
|
12707
|
+
preMs: require_restream_intent.PREBUFFER_MAX_MS,
|
|
12617
12708
|
postMs: (band.postBufferSec ?? require_dist.DEFAULT_EVENTS_BAND_BUFFER_SEC.postBufferSec) * 1e3
|
|
12618
12709
|
};
|
|
12619
12710
|
}
|
|
@@ -14456,6 +14547,26 @@ var RecordingController = class {
|
|
|
14456
14547
|
this.wakeups.arm(deviceId, next.atMs, next.reason);
|
|
14457
14548
|
}
|
|
14458
14549
|
/**
|
|
14550
|
+
* Where every LIVE writer is writing right now — the directory ffmpeg was
|
|
14551
|
+
* spawned against, per (device, profile).
|
|
14552
|
+
*
|
|
14553
|
+
* The plan and the disk disagree for as long as a writer outlives the tick
|
|
14554
|
+
* that re-planned it: placement applies at ATTACH BOUNDARIES only (the
|
|
14555
|
+
* `.rec-tmp` staging dir lives under the chosen root so the finalize
|
|
14556
|
+
* relocate is a same-filesystem rename), so a continuously-recording camera
|
|
14557
|
+
* keeps its root until something detaches it. That is a legitimate state and
|
|
14558
|
+
* a silent one, which is why it is READABLE from here (D387).
|
|
14559
|
+
*/
|
|
14560
|
+
activeWriterDirs() {
|
|
14561
|
+
const out = [];
|
|
14562
|
+
for (const [deviceId, recordings] of this.active) for (const r of recordings) out.push({
|
|
14563
|
+
deviceId,
|
|
14564
|
+
profile: r.profile,
|
|
14565
|
+
outDir: r.outDir
|
|
14566
|
+
});
|
|
14567
|
+
return out;
|
|
14568
|
+
}
|
|
14569
|
+
/**
|
|
14459
14570
|
* Boot restore: seed the readiness-gated queue with every persisted device and
|
|
14460
14571
|
* (re)evaluate each on every `stream-broker` ready transition. Also arms the
|
|
14461
14572
|
* periodic passes. Idempotent — safe to call once after the index is hydrated.
|
|
@@ -15383,7 +15494,7 @@ var RecordingController = class {
|
|
|
15383
15494
|
tag: `recorder:${deviceId}/${profile}`
|
|
15384
15495
|
}, require_dist.nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "getStreamWithCodec"));
|
|
15385
15496
|
const streamAcquiredMs = this.now();
|
|
15386
|
-
const rtspUrl = require_restream_intent.withRecordingIntent(source.url);
|
|
15497
|
+
const rtspUrl = require_restream_intent.withRecordingIntent(source.url, require_restream_intent.RECORDING_PRE_ROLL_REQUEST_MS);
|
|
15387
15498
|
this.attachSeq++;
|
|
15388
15499
|
const consumerTag = `recorder/${deviceId}/${profile}/${this.attachSeq}`;
|
|
15389
15500
|
if (rtspUrl === source.url) this.deps.logger.info("recorder: dial carries no recording intent — no pre-roll", {
|
|
@@ -16841,6 +16952,21 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
16841
16952
|
* per episode, not one per 30 s tick. `null` ⇒ the last pass had work. */
|
|
16842
16953
|
placementWorkGap = null;
|
|
16843
16954
|
/**
|
|
16955
|
+
* When the recorder re-resolves its storage locations (D387): coalesced off
|
|
16956
|
+
* the `StorageLocationsChanged` doorbell, and converged on the placement
|
|
16957
|
+
* pass's own timer whether or not that doorbell ever arrives. Built on the
|
|
16958
|
+
* recording node only, with the rest of the write path.
|
|
16959
|
+
*/
|
|
16960
|
+
locationRefresher = null;
|
|
16961
|
+
/** Unsubscribe handle for the `StorageLocationsChanged` subscription. */
|
|
16962
|
+
locationChangeUnsub = null;
|
|
16963
|
+
/**
|
|
16964
|
+
* How many live writers are recording somewhere other than their planned
|
|
16965
|
+
* location, latched so the line is one per episode rather than one per 30 s
|
|
16966
|
+
* tick. `null` ⇒ nothing pending (or nothing measured yet).
|
|
16967
|
+
*/
|
|
16968
|
+
pendingWriterMoves = null;
|
|
16969
|
+
/**
|
|
16844
16970
|
* The calendar rungs — which days, which hours, and a day's exact ranges.
|
|
16845
16971
|
*
|
|
16846
16972
|
* Hours the RAM index has already walked (or the live hour it is
|
|
@@ -17065,8 +17191,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
17065
17191
|
},
|
|
17066
17192
|
archiveHourPaths: async (deviceId, profile, locationId, hourStartMs) => await this.segmentHours?.hourPaths(deviceId, profile, locationId, hourStartMs) ?? null,
|
|
17067
17193
|
removeDir: async (locationId, relDir) => {
|
|
17068
|
-
const loc = this.resolvedLocations
|
|
17069
|
-
if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
|
|
17194
|
+
const loc = requireReadableLocation(this.resolvedLocations, locationId, "recorder");
|
|
17070
17195
|
await node_fs.promises.rm(node_path.default.join(loc.root, relDir), {
|
|
17071
17196
|
recursive: true,
|
|
17072
17197
|
force: true
|
|
@@ -17104,8 +17229,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
17104
17229
|
});
|
|
17105
17230
|
},
|
|
17106
17231
|
removeDirIfEmpty: async (locationId, relDir) => {
|
|
17107
|
-
const loc = this.resolvedLocations
|
|
17108
|
-
if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
|
|
17232
|
+
const loc = requireReadableLocation(this.resolvedLocations, locationId, "recorder");
|
|
17109
17233
|
await node_fs.promises.rmdir(node_path.default.join(loc.root, relDir));
|
|
17110
17234
|
},
|
|
17111
17235
|
logger: {
|
|
@@ -17318,7 +17442,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
17318
17442
|
logger: this.ctx.logger,
|
|
17319
17443
|
store: this.segmentStore,
|
|
17320
17444
|
resolveDomain: (locationId) => this.locationDomain(locationId),
|
|
17321
|
-
|
|
17445
|
+
isEvictionFrozen: (locationId) => {
|
|
17446
|
+
const location = this.resolvedLocations.find((l) => l.id === locationId);
|
|
17447
|
+
return location !== void 0 && require_dist.evictionPolicyForMode(location.mode) === "never";
|
|
17448
|
+
},
|
|
17322
17449
|
deviceRegistry: async () => {
|
|
17323
17450
|
try {
|
|
17324
17451
|
const devices = await this.ctx.api.deviceManager.listAll.query({ projection: "slim" });
|
|
@@ -17498,6 +17625,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
17498
17625
|
capability: require_dist.storageEvictableCapability,
|
|
17499
17626
|
provider: evictableProvider
|
|
17500
17627
|
},
|
|
17628
|
+
{
|
|
17629
|
+
capability: require_dist.storageOccupancyCapability,
|
|
17630
|
+
provider: evictableProvider
|
|
17631
|
+
},
|
|
17501
17632
|
{
|
|
17502
17633
|
capability: require_dist.recordingExportCapability,
|
|
17503
17634
|
provider: exportProvider
|
|
@@ -17548,6 +17679,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
17548
17679
|
this.sensorTriggerUnsub = null;
|
|
17549
17680
|
this.signalTriggerUnsub?.();
|
|
17550
17681
|
this.signalTriggerUnsub = null;
|
|
17682
|
+
this.locationChangeUnsub?.();
|
|
17683
|
+
this.locationChangeUnsub = null;
|
|
17684
|
+
this.locationRefresher?.dispose();
|
|
17685
|
+
this.locationRefresher = null;
|
|
17551
17686
|
await this.controller?.stop();
|
|
17552
17687
|
this.controller = null;
|
|
17553
17688
|
this.segmentStore = null;
|
|
@@ -17663,6 +17798,13 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
17663
17798
|
this.ctx.logger.warn("recorder: controller start failed", { meta: { error: require_dist.errMsg(err) } });
|
|
17664
17799
|
}
|
|
17665
17800
|
this.recoverStagedOrphans(stagingBootMs);
|
|
17801
|
+
if (this.locationRefresher === null) this.locationRefresher = new LocationRefresher({
|
|
17802
|
+
logger: this.ctx.logger,
|
|
17803
|
+
refresh: () => this.refreshLocations()
|
|
17804
|
+
});
|
|
17805
|
+
if (this.locationChangeUnsub === null) this.locationChangeUnsub = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.StorageLocationsChanged }, (event) => {
|
|
17806
|
+
this.locationRefresher?.ring(event.data.locationId, event.data.reason);
|
|
17807
|
+
});
|
|
17666
17808
|
if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
|
|
17667
17809
|
eventBus: this.ctx.eventBus,
|
|
17668
17810
|
onMotion: (deviceId, atMs) => {
|
|
@@ -18069,6 +18211,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
18069
18211
|
*/
|
|
18070
18212
|
async recomputePlacement(deviceIds) {
|
|
18071
18213
|
if (!this.placement) return;
|
|
18214
|
+
await (this.locationRefresher?.convergeIfDue() ?? Promise.resolve());
|
|
18072
18215
|
const archive = this.segmentHours;
|
|
18073
18216
|
if (!archive) {
|
|
18074
18217
|
this.reportPlacementWorkGap("no segment-hour ledger on this node", deviceIds.length, 0);
|
|
@@ -18085,7 +18228,48 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
18085
18228
|
return;
|
|
18086
18229
|
}
|
|
18087
18230
|
this.placementWorkGap = null;
|
|
18088
|
-
await this.placement.recompute(work);
|
|
18231
|
+
const plan = await this.placement.recompute(work);
|
|
18232
|
+
this.reportPendingWriterMoves(plan.assignments);
|
|
18233
|
+
}
|
|
18234
|
+
/**
|
|
18235
|
+
* Cameras whose plan says one disk and whose live writer says another —
|
|
18236
|
+
* REPORTED, per camera, for as long as it stands (D387).
|
|
18237
|
+
*
|
|
18238
|
+
* Placement applies at attach boundaries only, so a re-planned camera moves
|
|
18239
|
+
* when it next attaches. A camera on a schedule attaches again within
|
|
18240
|
+
* minutes; a CONTINUOUSLY recording one never does, and its plan can sit
|
|
18241
|
+
* unapplied forever. Repointing a live writer is not the fix — the
|
|
18242
|
+
* `.rec-tmp` staging dir lives under the chosen root precisely so the
|
|
18243
|
+
* finalize relocate is a same-filesystem rename — and detaching the fleet to
|
|
18244
|
+
* apply a plan costs a recording gap on every affected camera at once, on
|
|
18245
|
+
* the very day the operator added a disk to relieve pressure. So the honest
|
|
18246
|
+
* behaviour is to say it, name the cameras, and let the operator decide.
|
|
18247
|
+
*
|
|
18248
|
+
* Latched on WHAT is pending: a line per 30 s tick would be its own defect.
|
|
18249
|
+
*/
|
|
18250
|
+
reportPendingWriterMoves(planned) {
|
|
18251
|
+
const controller = this.controller;
|
|
18252
|
+
if (!controller) return;
|
|
18253
|
+
const moves = pendingWriterMoves(controller.activeWriterDirs(), planned, this.resolvedLocations);
|
|
18254
|
+
const signature = pendingMovesSignature(moves);
|
|
18255
|
+
if (signature === this.pendingWriterMoves) return;
|
|
18256
|
+
this.pendingWriterMoves = signature;
|
|
18257
|
+
if (moves.length === 0) return;
|
|
18258
|
+
for (const move of moves) this.ctx.logger.warn("recorder placement: this camera is PLANNED for another location and its writer will not move until it next attaches", {
|
|
18259
|
+
tags: { deviceId: move.deviceId },
|
|
18260
|
+
meta: {
|
|
18261
|
+
profile: move.profile,
|
|
18262
|
+
writingTo: move.writingTo,
|
|
18263
|
+
plannedFor: move.plannedFor,
|
|
18264
|
+
hint: "a continuously-recording camera never re-attaches on its own — toggle its recording switch, or restart the recorder, to apply the plan (each costs one gap on that camera)"
|
|
18265
|
+
}
|
|
18266
|
+
});
|
|
18267
|
+
this.emitAlert({
|
|
18268
|
+
severity: "warning",
|
|
18269
|
+
title: "Recording placement waiting on a writer restart",
|
|
18270
|
+
message: `${String(moves.length)} recording profile(s) are planned for a different storage location and will keep writing to their current one until each camera next attaches. Continuously-recording cameras never re-attach on their own.`,
|
|
18271
|
+
locationId: moves[0]?.plannedFor ?? ""
|
|
18272
|
+
});
|
|
18089
18273
|
}
|
|
18090
18274
|
/**
|
|
18091
18275
|
* A placement pass that plans NOTHING says so — once per episode.
|
|
@@ -18248,12 +18432,22 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
18248
18432
|
* still be shouted about in the log even when it is unreachable.
|
|
18249
18433
|
*/
|
|
18250
18434
|
emitPlacementAlert(input) {
|
|
18251
|
-
this.
|
|
18252
|
-
id: (0, node_crypto.randomUUID)(),
|
|
18253
|
-
category: "storage",
|
|
18435
|
+
this.emitAlert({
|
|
18254
18436
|
severity: "error",
|
|
18255
18437
|
title: "Recordings storage location unusable",
|
|
18256
18438
|
message: input.detail,
|
|
18439
|
+
locationId: input.locationId
|
|
18440
|
+
});
|
|
18441
|
+
}
|
|
18442
|
+
/** One storage-category alert. Advisory: the LOG line is the durable record,
|
|
18443
|
+
* so a failed emit is swallowed rather than raised. */
|
|
18444
|
+
emitAlert(input) {
|
|
18445
|
+
this.ctx.api.alerts.emit.mutate({
|
|
18446
|
+
id: (0, node_crypto.randomUUID)(),
|
|
18447
|
+
category: "storage",
|
|
18448
|
+
severity: input.severity,
|
|
18449
|
+
title: input.title,
|
|
18450
|
+
message: input.message,
|
|
18257
18451
|
status: "active",
|
|
18258
18452
|
read: false,
|
|
18259
18453
|
createdAt: Date.now(),
|
|
@@ -18410,7 +18604,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
18410
18604
|
*/
|
|
18411
18605
|
async purgeFlattenedSettingsKeys() {
|
|
18412
18606
|
try {
|
|
18413
|
-
await
|
|
18607
|
+
await require_retire_root_keys.retireRootKeys({
|
|
18414
18608
|
store: this.ctx.api.settingsStore,
|
|
18415
18609
|
addonId: RECORDER_ADDON_ID,
|
|
18416
18610
|
logger: this.ctx.logger,
|
|
@@ -18491,8 +18685,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
18491
18685
|
* manifest uses). The scrub packs read the SAME source segments as playback.
|
|
18492
18686
|
*/
|
|
18493
18687
|
resolveSegmentAbsPath(row) {
|
|
18494
|
-
const loc = this.resolvedLocations
|
|
18495
|
-
if (loc === void 0) throw new Error(`recorder scrub: unknown recordings location ${row.locationId}`);
|
|
18688
|
+
const loc = requireReadableLocation(this.resolvedLocations, row.locationId, "recorder scrub");
|
|
18496
18689
|
return node_path.default.join(loc.root, row.path);
|
|
18497
18690
|
}
|
|
18498
18691
|
/**
|