@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.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-
|
|
1
|
+
import { At as storageEvictableCapability, Ct as recordingSignalCapability, D as RecordingSignalStatusSchema, E as RecordingConfigSchema, Et as resolveLocationMode, Gt as DeviceFeature, H as deriveBatteryPresence, It as errMsg, Jt as hydrateSchema, Kt as DeviceType, Nt as stripRetiredBandPreBufferSec, Ot as resolveRecordingProfiles, P as batteryCapability, Q as evictionPolicyOfLocation, Qt as nodePin, R as customAction, St as recordingExportCapability, T as RECORDING_EXPORT_MAX_READ_BYTES, Ut as BaseAddon, V as defineCustomActions, W as deriveRecordingMode, Y as evaluateSensorEdge, Z as evictionPolicyForMode, an as boolean, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dn as preprocess, dt as modeMayWrite, et as gbToBytes, fn as record, g as ExportRecordSchema, hn as EventCategory, i as BatteryStatusSchema, in as array, it as loadContributionCapability, jt as storageOccupancyCapability, ln as number, lt as mayWriteToLocation, nn as sleep$1, pn as string, rn as _enum, rt as isFirstLevelMacroClass, tn as selectAssignedProfileSlots, un as object, ut as migrateLegacyDeviceSignalConfig, x as OpsLogEntrySchema, xt as recordingCapability } from "../dist-D2jcEuZD.mjs";
|
|
2
|
+
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-CmfXSgr3.mjs";
|
|
3
3
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
4
|
-
import { r as withRecordingIntent } from "../restream-intent-
|
|
4
|
+
import { c as PREBUFFER_MAX_MS, r as RECORDING_PRE_ROLL_REQUEST_MS, s as withRecordingIntent } from "../restream-intent-DWe2kInY.mjs";
|
|
5
5
|
import { n as startEventLoopStallMonitor } from "../event-loop-stall-monitor-DXLNBMkY.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import { createReadStream, promises, realpathSync } from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
@@ -1512,14 +1512,17 @@ var RecordingIndex = class {
|
|
|
1512
1512
|
/**
|
|
1513
1513
|
* The physical-device key of a location root: `realpath` of the resolved path,
|
|
1514
1514
|
* or the resolved path string itself when realpath throws (root missing).
|
|
1515
|
+
*
|
|
1516
|
+
* ONE derivation, shared with the orchestrator's same-type root refusal (D389)
|
|
1517
|
+
* — two implementations of "is this the same disk" is two answers waiting to
|
|
1518
|
+
* disagree. This side deliberately DROPS the `resolved` flag: an unresolvable
|
|
1519
|
+
* root falls back to the lexical path, which can only over-partition (treat one
|
|
1520
|
+
* disk as two) and never merge two different disks, the safe failure mode for a
|
|
1521
|
+
* data-loss guard. The orchestrator needs the flag, because a refusal must not
|
|
1522
|
+
* be built on a read that failed.
|
|
1515
1523
|
*/
|
|
1516
1524
|
function physicalRootKey(root, realpath = realpathSync) {
|
|
1517
|
-
|
|
1518
|
-
try {
|
|
1519
|
-
return realpath(resolved);
|
|
1520
|
-
} catch {
|
|
1521
|
-
return resolved;
|
|
1522
|
-
}
|
|
1525
|
+
return physicalRootOf(root, realpath).key;
|
|
1523
1526
|
}
|
|
1524
1527
|
/**
|
|
1525
1528
|
* The eviction DOMAIN of `locationId`: the set of location ids (INCLUDING
|
|
@@ -1534,211 +1537,578 @@ function resolveEvictionDomain(locations, locationId, realpath = realpathSync) {
|
|
|
1534
1537
|
for (const l of locations) if (physicalRootKey(l.root, realpath) === selfKey) domain.add(l.id);
|
|
1535
1538
|
return domain;
|
|
1536
1539
|
}
|
|
1537
|
-
|
|
1538
|
-
//#region src/recorder/relocate-engine.ts
|
|
1540
|
+
var RECORDING_LOCATION_TYPES = new Set(["recordings", "recordingsLow"]);
|
|
1539
1541
|
/**
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
* Mechanics, per HOUR (D291): throttled copy + size-verify of every file in the
|
|
1545
|
-
* hour → re-point the DURABLE hour row → delete the sources → re-point the RAM
|
|
1546
|
-
* index. The hour is the batch because the durable index is hour-granular, and
|
|
1547
|
-
* the sources outlive the durable write on purpose: between the copy and the
|
|
1548
|
-
* delete both locations hold the hour, so no crash can leave the index naming a
|
|
1549
|
-
* location without the bytes. The copy is `copy-if-absent` (an equal-size
|
|
1550
|
-
* destination is adopted, not re-copied), so cancel/crash/re-run RESUMES
|
|
1551
|
-
* instead of restarting, and a cancel inside an hour leaves that hour WHOLLY
|
|
1552
|
-
* readable at the source. Throttling is file-level pacing (segments are
|
|
1553
|
-
* ~1–8 MB / ~4 s, so per-file granularity bounds the rate tightly enough) —
|
|
1554
|
-
* the drain is a background chore that must never starve live writers.
|
|
1555
|
-
*
|
|
1556
|
-
* Runs INSIDE the recorder process — container uid, same-uid files, no host
|
|
1557
|
-
* chown trap (see docs/history 2026-07-29).
|
|
1558
|
-
*
|
|
1559
|
-
* ── Phase 2 (D116, multi-location) ────────────────────────────────────────
|
|
1560
|
-
* Two additions the OPERATOR-ARMED rebalance needs:
|
|
1561
|
-
*
|
|
1562
|
-
* - A move is scoped to one CAMERA (and optionally to specific profiles),
|
|
1563
|
-
* not just to a whole disk. The rebalance's unit is a (camera, profile)
|
|
1564
|
-
* pile, because that is the unit the placement plan assigns.
|
|
1565
|
-
* - Concurrent starts QUEUE instead of being refused. The engine is still
|
|
1566
|
-
* single-flight — two movers on one disk thrash both — but a rebalance
|
|
1567
|
-
* enqueues one job per pile, and refusing the second turned a
|
|
1568
|
-
* fifteen-camera rebalance into fifteen manual retries. Jobs run FIFO; a
|
|
1569
|
-
* job cancelled while queued never runs, and a job that FAILS does not
|
|
1570
|
-
* strand the queue behind it.
|
|
1542
|
+
* The recordings CLASS a profile belongs to. The one place this rule is
|
|
1543
|
+
* written: `resolvePlacement`, the planner's demand type and the placement
|
|
1544
|
+
* census must agree on it, and three copies of `profile === 'low' ? … : …` is
|
|
1545
|
+
* how they stop agreeing.
|
|
1571
1546
|
*/
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
/** Progress callback cadence (files) — cheap enough to fire often. */
|
|
1576
|
-
var PROGRESS_EVERY_FILES = 25;
|
|
1577
|
-
/** Free space a destination must keep when it declares no `config.minFreePercent`
|
|
1578
|
-
* — the same default `roots.ts` documents for placement. */
|
|
1579
|
-
var DEFAULT_MIN_FREE_PERCENT$1 = 10;
|
|
1580
|
-
/** Prefix every destination-full error carries, so the coordinator can report
|
|
1581
|
-
* "drain stalled: destination full" instead of a raw ENOSPC string. */
|
|
1582
|
-
var DESTINATION_FULL_PREFIX = "destination full";
|
|
1547
|
+
function placementTypeForProfile(profile) {
|
|
1548
|
+
return profile === "low" ? "recordingsLow" : "recordings";
|
|
1549
|
+
}
|
|
1583
1550
|
/**
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
* or an eviction, so that a transient read error cannot erase an operator's
|
|
1589
|
-
* index of real footage. A per-path ENOENT on a path derived from the row is
|
|
1590
|
-
* strong evidence about THAT path (D296) and weak evidence about a mount: a
|
|
1591
|
-
* root that vanished answers ENOENT for every path it is asked about, and looks
|
|
1592
|
-
* exactly like a location that is genuinely empty. The bound is what makes the
|
|
1593
|
-
* difference survivable — past it the mover keeps moving, keeps skipping, and
|
|
1594
|
-
* REPORTS, so an operator gets a reconcile to act on instead of an archive that
|
|
1595
|
-
* quietly emptied itself.
|
|
1551
|
+
* Narrow a resolved location list to the ones placement may write to: the
|
|
1552
|
+
* enabled ones — ALL of them, together. A location the operator has not opted
|
|
1553
|
+
* in (`enabled === false`) is still READ, age-swept and drained — it is simply
|
|
1554
|
+
* never a write target, exactly like a read-only one.
|
|
1596
1555
|
*
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1556
|
+
* When no enabled location is writable EVERY location serves instead, loudly.
|
|
1557
|
+
* The orchestrator refuses to leave a recordings class without an enabled
|
|
1558
|
+
* location, so this is reachable only through a hand-edited store — and a
|
|
1559
|
+
* recorder that writes nowhere is worse than one that writes under protest.
|
|
1600
1560
|
*/
|
|
1601
|
-
|
|
1602
|
-
|
|
1561
|
+
function placementPool(locations) {
|
|
1562
|
+
const writable = locations.filter((l) => locationMayBeWritten(l));
|
|
1563
|
+
if (writable.length > 0) return {
|
|
1564
|
+
locations: writable,
|
|
1565
|
+
fellBackToDisabled: false
|
|
1566
|
+
};
|
|
1603
1567
|
return {
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
fromLocationId: j.fromLocationId,
|
|
1607
|
-
toLocationId: j.toLocationId,
|
|
1608
|
-
deviceId: j.deviceId,
|
|
1609
|
-
entities: [...j.entities],
|
|
1610
|
-
filesMoved: j.filesMoved,
|
|
1611
|
-
bytesMoved: j.bytesMoved,
|
|
1612
|
-
filesTotal: j.filesTotal,
|
|
1613
|
-
rowsReconciled: j.rowsReconciled,
|
|
1614
|
-
rowsForgotten: j.rowsForgotten,
|
|
1615
|
-
startedAt: j.startedAt,
|
|
1616
|
-
finishedAt: j.finishedAt,
|
|
1617
|
-
error: j.error
|
|
1568
|
+
locations: locations.filter((l) => locationMayBeRead(l)),
|
|
1569
|
+
fellBackToDisabled: true
|
|
1618
1570
|
};
|
|
1619
1571
|
}
|
|
1620
1572
|
/**
|
|
1621
|
-
* The
|
|
1573
|
+
* The state question, put to the ONE interpreter.
|
|
1622
1574
|
*
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1575
|
+
* The legacy mirrors are handed over too, so a location built by a caller that
|
|
1576
|
+
* has not moved to `recordingsLocationOfMode` yet is folded by the SAME rule
|
|
1577
|
+
* that folds an unmigrated persisted row (`mode` absent → `readOnly` → `enabled`
|
|
1578
|
+
* → active). There is still exactly one interpretation; the mirrors and this
|
|
1579
|
+
* argument disappear together next release.
|
|
1626
1580
|
*/
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
}
|
|
1633
|
-
}
|
|
1581
|
+
function stateOf(location) {
|
|
1582
|
+
return {
|
|
1583
|
+
...location.mode !== void 0 ? { mode: location.mode } : {},
|
|
1584
|
+
...location.enabled !== void 0 ? { enabled: location.enabled } : {},
|
|
1585
|
+
config: location.readOnly === true ? { readOnly: true } : {}
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
/** The resolved state of a location, for a message or a log line. */
|
|
1589
|
+
function locationMode(location) {
|
|
1590
|
+
return resolveLocationMode(stateOf(location));
|
|
1591
|
+
}
|
|
1592
|
+
/** May the recorder write here? THE policy, never a local re-derivation. */
|
|
1593
|
+
function locationMayBeWritten(location) {
|
|
1594
|
+
return mayWriteToLocation(stateOf(location));
|
|
1595
|
+
}
|
|
1634
1596
|
/**
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
1638
|
-
* Split out of {@link moveFile} because the durable index re-point has to
|
|
1639
|
-
* happen while BOTH locations still hold the bytes — see
|
|
1640
|
-
* `SegmentHourLedger.repointSegments`.
|
|
1641
|
-
*
|
|
1642
|
-
* **`recordedBytes` is never the verify's reference.** It used to be, and it
|
|
1643
|
-
* cannot be: `fs.copyFile` truncates and replaces, so the destination always
|
|
1644
|
-
* equals the source, and comparing that result against a number the LEDGER
|
|
1645
|
-
* remembers can only ever fail when the ledger is wrong. On 2026-08-30 that
|
|
1646
|
-
* reported a byte-perfect copy as corruption and killed a 110 749-file drain
|
|
1647
|
-
* on its 12 440th file:
|
|
1648
|
-
*
|
|
1649
|
-
* relocate verify failed: …/1787926410000-9966-274432.m4s
|
|
1650
|
-
* is 783801B, expected 274432B
|
|
1651
|
-
*
|
|
1652
|
-
* Both files measured 783801 B on the hub. What is faithful is "the
|
|
1653
|
-
* destination equals the source", and that is what is asserted here; the
|
|
1654
|
-
* recorded number survives only as the reference for a destination whose
|
|
1655
|
-
* source is already GONE, where there is nothing else left to compare against.
|
|
1597
|
+
* A resolved location, with every derived mirror written from the ONE state.
|
|
1598
|
+
* Every `RecordingsLocation` in the recorder comes from here.
|
|
1656
1599
|
*/
|
|
1657
|
-
|
|
1658
|
-
const dstBytes = await fileSize(dst);
|
|
1659
|
-
const srcBytes = await fileSize(src);
|
|
1660
|
-
if (dstBytes !== null && dstBytes === (srcBytes ?? recordedBytes)) return {
|
|
1661
|
-
copied: 0,
|
|
1662
|
-
srcBytes: dstBytes
|
|
1663
|
-
};
|
|
1664
|
-
if (srcBytes === null) throw new RelocateSourceMissingError(src);
|
|
1665
|
-
await promises.mkdir(path.dirname(dst), { recursive: true });
|
|
1666
|
-
await promises.copyFile(src, dst);
|
|
1667
|
-
const after = await promises.stat(dst);
|
|
1668
|
-
if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
|
|
1600
|
+
function recordingsLocationOfMode(base, mode) {
|
|
1669
1601
|
return {
|
|
1670
|
-
|
|
1671
|
-
|
|
1602
|
+
...base,
|
|
1603
|
+
mode,
|
|
1604
|
+
readOnly: !modeMayWrite(mode),
|
|
1605
|
+
enabled: modeMayWrite(mode)
|
|
1672
1606
|
};
|
|
1673
1607
|
}
|
|
1674
|
-
/**
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
} catch {
|
|
1679
|
-
return null;
|
|
1680
|
-
}
|
|
1608
|
+
/** May the recorder read here? A `disabled` location may not — and that is an
|
|
1609
|
+
* operator CHOICE, reported as unavailable, never as an unknown location. */
|
|
1610
|
+
function locationMayBeRead(location) {
|
|
1611
|
+
return mayReadLocation(stateOf(location));
|
|
1681
1612
|
}
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1613
|
+
/**
|
|
1614
|
+
* Route a recording profile to a storage location, for a (camera, profile) the
|
|
1615
|
+
* PLANNER has not placed yet — the first attach of a new camera, before the
|
|
1616
|
+
* 30 s pass runs.
|
|
1617
|
+
*
|
|
1618
|
+
* The rule, in order:
|
|
1619
|
+
* 1. the location's declared `type` — `low` prefers `recordingsLow`, every
|
|
1620
|
+
* other profile prefers `recordings`; when the preferred type has only
|
|
1621
|
+
* read-only instances, any writable recordings-type location serves;
|
|
1622
|
+
* 2. among those, the one with the most MEASURED headroom
|
|
1623
|
+
* (`min(free − minFree floor, maxUsedGb − used)`) — the same signal the
|
|
1624
|
+
* planner balances on, so the bootstrap pick and the plan agree instead of
|
|
1625
|
+
* the plan having to undo the pick at the next tick;
|
|
1626
|
+
* 3. ties, and a pool where nothing was measured, fall to the
|
|
1627
|
+
* lexicographically first id — deterministic, never "whichever came back
|
|
1628
|
+
* first from the cap".
|
|
1629
|
+
*
|
|
1630
|
+
* There is no preferred instance any more (D383): every enabled location of a
|
|
1631
|
+
* class is a write target, and which one a camera gets is a capacity question,
|
|
1632
|
+
* not a flag. Throws when NO writable location exists — recording cannot start,
|
|
1633
|
+
* and that must be loud.
|
|
1634
|
+
*/
|
|
1635
|
+
function resolvePlacement(locations, profile) {
|
|
1636
|
+
if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
|
|
1637
|
+
const writable = locations.filter((l) => locationMayBeWritten(l));
|
|
1638
|
+
if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
|
|
1639
|
+
const preferType = profile === "low" ? "recordingsLow" : "recordings";
|
|
1640
|
+
const preferred = writable.filter((l) => l.type === preferType);
|
|
1641
|
+
return emptiestOf(preferred.length > 0 ? preferred : writable);
|
|
1642
|
+
}
|
|
1643
|
+
/** The candidate with the most measured headroom; ties (and an unmeasured
|
|
1644
|
+
* pool, where every headroom is absent) by lexicographic id. */
|
|
1645
|
+
function emptiestOf(candidates) {
|
|
1646
|
+
return candidates.toSorted((a, b) => {
|
|
1647
|
+
const byHeadroom = (b.headroomBytes ?? 0) - (a.headroomBytes ?? 0);
|
|
1648
|
+
return byHeadroom !== 0 ? byHeadroom : a.id.localeCompare(b.id);
|
|
1649
|
+
})[0];
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Attribute a discovered segment on an aliased physical root by its profile,
|
|
1653
|
+
* not by whichever logical location happened to be hydrated last. Stock
|
|
1654
|
+
* `recordings` and `recordingsLow` deliberately share a root: high/mid belong
|
|
1655
|
+
* to `recordings`, while low belongs to `recordingsLow`.
|
|
1656
|
+
*/
|
|
1657
|
+
function locationForHydratedProfile(aliases, profile) {
|
|
1658
|
+
const preferredType = profile === "low" ? "recordingsLow" : "recordings";
|
|
1659
|
+
const preferred = aliases.filter((location) => location.type === preferredType).toSorted((a, b) => a.id.localeCompare(b.id))[0];
|
|
1660
|
+
const fallback = aliases.toSorted((a, b) => a.id.localeCompare(b.id))[0];
|
|
1661
|
+
if (preferred) return preferred;
|
|
1662
|
+
if (fallback) return fallback;
|
|
1663
|
+
throw new Error("recorder: no location aliases to attribute hydrated profile");
|
|
1664
|
+
}
|
|
1665
|
+
function aliasesByRoot(locations) {
|
|
1666
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1667
|
+
for (const location of locations) {
|
|
1668
|
+
const aliases = grouped.get(location.root);
|
|
1669
|
+
if (aliases) aliases.push(location);
|
|
1670
|
+
else grouped.set(location.root, [location]);
|
|
1694
1671
|
}
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1672
|
+
return grouped;
|
|
1673
|
+
}
|
|
1674
|
+
/** One numeric key lifted out of a location's untyped `config` record, or
|
|
1675
|
+
* nothing at all when it is absent or not a finite number. Absent is a real
|
|
1676
|
+
* answer here: the caller falls back to a documented default, and a garbage
|
|
1677
|
+
* value must never become a capacity limit. */
|
|
1678
|
+
function numericConfig(config, key, field) {
|
|
1679
|
+
const raw = config[key];
|
|
1680
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return {};
|
|
1681
|
+
return { [field]: raw };
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Resolve every recordings storage location (`recordings` + `recordingsLow`)
|
|
1685
|
+
* to `{ id, root }`. Returns an empty list when the storage cap is unreachable
|
|
1686
|
+
* or no recordings location exists — callers degrade rather than crash.
|
|
1687
|
+
*/
|
|
1688
|
+
async function resolveRecordingsLocations(api, logger) {
|
|
1689
|
+
try {
|
|
1690
|
+
const recordings = (await api.storage.listLocations.query({})).filter((l) => RECORDING_LOCATION_TYPES.has(l.type));
|
|
1691
|
+
return Promise.all(recordings.map(async (l) => recordingsLocationOfMode({
|
|
1692
|
+
id: l.id,
|
|
1693
|
+
type: l.type,
|
|
1694
|
+
root: await api.storage.resolve.query({
|
|
1695
|
+
location: l.id,
|
|
1696
|
+
relativePath: ""
|
|
1697
|
+
}),
|
|
1698
|
+
...numericConfig(l.config, "minFreePercent", "minFreePercent"),
|
|
1699
|
+
...numericConfig(l.config, "maxUsedGb", "maxUsedGb")
|
|
1700
|
+
}, resolveLocationMode(l))));
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
logger.warn("recorder: listLocations failed — no recordings locations resolved", { meta: { error: errMsg(err) } });
|
|
1703
|
+
return [];
|
|
1716
1704
|
}
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Hydrate the v2 `RecordingIndex` for one device from every recordings location
|
|
1708
|
+
* by recursively walking `<root>/<deviceId>/` for `*.m4s` segments and feeding
|
|
1709
|
+
* the location-relative paths (`<deviceId>/<profile>/Y/M/D/H/<file>.m4s`, which
|
|
1710
|
+
* `parseSegmentPath` understands) to `index.hydrateDevice`. A missing device dir
|
|
1711
|
+
* (no footage yet) and any per-location failure are warned + skipped
|
|
1712
|
+
* (best-effort), so one bad volume never blocks the rest of the hydrate.
|
|
1713
|
+
*/
|
|
1714
|
+
async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
|
|
1715
|
+
const yieldBetween = options.yieldBetween ?? macrotask$3;
|
|
1716
|
+
const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
|
|
1717
|
+
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
1718
|
+
const deviceDir = path.join(root, String(deviceId));
|
|
1719
|
+
const startedMs = Date.now();
|
|
1720
|
+
let entries;
|
|
1721
|
+
try {
|
|
1722
|
+
entries = await promises.readdir(deviceDir, { recursive: true });
|
|
1723
|
+
} catch (err) {
|
|
1724
|
+
if (err.code === "ENOENT") {
|
|
1725
|
+
for (const location of aliases) index.hydrateDevice(deviceId, location.id, []);
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
logger.warn("recorder: hydrateDevice walk failed for location", {
|
|
1729
|
+
tags: { deviceId },
|
|
1730
|
+
meta: {
|
|
1731
|
+
locationIds: aliases.map((location) => location.id),
|
|
1732
|
+
error: errMsg(err)
|
|
1733
|
+
}
|
|
1734
|
+
});
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
const walkedMs = Date.now();
|
|
1738
|
+
const locationForProfile = /* @__PURE__ */ new Map();
|
|
1739
|
+
const pathsByLocation = /* @__PURE__ */ new Map();
|
|
1740
|
+
let handled = 0;
|
|
1741
|
+
for (const entry of entries) {
|
|
1742
|
+
if (!entry.endsWith(".m4s")) continue;
|
|
1743
|
+
const relative = `${deviceId}/${entry.split(path.sep).join("/")}`;
|
|
1744
|
+
const profile = relative.split("/")[1] ?? "";
|
|
1745
|
+
let locationId = locationForProfile.get(profile);
|
|
1746
|
+
if (locationId === void 0) {
|
|
1747
|
+
locationId = locationForHydratedProfile(aliases, profile).id;
|
|
1748
|
+
locationForProfile.set(profile, locationId);
|
|
1749
|
+
}
|
|
1750
|
+
const paths = pathsByLocation.get(locationId);
|
|
1751
|
+
if (paths) paths.push(relative);
|
|
1752
|
+
else pathsByLocation.set(locationId, [relative]);
|
|
1753
|
+
handled += 1;
|
|
1754
|
+
if (handled % chunkSize === 0) await yieldBetween();
|
|
1755
|
+
}
|
|
1756
|
+
for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
|
|
1757
|
+
let indexed = 0;
|
|
1758
|
+
const reuseBefore = index.hydrateReuseStats();
|
|
1759
|
+
for (const [locationId, relPaths] of pathsByLocation) {
|
|
1760
|
+
index.hydrateDevice(deviceId, locationId, relPaths);
|
|
1761
|
+
indexed += relPaths.length;
|
|
1762
|
+
}
|
|
1763
|
+
const reuseAfter = index.hydrateReuseStats();
|
|
1764
|
+
const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
|
|
1765
|
+
const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
|
|
1766
|
+
logger.info("recorder: hydrated a device from one recordings root", {
|
|
1767
|
+
tags: { deviceId },
|
|
1768
|
+
meta: {
|
|
1769
|
+
root,
|
|
1770
|
+
entries: entries.length,
|
|
1771
|
+
indexed,
|
|
1772
|
+
reused,
|
|
1773
|
+
allocated,
|
|
1774
|
+
locationIds: [...pathsByLocation.keys()],
|
|
1775
|
+
walkMs: walkedMs - startedMs,
|
|
1776
|
+
ms: Date.now() - startedMs
|
|
1777
|
+
}
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
/** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
|
|
1782
|
+
var HYDRATE_CHUNK = 2e4;
|
|
1783
|
+
var macrotask$3 = () => new Promise((resolve) => {
|
|
1784
|
+
setImmediate(resolve);
|
|
1785
|
+
});
|
|
1786
|
+
/** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
|
|
1787
|
+
function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
1788
|
+
const d = new Date(hourStartMs);
|
|
1789
|
+
const p2 = (n) => String(n).padStart(2, "0");
|
|
1790
|
+
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
1791
|
+
}
|
|
1792
|
+
var HOUR_MS$7 = 36e5;
|
|
1793
|
+
/**
|
|
1794
|
+
* Hydrate ONLY the hours a window spans, instead of walking the archive.
|
|
1795
|
+
*
|
|
1796
|
+
* `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
|
|
1797
|
+
* subtree — ">1M files and takes minutes" per its own comment. Until it lands,
|
|
1798
|
+
* every read of past footage sees an empty index, which the server reports as
|
|
1799
|
+
* "no footage": measured after a deploy, a camera-day held 7 segments and grew
|
|
1800
|
+
* only at the rate of NEW writes for ~9 minutes.
|
|
1801
|
+
*
|
|
1802
|
+
* The path convention makes the window addressable directly: one SHALLOW
|
|
1803
|
+
* `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
|
|
1804
|
+
* background full walk still runs, for the consumers that need the whole
|
|
1805
|
+
* archive (footprint, eviction domains, retention).
|
|
1806
|
+
*
|
|
1807
|
+
* Hours already walked are skipped, so a drag across an hour costs nothing the
|
|
1808
|
+
* second time.
|
|
1809
|
+
*
|
|
1810
|
+
* ## The ledger is the first stop for a CLOSED hour (D287)
|
|
1811
|
+
*
|
|
1812
|
+
* Boot no longer replays the archive into the index, so a ledger-known hour
|
|
1813
|
+
* arrives here `unknown` and pays ONE point read of its hour row instead of a
|
|
1814
|
+
* `readdir` — measured at ~650 ms per hour directory on the live share under
|
|
1815
|
+
* load, against the array the scrub feeder is competing for. Hours the ledger
|
|
1816
|
+
* does not know, and every hour if the read fails, fall through to the walk
|
|
1817
|
+
* exactly as before: the failure direction is toward the disk, never toward an
|
|
1818
|
+
* hour that renders empty because nobody read it.
|
|
1819
|
+
*
|
|
1820
|
+
* **The live hour is never served from the ledger.** It is the one hour whose
|
|
1821
|
+
* durable row is knowably behind the disk: a segment finalized after the last
|
|
1822
|
+
* persist and before a crash is on the array and not in the row. Boot used to
|
|
1823
|
+
* mark every ledger hour `hydrated`, which made that tail invisible to
|
|
1824
|
+
* playback and the timeline FOREVER, not merely under-reported. Excluding the
|
|
1825
|
+
* live hour costs exactly one hour-directory read per process — the walk marks
|
|
1826
|
+
* it, so the second reader pays nothing — and it is what turns a lost tail
|
|
1827
|
+
* back into a recoverable one.
|
|
1828
|
+
*
|
|
1829
|
+
* A CLOSED hour already walked is skipped, which is what makes that exclusion
|
|
1830
|
+
* affordable: without it, every window touching the live hour would re-walk
|
|
1831
|
+
* all of its hours. The past is immutable, so re-reading a closed hour
|
|
1832
|
+
* directory can only ever return what the index already holds; eviction
|
|
1833
|
+
* removes rows through the index directly.
|
|
1834
|
+
*/
|
|
1835
|
+
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
|
|
1836
|
+
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1837
|
+
const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
|
|
1838
|
+
if (ledger !== void 0) {
|
|
1839
|
+
const startedMs = Date.now();
|
|
1840
|
+
const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
|
|
1841
|
+
let segments = 0;
|
|
1842
|
+
let served = 0;
|
|
1843
|
+
for (const hour of hours) {
|
|
1844
|
+
if (hour.hourStartMs >= liveHour) continue;
|
|
1845
|
+
index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
1846
|
+
segments += hour.paths.length;
|
|
1847
|
+
served += 1;
|
|
1848
|
+
}
|
|
1849
|
+
if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
|
|
1850
|
+
tags: { deviceId },
|
|
1851
|
+
meta: {
|
|
1852
|
+
hours: served,
|
|
1853
|
+
segments,
|
|
1854
|
+
ms: Date.now() - startedMs
|
|
1855
|
+
}
|
|
1856
|
+
});
|
|
1857
|
+
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1858
|
+
}
|
|
1859
|
+
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
1860
|
+
const deviceDir = path.join(root, String(deviceId));
|
|
1861
|
+
let profiles;
|
|
1862
|
+
try {
|
|
1863
|
+
profiles = (await promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
|
|
1864
|
+
} catch (err) {
|
|
1865
|
+
if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
|
|
1866
|
+
tags: { deviceId },
|
|
1867
|
+
meta: {
|
|
1868
|
+
locationIds: aliases.map((location) => location.id),
|
|
1869
|
+
error: errMsg(err)
|
|
1870
|
+
}
|
|
1871
|
+
});
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
const first = Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7;
|
|
1875
|
+
const reuseBefore = index.hydrateReuseStats();
|
|
1876
|
+
let hoursRead = 0;
|
|
1877
|
+
let repaired = 0;
|
|
1878
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
|
|
1879
|
+
if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
|
|
1880
|
+
for (const profile of profiles) {
|
|
1881
|
+
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
1882
|
+
let files;
|
|
1883
|
+
try {
|
|
1884
|
+
files = await promises.readdir(path.join(root, rel));
|
|
1885
|
+
} catch {
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1888
|
+
hoursRead += 1;
|
|
1889
|
+
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
1890
|
+
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
1891
|
+
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
1892
|
+
if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$7 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
|
|
1893
|
+
repaired += 1;
|
|
1894
|
+
await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
index.markHydrated(deviceId, fromMs, toMs);
|
|
1899
|
+
if (hoursRead === 0) continue;
|
|
1900
|
+
const reuseAfter = index.hydrateReuseStats();
|
|
1901
|
+
const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
|
|
1902
|
+
const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
|
|
1903
|
+
logger.info("recorder: hydrated a window from one recordings root", {
|
|
1904
|
+
tags: { deviceId },
|
|
1905
|
+
meta: {
|
|
1906
|
+
root,
|
|
1907
|
+
hoursRead,
|
|
1908
|
+
repaired,
|
|
1909
|
+
reused,
|
|
1910
|
+
allocated
|
|
1740
1911
|
}
|
|
1741
1912
|
});
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
//#endregion
|
|
1916
|
+
//#region src/recorder/relocate-engine.ts
|
|
1917
|
+
/**
|
|
1918
|
+
* Footage relocation engine (entity-routing spec, Phase 4) — the mover half
|
|
1919
|
+
* of disk decommissioning: mark the old location read-only (Phase 2), then
|
|
1920
|
+
* relocate its footage here, then delete the location.
|
|
1921
|
+
*
|
|
1922
|
+
* Mechanics, per HOUR (D291): throttled copy + size-verify of every file in the
|
|
1923
|
+
* hour → re-point the DURABLE hour row → delete the sources → re-point the RAM
|
|
1924
|
+
* index. The hour is the batch because the durable index is hour-granular, and
|
|
1925
|
+
* the sources outlive the durable write on purpose: between the copy and the
|
|
1926
|
+
* delete both locations hold the hour, so no crash can leave the index naming a
|
|
1927
|
+
* location without the bytes. The copy is `copy-if-absent` (an equal-size
|
|
1928
|
+
* destination is adopted, not re-copied), so cancel/crash/re-run RESUMES
|
|
1929
|
+
* instead of restarting, and a cancel inside an hour leaves that hour WHOLLY
|
|
1930
|
+
* readable at the source. Throttling is file-level pacing (segments are
|
|
1931
|
+
* ~1–8 MB / ~4 s, so per-file granularity bounds the rate tightly enough) —
|
|
1932
|
+
* the drain is a background chore that must never starve live writers.
|
|
1933
|
+
*
|
|
1934
|
+
* Runs INSIDE the recorder process — container uid, same-uid files, no host
|
|
1935
|
+
* chown trap (see docs/history 2026-07-29).
|
|
1936
|
+
*
|
|
1937
|
+
* ── Phase 2 (D116, multi-location) ────────────────────────────────────────
|
|
1938
|
+
* Two additions the OPERATOR-ARMED rebalance needs:
|
|
1939
|
+
*
|
|
1940
|
+
* - A move is scoped to one CAMERA (and optionally to specific profiles),
|
|
1941
|
+
* not just to a whole disk. The rebalance's unit is a (camera, profile)
|
|
1942
|
+
* pile, because that is the unit the placement plan assigns.
|
|
1943
|
+
* - Concurrent starts QUEUE instead of being refused. The engine is still
|
|
1944
|
+
* single-flight — two movers on one disk thrash both — but a rebalance
|
|
1945
|
+
* enqueues one job per pile, and refusing the second turned a
|
|
1946
|
+
* fifteen-camera rebalance into fifteen manual retries. Jobs run FIFO; a
|
|
1947
|
+
* job cancelled while queued never runs, and a job that FAILS does not
|
|
1948
|
+
* strand the queue behind it.
|
|
1949
|
+
*/
|
|
1950
|
+
var DEFAULT_THROTTLE_MBPS = 40;
|
|
1951
|
+
/** The durable index's bucket, and therefore this mover's batch. */
|
|
1952
|
+
var HOUR_MS$6 = 36e5;
|
|
1953
|
+
/** Progress callback cadence (files) — cheap enough to fire often. */
|
|
1954
|
+
var PROGRESS_EVERY_FILES = 25;
|
|
1955
|
+
/** Free space a destination must keep when it declares no `config.minFreePercent`
|
|
1956
|
+
* — the same default `roots.ts` documents for placement. */
|
|
1957
|
+
var DEFAULT_MIN_FREE_PERCENT$1 = 10;
|
|
1958
|
+
/** Prefix every destination-full error carries, so the coordinator can report
|
|
1959
|
+
* "drain stalled: destination full" instead of a raw ENOSPC string. */
|
|
1960
|
+
var DESTINATION_FULL_PREFIX = "destination full";
|
|
1961
|
+
/**
|
|
1962
|
+
* Ghost rows one job may FORGET, as a share of its own census — and the floor
|
|
1963
|
+
* below which the share is not the bound.
|
|
1964
|
+
*
|
|
1965
|
+
* D148 is why this is bounded at all: the ledger forgets only on a trusted walk
|
|
1966
|
+
* or an eviction, so that a transient read error cannot erase an operator's
|
|
1967
|
+
* index of real footage. A per-path ENOENT on a path derived from the row is
|
|
1968
|
+
* strong evidence about THAT path (D296) and weak evidence about a mount: a
|
|
1969
|
+
* root that vanished answers ENOENT for every path it is asked about, and looks
|
|
1970
|
+
* exactly like a location that is genuinely empty. The bound is what makes the
|
|
1971
|
+
* difference survivable — past it the mover keeps moving, keeps skipping, and
|
|
1972
|
+
* REPORTS, so an operator gets a reconcile to act on instead of an archive that
|
|
1973
|
+
* quietly emptied itself.
|
|
1974
|
+
*
|
|
1975
|
+
* 17.5 % of `recordingsLow:default` was ghosts on 2026-08-30 (11 074 of
|
|
1976
|
+
* 63 346), so a quarter admits a real reconcile of that size in one pass while
|
|
1977
|
+
* refusing a whole-location erase.
|
|
1978
|
+
*/
|
|
1979
|
+
var GHOST_FORGET_SHARE = .25;
|
|
1980
|
+
function snapshot(j) {
|
|
1981
|
+
return {
|
|
1982
|
+
jobId: j.jobId,
|
|
1983
|
+
state: j.state,
|
|
1984
|
+
fromLocationId: j.fromLocationId,
|
|
1985
|
+
toLocationId: j.toLocationId,
|
|
1986
|
+
deviceId: j.deviceId,
|
|
1987
|
+
entities: [...j.entities],
|
|
1988
|
+
filesMoved: j.filesMoved,
|
|
1989
|
+
bytesMoved: j.bytesMoved,
|
|
1990
|
+
filesTotal: j.filesTotal,
|
|
1991
|
+
rowsReconciled: j.rowsReconciled,
|
|
1992
|
+
rowsForgotten: j.rowsForgotten,
|
|
1993
|
+
startedAt: j.startedAt,
|
|
1994
|
+
finishedAt: j.finishedAt,
|
|
1995
|
+
error: j.error
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* The row's source file is not on disk.
|
|
2000
|
+
*
|
|
2001
|
+
* Carries `code: 'ENOENT'` because that is how every caller here already tells
|
|
2002
|
+
* a ghost row from a real failure, and {@link copyVerified} now discovers the
|
|
2003
|
+
* absence with a `stat` rather than by letting `copyFile` raise it.
|
|
2004
|
+
*/
|
|
2005
|
+
var RelocateSourceMissingError = class extends Error {
|
|
2006
|
+
code = "ENOENT";
|
|
2007
|
+
constructor(src) {
|
|
2008
|
+
super(`relocate source missing: ${src}`);
|
|
2009
|
+
this.name = "RelocateSourceMissingError";
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
/**
|
|
2013
|
+
* One file's COPY: copy-if-absent → verify the destination against the SOURCE'S
|
|
2014
|
+
* OWN SIZE. The source is left in place.
|
|
2015
|
+
*
|
|
2016
|
+
* Split out of {@link moveFile} because the durable index re-point has to
|
|
2017
|
+
* happen while BOTH locations still hold the bytes — see
|
|
2018
|
+
* `SegmentHourLedger.repointSegments`.
|
|
2019
|
+
*
|
|
2020
|
+
* **`recordedBytes` is never the verify's reference.** It used to be, and it
|
|
2021
|
+
* cannot be: `fs.copyFile` truncates and replaces, so the destination always
|
|
2022
|
+
* equals the source, and comparing that result against a number the LEDGER
|
|
2023
|
+
* remembers can only ever fail when the ledger is wrong. On 2026-08-30 that
|
|
2024
|
+
* reported a byte-perfect copy as corruption and killed a 110 749-file drain
|
|
2025
|
+
* on its 12 440th file:
|
|
2026
|
+
*
|
|
2027
|
+
* relocate verify failed: …/1787926410000-9966-274432.m4s
|
|
2028
|
+
* is 783801B, expected 274432B
|
|
2029
|
+
*
|
|
2030
|
+
* Both files measured 783801 B on the hub. What is faithful is "the
|
|
2031
|
+
* destination equals the source", and that is what is asserted here; the
|
|
2032
|
+
* recorded number survives only as the reference for a destination whose
|
|
2033
|
+
* source is already GONE, where there is nothing else left to compare against.
|
|
2034
|
+
*/
|
|
2035
|
+
async function copyVerified(src, dst, recordedBytes) {
|
|
2036
|
+
const dstBytes = await fileSize(dst);
|
|
2037
|
+
const srcBytes = await fileSize(src);
|
|
2038
|
+
if (dstBytes !== null && dstBytes === (srcBytes ?? recordedBytes)) return {
|
|
2039
|
+
copied: 0,
|
|
2040
|
+
srcBytes: dstBytes
|
|
2041
|
+
};
|
|
2042
|
+
if (srcBytes === null) throw new RelocateSourceMissingError(src);
|
|
2043
|
+
await promises.mkdir(path.dirname(dst), { recursive: true });
|
|
2044
|
+
await promises.copyFile(src, dst);
|
|
2045
|
+
const after = await promises.stat(dst);
|
|
2046
|
+
if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
|
|
2047
|
+
return {
|
|
2048
|
+
copied: srcBytes,
|
|
2049
|
+
srcBytes
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
/** A file's size, or `null` when it is not there. */
|
|
2053
|
+
async function fileSize(p) {
|
|
2054
|
+
try {
|
|
2055
|
+
return (await promises.stat(p)).size;
|
|
2056
|
+
} catch {
|
|
2057
|
+
return null;
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
var RelocateEngine = class {
|
|
2061
|
+
deps;
|
|
2062
|
+
jobs = /* @__PURE__ */ new Map();
|
|
2063
|
+
/** Job ids waiting for the mover, in enqueue order. */
|
|
2064
|
+
queue = [];
|
|
2065
|
+
/** Set while a job is executing — the single-flight latch. */
|
|
2066
|
+
draining = false;
|
|
2067
|
+
constructor(deps) {
|
|
2068
|
+
this.deps = deps;
|
|
2069
|
+
}
|
|
2070
|
+
list() {
|
|
2071
|
+
return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* Cancel a running OR queued job. A running job stops after its current
|
|
2075
|
+
* file; a queued job is terminated where it stands and never runs — which is
|
|
2076
|
+
* why this returns true for both, and why the cancelled job still gets its
|
|
2077
|
+
* `onFinished` audit row.
|
|
2078
|
+
*/
|
|
2079
|
+
cancel(jobId) {
|
|
2080
|
+
const job = this.jobs.get(jobId);
|
|
2081
|
+
if (!job) return false;
|
|
2082
|
+
if (job.state === "running") {
|
|
2083
|
+
job.cancelRequested = true;
|
|
2084
|
+
return true;
|
|
2085
|
+
}
|
|
2086
|
+
if (job.state !== "queued") return false;
|
|
2087
|
+
job.cancelRequested = true;
|
|
2088
|
+
job.state = "cancelled";
|
|
2089
|
+
job.finishedAt = this.deps.now();
|
|
2090
|
+
const at = this.queue.indexOf(jobId);
|
|
2091
|
+
if (at >= 0) this.queue.splice(at, 1);
|
|
2092
|
+
this.deps.onFinished?.(snapshot(job));
|
|
2093
|
+
return true;
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Enqueue a relocation and return its job id. Throws only on an input the
|
|
2097
|
+
* mover could never satisfy (unknown/identical/read-only locations) —
|
|
2098
|
+
* validated SYNCHRONOUSLY so a caller arming a rebalance learns about a bad
|
|
2099
|
+
* target before any job runs.
|
|
2100
|
+
*
|
|
2101
|
+
* Concurrency: the returned job may be `queued` rather than `running`. The
|
|
2102
|
+
* engine still moves one job at a time.
|
|
2103
|
+
*/
|
|
2104
|
+
start(input) {
|
|
2105
|
+
const locs = this.deps.locations();
|
|
2106
|
+
const from = locs.find((l) => l.id === input.fromLocationId);
|
|
2107
|
+
const to = locs.find((l) => l.id === input.toLocationId);
|
|
2108
|
+
if (!from) throw new Error(`unknown source location: ${input.fromLocationId}`);
|
|
2109
|
+
if (!to) throw new Error(`unknown target location: ${input.toLocationId}`);
|
|
2110
|
+
if (from.id === to.id) throw new Error("source and target locations are identical");
|
|
2111
|
+
if (!locationMayBeWritten(to)) throw new Error(`target location is read-only (mode: ${locationMode(to)}): ${to.id}`);
|
|
1742
2112
|
const job = {
|
|
1743
2113
|
jobId: this.deps.newId(),
|
|
1744
2114
|
state: "queued",
|
|
@@ -2276,7 +2646,7 @@ function profilesOfClass(footageClass) {
|
|
|
2276
2646
|
function groupByHour(rows) {
|
|
2277
2647
|
const out = /* @__PURE__ */ new Map();
|
|
2278
2648
|
for (const row of rows) {
|
|
2279
|
-
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$
|
|
2649
|
+
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$6)}`;
|
|
2280
2650
|
const found = out.get(key);
|
|
2281
2651
|
if (found) found.push(row);
|
|
2282
2652
|
else out.set(key, [row]);
|
|
@@ -2465,7 +2835,7 @@ var SegmentStore = class SegmentStore {
|
|
|
2465
2835
|
else byBucket.set(bucketDir, [r]);
|
|
2466
2836
|
}
|
|
2467
2837
|
const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
|
|
2468
|
-
const yieldBetween = this.deps.yieldBetween ?? macrotask$
|
|
2838
|
+
const yieldBetween = this.deps.yieldBetween ?? macrotask$2;
|
|
2469
2839
|
let bucketsRemoved = 0;
|
|
2470
2840
|
let handled = 0;
|
|
2471
2841
|
for (const [bucketDir, bucketRows] of byBucket) {
|
|
@@ -2613,7 +2983,7 @@ var SegmentStore = class SegmentStore {
|
|
|
2613
2983
|
return held.every((p) => doomed.has(p));
|
|
2614
2984
|
}
|
|
2615
2985
|
};
|
|
2616
|
-
var macrotask$
|
|
2986
|
+
var macrotask$2 = () => new Promise((resolve) => {
|
|
2617
2987
|
setImmediate(resolve);
|
|
2618
2988
|
});
|
|
2619
2989
|
//#endregion
|
|
@@ -3125,6 +3495,12 @@ function intersect(a, b) {
|
|
|
3125
3495
|
* The cap's `locationId` is used directly as the storage `location` (identity); see Plan 2d
|
|
3126
3496
|
* notes. Wiring this class to the cap + the SegmentStore happens in Plan 2e.
|
|
3127
3497
|
*/
|
|
3498
|
+
/**
|
|
3499
|
+
* One class, two cap surfaces: `storage-evictable` (what may be deleted under
|
|
3500
|
+
* pressure) and `storage-occupancy` (what is HELD, D388). They live together
|
|
3501
|
+
* because they read the same archive, and they are separate CAPS because they
|
|
3502
|
+
* are separate quantities — see `getOccupancy`.
|
|
3503
|
+
*/
|
|
3128
3504
|
var StorageEvictableProvider = class {
|
|
3129
3505
|
deps;
|
|
3130
3506
|
orphanLane;
|
|
@@ -3142,9 +3518,9 @@ var StorageEvictableProvider = class {
|
|
|
3142
3518
|
* reported evictable nor selected as victims). */
|
|
3143
3519
|
domainOf(locationId) {
|
|
3144
3520
|
const domain = this.deps.resolveDomain?.(locationId) ?? new Set([locationId]);
|
|
3145
|
-
if (!this.deps.
|
|
3521
|
+
if (!this.deps.isEvictionFrozen) return domain;
|
|
3146
3522
|
const writable = /* @__PURE__ */ new Set();
|
|
3147
|
-
for (const id of domain) if (!this.deps.
|
|
3523
|
+
for (const id of domain) if (!this.deps.isEvictionFrozen(id)) writable.add(id);
|
|
3148
3524
|
return writable;
|
|
3149
3525
|
}
|
|
3150
3526
|
/**
|
|
@@ -3163,6 +3539,41 @@ var StorageEvictableProvider = class {
|
|
|
3163
3539
|
}
|
|
3164
3540
|
return { bytes: (await archive.accountingForLocations(this.domainOf(input.locationId))).bytes };
|
|
3165
3541
|
}
|
|
3542
|
+
/**
|
|
3543
|
+
* The `storage-occupancy` answer (D388) — a DIFFERENT quantity from
|
|
3544
|
+
* {@link getEvictableUsage}, and the difference is the whole reason the cap
|
|
3545
|
+
* exists:
|
|
3546
|
+
*
|
|
3547
|
+
* - it is the location's OWN bytes, not its eviction domain's. Two aliases
|
|
3548
|
+
* of one root report their own halves instead of the same combined total
|
|
3549
|
+
* twice;
|
|
3550
|
+
* - it counts a location whose eviction policy is `never` (a `readonly` or
|
|
3551
|
+
* `disabled` disk), which `getEvictableUsage` deliberately reports as 0.
|
|
3552
|
+
* Those are the disks an operator retiring hardware is staring at.
|
|
3553
|
+
*
|
|
3554
|
+
* A location this recorder holds nothing on is OMITTED, never reported as
|
|
3555
|
+
* zero, and an archive it cannot measure yields NO reports at all — unknown
|
|
3556
|
+
* is the truth when nothing could be read.
|
|
3557
|
+
*/
|
|
3558
|
+
async getOccupancy(input) {
|
|
3559
|
+
const archive = this.deps.archive();
|
|
3560
|
+
if (archive === null) {
|
|
3561
|
+
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] } });
|
|
3562
|
+
return [];
|
|
3563
|
+
}
|
|
3564
|
+
const measuredAtMs = this.deps.now?.() ?? Date.now();
|
|
3565
|
+
const out = [];
|
|
3566
|
+
for (const locationId of input.locationIds) {
|
|
3567
|
+
const acct = await archive.accountingForLocations(new Set([locationId]));
|
|
3568
|
+
if (acct.bytes <= 0) continue;
|
|
3569
|
+
out.push({
|
|
3570
|
+
locationId,
|
|
3571
|
+
ownedBytes: acct.bytes,
|
|
3572
|
+
measuredAtMs
|
|
3573
|
+
});
|
|
3574
|
+
}
|
|
3575
|
+
return out;
|
|
3576
|
+
}
|
|
3166
3577
|
async evict(input) {
|
|
3167
3578
|
const archive = this.deps.archive();
|
|
3168
3579
|
if (archive === null) {
|
|
@@ -3429,7 +3840,7 @@ async function profileHasFootage(root, deviceId, profile) {
|
|
|
3429
3840
|
//#endregion
|
|
3430
3841
|
//#region src/recorder/addon/calendar-index.ts
|
|
3431
3842
|
var DAY_MS$3 = 864e5;
|
|
3432
|
-
var HOUR_MS$
|
|
3843
|
+
var HOUR_MS$5 = 36e5;
|
|
3433
3844
|
/**
|
|
3434
3845
|
* UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
|
|
3435
3846
|
*
|
|
@@ -3467,8 +3878,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
|
|
|
3467
3878
|
* deploy gap). Unknown hours still fall through to one directory read.
|
|
3468
3879
|
*/
|
|
3469
3880
|
function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
|
|
3470
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
3471
|
-
const hourEnd = hour + HOUR_MS$
|
|
3881
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
|
|
3882
|
+
const hourEnd = hour + HOUR_MS$5;
|
|
3472
3883
|
if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
|
|
3473
3884
|
const rows = [];
|
|
3474
3885
|
for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
|
|
@@ -3625,7 +4036,7 @@ var CalendarIndex = class {
|
|
|
3625
4036
|
}));
|
|
3626
4037
|
const days = /* @__PURE__ */ new Set();
|
|
3627
4038
|
for (const { day, hours } of perDay) for (const h of hours) {
|
|
3628
|
-
const at = day + h * HOUR_MS$
|
|
4039
|
+
const at = day + h * HOUR_MS$5;
|
|
3629
4040
|
if (at < fromMs || at >= toMs) continue;
|
|
3630
4041
|
days.add(localDayStart(at, tzOffsetMinutes));
|
|
3631
4042
|
}
|
|
@@ -3687,7 +4098,7 @@ var CalendarIndex = class {
|
|
|
3687
4098
|
const day = utcDayOf(utcDayMs);
|
|
3688
4099
|
for (let h = 0; h < 24; h++) {
|
|
3689
4100
|
await this.awaitQuiet();
|
|
3690
|
-
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$
|
|
4101
|
+
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$5, true);
|
|
3691
4102
|
}
|
|
3692
4103
|
}
|
|
3693
4104
|
/**
|
|
@@ -3708,7 +4119,7 @@ var CalendarIndex = class {
|
|
|
3708
4119
|
*/
|
|
3709
4120
|
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
3710
4121
|
if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
|
|
3711
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
4122
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
|
|
3712
4123
|
const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
|
|
3713
4124
|
if (fromIndex !== null && fromIndex !== void 0) return {
|
|
3714
4125
|
segments: fromIndex,
|
|
@@ -3716,7 +4127,7 @@ var CalendarIndex = class {
|
|
|
3716
4127
|
};
|
|
3717
4128
|
const key = `${deviceId}:${profile}:${hour}`;
|
|
3718
4129
|
const now = this.deps.now?.() ?? Date.now();
|
|
3719
|
-
const isCurrentHour = hour === Math.floor(now / HOUR_MS$
|
|
4130
|
+
const isCurrentHour = hour === Math.floor(now / HOUR_MS$5) * HOUR_MS$5;
|
|
3720
4131
|
const cached = this.hourSegs.get(key);
|
|
3721
4132
|
if (cached !== void 0) {
|
|
3722
4133
|
if (!isCurrentHour) return {
|
|
@@ -3780,7 +4191,7 @@ var CalendarIndex = class {
|
|
|
3780
4191
|
*/
|
|
3781
4192
|
async dayRanges(deviceId, profile, utcDayMs) {
|
|
3782
4193
|
const day = utcDayOf(utcDayMs);
|
|
3783
|
-
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$
|
|
4194
|
+
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);
|
|
3784
4195
|
}
|
|
3785
4196
|
/**
|
|
3786
4197
|
* Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
|
|
@@ -3794,9 +4205,9 @@ var CalendarIndex = class {
|
|
|
3794
4205
|
*/
|
|
3795
4206
|
async rangesIn(deviceId, profile, fromMs, toMs) {
|
|
3796
4207
|
if (toMs <= fromMs) return [];
|
|
3797
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
4208
|
+
const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
|
|
3798
4209
|
const hours = [];
|
|
3799
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
4210
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$5) hours.push(hour);
|
|
3800
4211
|
const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
|
|
3801
4212
|
const ranges = [];
|
|
3802
4213
|
for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
|
|
@@ -4323,7 +4734,7 @@ function handleStoreCensusAction(census, logger, input) {
|
|
|
4323
4734
|
}
|
|
4324
4735
|
//#endregion
|
|
4325
4736
|
//#region src/recorder/addon/directory-handler.ts
|
|
4326
|
-
var HOUR_MS$
|
|
4737
|
+
var HOUR_MS$4 = 36e5;
|
|
4327
4738
|
/** A window wider than this is refused: it is a client bug, and answering it
|
|
4328
4739
|
* would walk the archive on the read path — the thing this replaces. */
|
|
4329
4740
|
var MAX_WINDOW_MS = 32 * 864e5;
|
|
@@ -4370,9 +4781,9 @@ function withDirectoryRoute(deps, next) {
|
|
|
4370
4781
|
return;
|
|
4371
4782
|
}
|
|
4372
4783
|
const rows = [];
|
|
4373
|
-
const firstHour = Math.floor(fromMs / HOUR_MS$
|
|
4784
|
+
const firstHour = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
|
|
4374
4785
|
let hydrated = true;
|
|
4375
|
-
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$
|
|
4786
|
+
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$4) {
|
|
4376
4787
|
const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
|
|
4377
4788
|
if (!complete) hydrated = false;
|
|
4378
4789
|
for (const s of segments) {
|
|
@@ -4558,21 +4969,16 @@ async function wipeClassSubtrees(input) {
|
|
|
4558
4969
|
}
|
|
4559
4970
|
//#endregion
|
|
4560
4971
|
//#region src/recorder/addon/location-discard-policy.ts
|
|
4561
|
-
/**
|
|
4562
|
-
* Server-side eligibility for discarding a frozen location's leftover
|
|
4563
|
-
* class subtree. Same rules as the admin-ui card: frozen, known class,
|
|
4564
|
-
* writable sibling of the same type. The last writable of a type is
|
|
4565
|
-
* refused here even if the UI is bypassed.
|
|
4566
|
-
*/
|
|
4567
4972
|
var DISCARD_SUBTREES = {
|
|
4568
4973
|
eventMedia: ["events"],
|
|
4569
4974
|
recordingsLow: ["low"],
|
|
4570
4975
|
recordings: ["high", "mid"]
|
|
4571
4976
|
};
|
|
4977
|
+
/** Frozen = the system will not remove this footage on its own: eviction
|
|
4978
|
+
* policy `never` (D385), i.e. `readonly` or `disabled`. A `drain` location is
|
|
4979
|
+
* NOT frozen — the ratchet is already removing it. */
|
|
4572
4980
|
function locationIsFrozen(location) {
|
|
4573
|
-
|
|
4574
|
-
const readOnly = location.config["readOnly"] === true;
|
|
4575
|
-
return !enabled || readOnly;
|
|
4981
|
+
return evictionPolicyOfLocation(location) === "never";
|
|
4576
4982
|
}
|
|
4577
4983
|
function discardEligible(location, siblings) {
|
|
4578
4984
|
if (!(location.type in DISCARD_SUBTREES)) return false;
|
|
@@ -4580,9 +4986,7 @@ function discardEligible(location, siblings) {
|
|
|
4580
4986
|
return siblings.some((s) => {
|
|
4581
4987
|
if (s.id === location.id) return false;
|
|
4582
4988
|
if (s.type !== location.type) return false;
|
|
4583
|
-
|
|
4584
|
-
const readOnly = s.config["readOnly"] === true;
|
|
4585
|
-
return enabled && !readOnly;
|
|
4989
|
+
return mayWriteToLocation(s);
|
|
4586
4990
|
});
|
|
4587
4991
|
}
|
|
4588
4992
|
function assertDiscardAllowed(location, all, localNodeId) {
|
|
@@ -4791,6 +5195,20 @@ function createFootageDiscardJob(deps) {
|
|
|
4791
5195
|
};
|
|
4792
5196
|
}
|
|
4793
5197
|
//#endregion
|
|
5198
|
+
//#region src/recorder/addon/location-resolve.ts
|
|
5199
|
+
/**
|
|
5200
|
+
* A location this process may READ from.
|
|
5201
|
+
*
|
|
5202
|
+
* Throws with a message that names WHICH of the two it is — never one message
|
|
5203
|
+
* for both. The caller does not have to branch: the message is the product.
|
|
5204
|
+
*/
|
|
5205
|
+
function requireReadableLocation(locations, locationId, context) {
|
|
5206
|
+
const location = locations.find((candidate) => candidate.id === locationId);
|
|
5207
|
+
if (location === void 0) throw new Error(`${context}: unknown location ${locationId}`);
|
|
5208
|
+
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.`);
|
|
5209
|
+
return location;
|
|
5210
|
+
}
|
|
5211
|
+
//#endregion
|
|
4794
5212
|
//#region src/recorder/addon/export-dense-map.ts
|
|
4795
5213
|
/**
|
|
4796
5214
|
* Shortest clip worth keeping, in ms.
|
|
@@ -6778,8 +7196,7 @@ async function evictVictims(deps, deviceId, victims) {
|
|
|
6778
7196
|
function resolveSegmentFile(deps, deviceId, profile, startMs) {
|
|
6779
7197
|
const seg = deps.index.segmentStartingAt(deviceId, profile, startMs);
|
|
6780
7198
|
if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
|
|
6781
|
-
const loc = deps.locations()
|
|
6782
|
-
if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
|
|
7199
|
+
const loc = requireReadableLocation(deps.locations(), seg.locationId, "recording");
|
|
6783
7200
|
return {
|
|
6784
7201
|
seg,
|
|
6785
7202
|
abs: path.join(loc.root, seg.path)
|
|
@@ -7052,7 +7469,7 @@ function buildRecordingProvider(deps) {
|
|
|
7052
7469
|
}
|
|
7053
7470
|
const locationUsages = await Promise.all([...byPhysical.values()].map(async (group) => {
|
|
7054
7471
|
const first = group[0];
|
|
7055
|
-
const cap = group.some((l) => l
|
|
7472
|
+
const cap = group.some((l) => locationMayBeWritten(l)) ? await deps.capacity(first.root) : {
|
|
7056
7473
|
availableBytes: null,
|
|
7057
7474
|
totalBytes: null
|
|
7058
7475
|
};
|
|
@@ -7570,528 +7987,162 @@ var RENDER_TIMEOUT_MS = 3e4;
|
|
|
7570
7987
|
*/
|
|
7571
7988
|
var FOOTAGE_NOT_READY = "footage-not-yet-finalized";
|
|
7572
7989
|
/**
|
|
7573
|
-
* GIF for the widest notifier reach (every backend that takes an image takes a
|
|
7574
|
-
* gif); `fps` decimates and lanczos downscales.
|
|
7575
|
-
*/
|
|
7576
|
-
async function renderGif(renderDeps, input) {
|
|
7577
|
-
const out = await renderFootage(renderDeps, input, "gif", (playlistPath, outPath) => [
|
|
7578
|
-
"-y",
|
|
7579
|
-
"-i",
|
|
7580
|
-
playlistPath,
|
|
7581
|
-
"-vf",
|
|
7582
|
-
`fps=${input.fps},scale=${input.maxWidth}:-2:flags=lanczos`,
|
|
7583
|
-
"-loop",
|
|
7584
|
-
"0",
|
|
7585
|
-
"-f",
|
|
7586
|
-
"gif",
|
|
7587
|
-
outPath
|
|
7588
|
-
]);
|
|
7589
|
-
return {
|
|
7590
|
-
gifBase64: out.bytes.toString("base64"),
|
|
7591
|
-
fromMs: out.fromMs,
|
|
7592
|
-
toMs: out.toMs
|
|
7593
|
-
};
|
|
7594
|
-
}
|
|
7595
|
-
/**
|
|
7596
|
-
* MP4 clip — the "send a clip, not a gif" attachment. Re-encodes rather than
|
|
7597
|
-
* stream-copying: a concat of segments whose encoder parameters differ (a
|
|
7598
|
-
* profile switch, a broker redial) produces an mp4 no player will scrub, and a
|
|
7599
|
-
* notification attachment that will not play is worse than none. `faststart`
|
|
7600
|
-
* puts the moov up front so a chat client can play it without the whole file.
|
|
7601
|
-
*/
|
|
7602
|
-
async function renderClip(renderDeps, input) {
|
|
7603
|
-
const out = await renderFootage(renderDeps, input, "mp4", (playlistPath, outPath) => [
|
|
7604
|
-
"-y",
|
|
7605
|
-
"-i",
|
|
7606
|
-
playlistPath,
|
|
7607
|
-
"-vf",
|
|
7608
|
-
`scale=${input.maxWidth}:-2:flags=lanczos`,
|
|
7609
|
-
"-c:v",
|
|
7610
|
-
"libx264",
|
|
7611
|
-
"-preset",
|
|
7612
|
-
"veryfast",
|
|
7613
|
-
"-crf",
|
|
7614
|
-
"28",
|
|
7615
|
-
"-profile:v",
|
|
7616
|
-
"main",
|
|
7617
|
-
"-pix_fmt",
|
|
7618
|
-
"yuv420p",
|
|
7619
|
-
"-movflags",
|
|
7620
|
-
"+faststart",
|
|
7621
|
-
"-an",
|
|
7622
|
-
"-f",
|
|
7623
|
-
"mp4",
|
|
7624
|
-
outPath
|
|
7625
|
-
]);
|
|
7626
|
-
return {
|
|
7627
|
-
clipBase64: out.bytes.toString("base64"),
|
|
7628
|
-
mime: "video/mp4",
|
|
7629
|
-
fromMs: out.fromMs,
|
|
7630
|
-
toMs: out.toMs,
|
|
7631
|
-
bytes: out.bytes.byteLength
|
|
7632
|
-
};
|
|
7633
|
-
}
|
|
7634
|
-
/** The shared playlist → ffmpeg → bytes pipeline. */
|
|
7635
|
-
async function renderFootage(renderDeps, input, ext, buildArgs) {
|
|
7636
|
-
const now = renderDeps.now ?? Date.now;
|
|
7637
|
-
const fromMs = input.aroundMs - input.preRollSec * 1e3;
|
|
7638
|
-
const toMs = input.aroundMs + input.postRollSec * 1e3;
|
|
7639
|
-
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}`);
|
|
7640
|
-
const renderId = `${ext}-${now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
7641
|
-
const availableEnd = latestFinalizedEnd(renderDeps, input.deviceId);
|
|
7642
|
-
if (availableEnd === null || availableEnd < toMs) throw new Error(`${FOOTAGE_NOT_READY}: [${fromMs}, ${toMs}) for device ${input.deviceId} — finalized footage ends at ${availableEnd ?? "never"}`);
|
|
7643
|
-
const window = {
|
|
7644
|
-
fromMs,
|
|
7645
|
-
toMs
|
|
7646
|
-
};
|
|
7647
|
-
const playlist = await writeExportSourcePlaylist({
|
|
7648
|
-
deps: renderDeps.deps,
|
|
7649
|
-
exportsDir: renderDeps.exportsDir,
|
|
7650
|
-
exportId: renderId,
|
|
7651
|
-
deviceId: input.deviceId,
|
|
7652
|
-
profile: "low",
|
|
7653
|
-
fromMs: window.fromMs,
|
|
7654
|
-
toMs: window.toMs
|
|
7655
|
-
});
|
|
7656
|
-
if (playlist === null) throw new Error(`no footage covers [${window.fromMs}, ${window.toMs}) for device ${input.deviceId}`);
|
|
7657
|
-
const outPath = path.join(path.dirname(playlist.path), `${renderId}.${ext}`);
|
|
7658
|
-
try {
|
|
7659
|
-
await runFfmpeg(renderDeps, buildArgs(playlist.path, outPath), ext);
|
|
7660
|
-
const bytes = await promises.readFile(outPath);
|
|
7661
|
-
if (bytes.byteLength === 0) throw new Error(`${ext} render produced an empty file`);
|
|
7662
|
-
return {
|
|
7663
|
-
bytes,
|
|
7664
|
-
fromMs: window.fromMs,
|
|
7665
|
-
toMs: window.toMs
|
|
7666
|
-
};
|
|
7667
|
-
} finally {
|
|
7668
|
-
await promises.rm(playlist.path, { force: true }).catch(() => {});
|
|
7669
|
-
await promises.rm(playlist.concatPath, { force: true }).catch(() => {});
|
|
7670
|
-
await promises.rm(outPath, { force: true }).catch(() => {});
|
|
7671
|
-
}
|
|
7672
|
-
}
|
|
7673
|
-
/**
|
|
7674
|
-
* The end of the newest FINALIZED `low` segment, or null when none exists.
|
|
7675
|
-
*
|
|
7676
|
-
* One scalar off the tail of the live sorted view — it used to COPY the
|
|
7677
|
-
* device's whole `low` archive to fold a maximum out of it, per render.
|
|
7678
|
-
*/
|
|
7679
|
-
function latestFinalizedEnd(renderDeps, deviceId) {
|
|
7680
|
-
return renderDeps.deps.index.latestEnd(deviceId, "low");
|
|
7681
|
-
}
|
|
7682
|
-
/**
|
|
7683
|
-
* Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
|
|
7684
|
-
*
|
|
7685
|
-
* The previous shape leaked on two paths: a `spawn` error (`ENOENT`, a bad
|
|
7686
|
-
* path) rejected while a process could still be alive, and a `close` with a
|
|
7687
|
-
* non-zero code rejected without ever reaping stdio. These renders fire on
|
|
7688
|
-
* every notification with a gif or clip, so a leak here is a slow accumulation
|
|
7689
|
-
* of ffmpeg processes on the recording node — the kind that shows up days later
|
|
7690
|
-
* as "the hub got slow" (operator, 2026-07-30).
|
|
7691
|
-
*
|
|
7692
|
-
* `settle` is idempotent and always kills first: killing an already-exited pid
|
|
7693
|
-
* throws ESRCH, which is swallowed, so the cost of being wrong is nothing while
|
|
7694
|
-
* the cost of NOT killing is a zombie per render.
|
|
7695
|
-
*/
|
|
7696
|
-
function runFfmpeg(renderDeps, args, ext) {
|
|
7697
|
-
return new Promise((resolve, reject) => {
|
|
7698
|
-
const proc = renderDeps.spawn(renderDeps.ffmpegPath ?? "ffmpeg", args);
|
|
7699
|
-
let done = false;
|
|
7700
|
-
let timer = null;
|
|
7701
|
-
const settle = (err) => {
|
|
7702
|
-
if (done) return;
|
|
7703
|
-
done = true;
|
|
7704
|
-
if (timer !== null) {
|
|
7705
|
-
clearTimeout(timer);
|
|
7706
|
-
timer = null;
|
|
7707
|
-
}
|
|
7708
|
-
if (proc.exitCode === null && proc.signalCode === null) try {
|
|
7709
|
-
proc.kill("SIGKILL");
|
|
7710
|
-
} catch {}
|
|
7711
|
-
try {
|
|
7712
|
-
proc.stderr?.removeAllListeners();
|
|
7713
|
-
proc.stdout?.destroy();
|
|
7714
|
-
proc.stderr?.destroy();
|
|
7715
|
-
} catch {}
|
|
7716
|
-
if (err === null) resolve();
|
|
7717
|
-
else reject(err);
|
|
7718
|
-
};
|
|
7719
|
-
timer = setTimeout(() => settle(/* @__PURE__ */ new Error(`${ext} render timed out`)), RENDER_TIMEOUT_MS);
|
|
7720
|
-
let stderrTail = "";
|
|
7721
|
-
proc.stderr?.on("data", (c) => {
|
|
7722
|
-
stderrTail = (stderrTail + c.toString()).slice(-400);
|
|
7723
|
-
});
|
|
7724
|
-
proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
|
|
7725
|
-
proc.on("close", (code) => {
|
|
7726
|
-
settle(code === 0 ? null : /* @__PURE__ */ new Error(`ffmpeg ${ext} render exited ${code}: ${stderrTail}`));
|
|
7727
|
-
});
|
|
7728
|
-
});
|
|
7729
|
-
}
|
|
7730
|
-
//#endregion
|
|
7731
|
-
//#region src/recorder/addon/roots.ts
|
|
7732
|
-
/**
|
|
7733
|
-
* Storage-root resolution + boot hydrate for the recorder addon.
|
|
7734
|
-
*
|
|
7735
|
-
* Recordings live in the managed `recordings` / `recordingsLow` storage
|
|
7736
|
-
* locations (operator-configurable, multi-disk-ready), resolved through the
|
|
7737
|
-
* `storage` cap. The v2 `RecordingIndex` is hydrated by walking each location
|
|
7738
|
-
* root's `<deviceId>/` subtree on disk — segment paths encode start/dur/bytes,
|
|
7739
|
-
* so the in-memory index can never drift from disk. (We walk the filesystem
|
|
7740
|
-
* directly instead of `storage.list` because that cap lists only a directory's
|
|
7741
|
-
* IMMEDIATE children, not the `<deviceId>/<profile>/Y/M/D/H/*.m4s` tree — using
|
|
7742
|
-
* it dropped the entire index to ~empty on every restart/rescan; the recorder
|
|
7743
|
-
* already does local fs I/O for stat/move on these same roots.)
|
|
7744
|
-
*/
|
|
7745
|
-
/** The two recordings location types the recorder addon writes/reads. */
|
|
7746
|
-
var PLACEMENT_LOCATION_TYPES = ["recordings", "recordingsLow"];
|
|
7747
|
-
var RECORDING_LOCATION_TYPES = new Set(PLACEMENT_LOCATION_TYPES);
|
|
7748
|
-
/**
|
|
7749
|
-
* The recordings CLASS a profile belongs to. The one place this rule is
|
|
7750
|
-
* written: `resolvePlacement`, the planner's demand type and the repoint check
|
|
7751
|
-
* must agree on it, and three copies of `profile === 'low' ? … : …` is how they
|
|
7752
|
-
* stop agreeing.
|
|
7753
|
-
*/
|
|
7754
|
-
function placementTypeForProfile(profile) {
|
|
7755
|
-
return profile === "low" ? "recordingsLow" : "recordings";
|
|
7756
|
-
}
|
|
7757
|
-
/** The profile that routes to each class — the probe `authorityLocations` asks
|
|
7758
|
-
* `resolvePlacement` with, so the answer is derived from the same function the
|
|
7759
|
-
* unassigned path uses rather than from a second reading of `isDefault`. */
|
|
7760
|
-
var CLASS_PROBE_PROFILE = {
|
|
7761
|
-
recordings: "high",
|
|
7762
|
-
recordingsLow: "low"
|
|
7763
|
-
};
|
|
7764
|
-
/**
|
|
7765
|
-
* Where each recordings class writes when NOTHING has planned it — the location
|
|
7766
|
-
* `resolvePlacement` picks, which is the type default unless the default is
|
|
7767
|
-
* gone, read-only, or absent from the pool.
|
|
7768
|
-
*
|
|
7769
|
-
* This is the AUTHORITY a stored assignment is measured against (D298). A
|
|
7770
|
-
* repoint (`storage.setDefaultLocations`, an operator making another disk the
|
|
7771
|
-
* default, a default deleted) changes exactly this map, and a stored assignment
|
|
7772
|
-
* that survives that change is a plan that outlived the pool it was planned
|
|
7773
|
-
* for.
|
|
7774
|
-
*
|
|
7775
|
-
* A class with nothing writable is ABSENT from the map, not guessed at: `place`
|
|
7776
|
-
* already throws loudly on that path, and an authority nobody can compute must
|
|
7777
|
-
* invalidate nothing.
|
|
7778
|
-
*/
|
|
7779
|
-
function authorityLocations(pool) {
|
|
7780
|
-
const out = /* @__PURE__ */ new Map();
|
|
7781
|
-
for (const type of PLACEMENT_LOCATION_TYPES) try {
|
|
7782
|
-
out.set(type, resolvePlacement(pool, CLASS_PROBE_PROFILE[type]).id);
|
|
7783
|
-
} catch {}
|
|
7784
|
-
return out;
|
|
7785
|
-
}
|
|
7786
|
-
/**
|
|
7787
|
-
* Narrow a resolved location list to the ones placement may write to: the
|
|
7788
|
-
* operator-enabled ones. A location the operator has not opted in
|
|
7789
|
-
* (`enabled === false`) is still READ, age-swept and drained — it is simply
|
|
7790
|
-
* never a write target, exactly like a read-only one.
|
|
7791
|
-
*
|
|
7792
|
-
* When no enabled location is writable the type DEFAULTS serve instead, loudly:
|
|
7793
|
-
* the default of a type is guaranteed enabled by the orchestrator, so this can
|
|
7794
|
-
* only happen through a hand-edited store — and a recorder that writes nowhere
|
|
7795
|
-
* is worse than one that writes to the default and complains.
|
|
7796
|
-
*/
|
|
7797
|
-
function placementPool(locations) {
|
|
7798
|
-
const enabled = locations.filter((l) => l.enabled !== false);
|
|
7799
|
-
if (enabled.some((l) => !l.readOnly)) return {
|
|
7800
|
-
locations: enabled,
|
|
7801
|
-
fellBackToDefault: false
|
|
7802
|
-
};
|
|
7803
|
-
const defaults = locations.filter((l) => l.isDefault === true);
|
|
7804
|
-
if (defaults.some((l) => !l.readOnly)) return {
|
|
7805
|
-
locations: defaults,
|
|
7806
|
-
fellBackToDefault: true
|
|
7807
|
-
};
|
|
7808
|
-
return {
|
|
7809
|
-
locations,
|
|
7810
|
-
fellBackToDefault: true
|
|
7811
|
-
};
|
|
7812
|
-
}
|
|
7813
|
-
/**
|
|
7814
|
-
* Route a recording profile to its storage location by the location's declared
|
|
7815
|
-
* `type`: the `low` profile prefers the `recordingsLow` location, every other
|
|
7816
|
-
* profile the `recordings` location. READ-ONLY locations are never write
|
|
7817
|
-
* targets (the drain model): when the preferred type has only read-only
|
|
7818
|
-
* instances, any writable recordings-type location serves. Throws when NO
|
|
7819
|
-
* writable location exists — recording cannot start, and that must be loud.
|
|
7820
|
-
*/
|
|
7821
|
-
function resolvePlacement(locations, profile) {
|
|
7822
|
-
if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
|
|
7823
|
-
const writable = locations.filter((l) => !l.readOnly);
|
|
7824
|
-
if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
|
|
7825
|
-
const preferType = profile === "low" ? "recordingsLow" : "recordings";
|
|
7826
|
-
const preferred = writable.filter((l) => l.type === preferType);
|
|
7827
|
-
return preferred.find((l) => l.isDefault) ?? preferred[0] ?? writable.find((l) => l.isDefault) ?? writable[0];
|
|
7828
|
-
}
|
|
7829
|
-
/**
|
|
7830
|
-
* Attribute a discovered segment on an aliased physical root by its profile,
|
|
7831
|
-
* not by whichever logical location happened to be hydrated last. Stock
|
|
7832
|
-
* `recordings` and `recordingsLow` deliberately share a root: high/mid belong
|
|
7833
|
-
* to `recordings`, while low belongs to `recordingsLow`.
|
|
7834
|
-
*/
|
|
7835
|
-
function locationForHydratedProfile(aliases, profile) {
|
|
7836
|
-
const preferredType = profile === "low" ? "recordingsLow" : "recordings";
|
|
7837
|
-
const preferred = aliases.filter((location) => location.type === preferredType).toSorted((a, b) => a.id.localeCompare(b.id))[0];
|
|
7838
|
-
const fallback = aliases.toSorted((a, b) => a.id.localeCompare(b.id))[0];
|
|
7839
|
-
if (preferred) return preferred;
|
|
7840
|
-
if (fallback) return fallback;
|
|
7841
|
-
throw new Error("recorder: no location aliases to attribute hydrated profile");
|
|
7842
|
-
}
|
|
7843
|
-
function aliasesByRoot(locations) {
|
|
7844
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
7845
|
-
for (const location of locations) {
|
|
7846
|
-
const aliases = grouped.get(location.root);
|
|
7847
|
-
if (aliases) aliases.push(location);
|
|
7848
|
-
else grouped.set(location.root, [location]);
|
|
7849
|
-
}
|
|
7850
|
-
return grouped;
|
|
7851
|
-
}
|
|
7852
|
-
/** One numeric key lifted out of a location's untyped `config` record, or
|
|
7853
|
-
* nothing at all when it is absent or not a finite number. Absent is a real
|
|
7854
|
-
* answer here: the caller falls back to a documented default, and a garbage
|
|
7855
|
-
* value must never become a capacity limit. */
|
|
7856
|
-
function numericConfig(config, key, field) {
|
|
7857
|
-
const raw = config[key];
|
|
7858
|
-
if (typeof raw !== "number" || !Number.isFinite(raw)) return {};
|
|
7859
|
-
return { [field]: raw };
|
|
7860
|
-
}
|
|
7861
|
-
/**
|
|
7862
|
-
* Resolve every recordings storage location (`recordings` + `recordingsLow`)
|
|
7863
|
-
* to `{ id, root }`. Returns an empty list when the storage cap is unreachable
|
|
7864
|
-
* or no recordings location exists — callers degrade rather than crash.
|
|
7990
|
+
* GIF for the widest notifier reach (every backend that takes an image takes a
|
|
7991
|
+
* gif); `fps` decimates and lanczos downscales.
|
|
7865
7992
|
*/
|
|
7866
|
-
async function
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
|
|
7881
|
-
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
return [];
|
|
7885
|
-
}
|
|
7993
|
+
async function renderGif(renderDeps, input) {
|
|
7994
|
+
const out = await renderFootage(renderDeps, input, "gif", (playlistPath, outPath) => [
|
|
7995
|
+
"-y",
|
|
7996
|
+
"-i",
|
|
7997
|
+
playlistPath,
|
|
7998
|
+
"-vf",
|
|
7999
|
+
`fps=${input.fps},scale=${input.maxWidth}:-2:flags=lanczos`,
|
|
8000
|
+
"-loop",
|
|
8001
|
+
"0",
|
|
8002
|
+
"-f",
|
|
8003
|
+
"gif",
|
|
8004
|
+
outPath
|
|
8005
|
+
]);
|
|
8006
|
+
return {
|
|
8007
|
+
gifBase64: out.bytes.toString("base64"),
|
|
8008
|
+
fromMs: out.fromMs,
|
|
8009
|
+
toMs: out.toMs
|
|
8010
|
+
};
|
|
7886
8011
|
}
|
|
7887
8012
|
/**
|
|
7888
|
-
*
|
|
7889
|
-
*
|
|
7890
|
-
*
|
|
7891
|
-
*
|
|
7892
|
-
*
|
|
7893
|
-
* (best-effort), so one bad volume never blocks the rest of the hydrate.
|
|
8013
|
+
* MP4 clip — the "send a clip, not a gif" attachment. Re-encodes rather than
|
|
8014
|
+
* stream-copying: a concat of segments whose encoder parameters differ (a
|
|
8015
|
+
* profile switch, a broker redial) produces an mp4 no player will scrub, and a
|
|
8016
|
+
* notification attachment that will not play is worse than none. `faststart`
|
|
8017
|
+
* puts the moov up front so a chat client can play it without the whole file.
|
|
7894
8018
|
*/
|
|
7895
|
-
async function
|
|
7896
|
-
const
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
let locationId = locationForProfile.get(profile);
|
|
7927
|
-
if (locationId === void 0) {
|
|
7928
|
-
locationId = locationForHydratedProfile(aliases, profile).id;
|
|
7929
|
-
locationForProfile.set(profile, locationId);
|
|
7930
|
-
}
|
|
7931
|
-
const paths = pathsByLocation.get(locationId);
|
|
7932
|
-
if (paths) paths.push(relative);
|
|
7933
|
-
else pathsByLocation.set(locationId, [relative]);
|
|
7934
|
-
handled += 1;
|
|
7935
|
-
if (handled % chunkSize === 0) await yieldBetween();
|
|
7936
|
-
}
|
|
7937
|
-
for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
|
|
7938
|
-
let indexed = 0;
|
|
7939
|
-
const reuseBefore = index.hydrateReuseStats();
|
|
7940
|
-
for (const [locationId, relPaths] of pathsByLocation) {
|
|
7941
|
-
index.hydrateDevice(deviceId, locationId, relPaths);
|
|
7942
|
-
indexed += relPaths.length;
|
|
7943
|
-
}
|
|
7944
|
-
const reuseAfter = index.hydrateReuseStats();
|
|
7945
|
-
const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
|
|
7946
|
-
const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
|
|
7947
|
-
logger.info("recorder: hydrated a device from one recordings root", {
|
|
7948
|
-
tags: { deviceId },
|
|
7949
|
-
meta: {
|
|
7950
|
-
root,
|
|
7951
|
-
entries: entries.length,
|
|
7952
|
-
indexed,
|
|
7953
|
-
reused,
|
|
7954
|
-
allocated,
|
|
7955
|
-
locationIds: [...pathsByLocation.keys()],
|
|
7956
|
-
walkMs: walkedMs - startedMs,
|
|
7957
|
-
ms: Date.now() - startedMs
|
|
7958
|
-
}
|
|
7959
|
-
});
|
|
7960
|
-
}
|
|
8019
|
+
async function renderClip(renderDeps, input) {
|
|
8020
|
+
const out = await renderFootage(renderDeps, input, "mp4", (playlistPath, outPath) => [
|
|
8021
|
+
"-y",
|
|
8022
|
+
"-i",
|
|
8023
|
+
playlistPath,
|
|
8024
|
+
"-vf",
|
|
8025
|
+
`scale=${input.maxWidth}:-2:flags=lanczos`,
|
|
8026
|
+
"-c:v",
|
|
8027
|
+
"libx264",
|
|
8028
|
+
"-preset",
|
|
8029
|
+
"veryfast",
|
|
8030
|
+
"-crf",
|
|
8031
|
+
"28",
|
|
8032
|
+
"-profile:v",
|
|
8033
|
+
"main",
|
|
8034
|
+
"-pix_fmt",
|
|
8035
|
+
"yuv420p",
|
|
8036
|
+
"-movflags",
|
|
8037
|
+
"+faststart",
|
|
8038
|
+
"-an",
|
|
8039
|
+
"-f",
|
|
8040
|
+
"mp4",
|
|
8041
|
+
outPath
|
|
8042
|
+
]);
|
|
8043
|
+
return {
|
|
8044
|
+
clipBase64: out.bytes.toString("base64"),
|
|
8045
|
+
mime: "video/mp4",
|
|
8046
|
+
fromMs: out.fromMs,
|
|
8047
|
+
toMs: out.toMs,
|
|
8048
|
+
bytes: out.bytes.byteLength
|
|
8049
|
+
};
|
|
7961
8050
|
}
|
|
7962
|
-
/**
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
const
|
|
7970
|
-
|
|
7971
|
-
|
|
8051
|
+
/** The shared playlist → ffmpeg → bytes pipeline. */
|
|
8052
|
+
async function renderFootage(renderDeps, input, ext, buildArgs) {
|
|
8053
|
+
const now = renderDeps.now ?? Date.now;
|
|
8054
|
+
const fromMs = input.aroundMs - input.preRollSec * 1e3;
|
|
8055
|
+
const toMs = input.aroundMs + input.postRollSec * 1e3;
|
|
8056
|
+
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}`);
|
|
8057
|
+
const renderId = `${ext}-${now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
8058
|
+
const availableEnd = latestFinalizedEnd(renderDeps, input.deviceId);
|
|
8059
|
+
if (availableEnd === null || availableEnd < toMs) throw new Error(`${FOOTAGE_NOT_READY}: [${fromMs}, ${toMs}) for device ${input.deviceId} — finalized footage ends at ${availableEnd ?? "never"}`);
|
|
8060
|
+
const window = {
|
|
8061
|
+
fromMs,
|
|
8062
|
+
toMs
|
|
8063
|
+
};
|
|
8064
|
+
const playlist = await writeExportSourcePlaylist({
|
|
8065
|
+
deps: renderDeps.deps,
|
|
8066
|
+
exportsDir: renderDeps.exportsDir,
|
|
8067
|
+
exportId: renderId,
|
|
8068
|
+
deviceId: input.deviceId,
|
|
8069
|
+
profile: "low",
|
|
8070
|
+
fromMs: window.fromMs,
|
|
8071
|
+
toMs: window.toMs
|
|
8072
|
+
});
|
|
8073
|
+
if (playlist === null) throw new Error(`no footage covers [${window.fromMs}, ${window.toMs}) for device ${input.deviceId}`);
|
|
8074
|
+
const outPath = path.join(path.dirname(playlist.path), `${renderId}.${ext}`);
|
|
8075
|
+
try {
|
|
8076
|
+
await runFfmpeg(renderDeps, buildArgs(playlist.path, outPath), ext);
|
|
8077
|
+
const bytes = await promises.readFile(outPath);
|
|
8078
|
+
if (bytes.byteLength === 0) throw new Error(`${ext} render produced an empty file`);
|
|
8079
|
+
return {
|
|
8080
|
+
bytes,
|
|
8081
|
+
fromMs: window.fromMs,
|
|
8082
|
+
toMs: window.toMs
|
|
8083
|
+
};
|
|
8084
|
+
} finally {
|
|
8085
|
+
await promises.rm(playlist.path, { force: true }).catch(() => {});
|
|
8086
|
+
await promises.rm(playlist.concatPath, { force: true }).catch(() => {});
|
|
8087
|
+
await promises.rm(outPath, { force: true }).catch(() => {});
|
|
8088
|
+
}
|
|
7972
8089
|
}
|
|
7973
|
-
var HOUR_MS$4 = 36e5;
|
|
7974
8090
|
/**
|
|
7975
|
-
*
|
|
7976
|
-
*
|
|
7977
|
-
* `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
|
|
7978
|
-
* subtree — ">1M files and takes minutes" per its own comment. Until it lands,
|
|
7979
|
-
* every read of past footage sees an empty index, which the server reports as
|
|
7980
|
-
* "no footage": measured after a deploy, a camera-day held 7 segments and grew
|
|
7981
|
-
* only at the rate of NEW writes for ~9 minutes.
|
|
7982
|
-
*
|
|
7983
|
-
* The path convention makes the window addressable directly: one SHALLOW
|
|
7984
|
-
* `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
|
|
7985
|
-
* background full walk still runs, for the consumers that need the whole
|
|
7986
|
-
* archive (footprint, eviction domains, retention).
|
|
7987
|
-
*
|
|
7988
|
-
* Hours already walked are skipped, so a drag across an hour costs nothing the
|
|
7989
|
-
* second time.
|
|
7990
|
-
*
|
|
7991
|
-
* ## The ledger is the first stop for a CLOSED hour (D287)
|
|
7992
|
-
*
|
|
7993
|
-
* Boot no longer replays the archive into the index, so a ledger-known hour
|
|
7994
|
-
* arrives here `unknown` and pays ONE point read of its hour row instead of a
|
|
7995
|
-
* `readdir` — measured at ~650 ms per hour directory on the live share under
|
|
7996
|
-
* load, against the array the scrub feeder is competing for. Hours the ledger
|
|
7997
|
-
* does not know, and every hour if the read fails, fall through to the walk
|
|
7998
|
-
* exactly as before: the failure direction is toward the disk, never toward an
|
|
7999
|
-
* hour that renders empty because nobody read it.
|
|
8000
|
-
*
|
|
8001
|
-
* **The live hour is never served from the ledger.** It is the one hour whose
|
|
8002
|
-
* durable row is knowably behind the disk: a segment finalized after the last
|
|
8003
|
-
* persist and before a crash is on the array and not in the row. Boot used to
|
|
8004
|
-
* mark every ledger hour `hydrated`, which made that tail invisible to
|
|
8005
|
-
* playback and the timeline FOREVER, not merely under-reported. Excluding the
|
|
8006
|
-
* live hour costs exactly one hour-directory read per process — the walk marks
|
|
8007
|
-
* it, so the second reader pays nothing — and it is what turns a lost tail
|
|
8008
|
-
* back into a recoverable one.
|
|
8091
|
+
* The end of the newest FINALIZED `low` segment, or null when none exists.
|
|
8009
8092
|
*
|
|
8010
|
-
*
|
|
8011
|
-
*
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8035
|
-
|
|
8093
|
+
* One scalar off the tail of the live sorted view — it used to COPY the
|
|
8094
|
+
* device's whole `low` archive to fold a maximum out of it, per render.
|
|
8095
|
+
*/
|
|
8096
|
+
function latestFinalizedEnd(renderDeps, deviceId) {
|
|
8097
|
+
return renderDeps.deps.index.latestEnd(deviceId, "low");
|
|
8098
|
+
}
|
|
8099
|
+
/**
|
|
8100
|
+
* Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
|
|
8101
|
+
*
|
|
8102
|
+
* The previous shape leaked on two paths: a `spawn` error (`ENOENT`, a bad
|
|
8103
|
+
* path) rejected while a process could still be alive, and a `close` with a
|
|
8104
|
+
* non-zero code rejected without ever reaping stdio. These renders fire on
|
|
8105
|
+
* every notification with a gif or clip, so a leak here is a slow accumulation
|
|
8106
|
+
* of ffmpeg processes on the recording node — the kind that shows up days later
|
|
8107
|
+
* as "the hub got slow" (operator, 2026-07-30).
|
|
8108
|
+
*
|
|
8109
|
+
* `settle` is idempotent and always kills first: killing an already-exited pid
|
|
8110
|
+
* throws ESRCH, which is swallowed, so the cost of being wrong is nothing while
|
|
8111
|
+
* the cost of NOT killing is a zombie per render.
|
|
8112
|
+
*/
|
|
8113
|
+
function runFfmpeg(renderDeps, args, ext) {
|
|
8114
|
+
return new Promise((resolve, reject) => {
|
|
8115
|
+
const proc = renderDeps.spawn(renderDeps.ffmpegPath ?? "ffmpeg", args);
|
|
8116
|
+
let done = false;
|
|
8117
|
+
let timer = null;
|
|
8118
|
+
const settle = (err) => {
|
|
8119
|
+
if (done) return;
|
|
8120
|
+
done = true;
|
|
8121
|
+
if (timer !== null) {
|
|
8122
|
+
clearTimeout(timer);
|
|
8123
|
+
timer = null;
|
|
8036
8124
|
}
|
|
8125
|
+
if (proc.exitCode === null && proc.signalCode === null) try {
|
|
8126
|
+
proc.kill("SIGKILL");
|
|
8127
|
+
} catch {}
|
|
8128
|
+
try {
|
|
8129
|
+
proc.stderr?.removeAllListeners();
|
|
8130
|
+
proc.stdout?.destroy();
|
|
8131
|
+
proc.stderr?.destroy();
|
|
8132
|
+
} catch {}
|
|
8133
|
+
if (err === null) resolve();
|
|
8134
|
+
else reject(err);
|
|
8135
|
+
};
|
|
8136
|
+
timer = setTimeout(() => settle(/* @__PURE__ */ new Error(`${ext} render timed out`)), RENDER_TIMEOUT_MS);
|
|
8137
|
+
let stderrTail = "";
|
|
8138
|
+
proc.stderr?.on("data", (c) => {
|
|
8139
|
+
stderrTail = (stderrTail + c.toString()).slice(-400);
|
|
8037
8140
|
});
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
const deviceDir = path.join(root, String(deviceId));
|
|
8042
|
-
let profiles;
|
|
8043
|
-
try {
|
|
8044
|
-
profiles = (await promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
|
|
8045
|
-
} catch (err) {
|
|
8046
|
-
if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
|
|
8047
|
-
tags: { deviceId },
|
|
8048
|
-
meta: {
|
|
8049
|
-
locationIds: aliases.map((location) => location.id),
|
|
8050
|
-
error: errMsg(err)
|
|
8051
|
-
}
|
|
8052
|
-
});
|
|
8053
|
-
continue;
|
|
8054
|
-
}
|
|
8055
|
-
const first = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
|
|
8056
|
-
const reuseBefore = index.hydrateReuseStats();
|
|
8057
|
-
let hoursRead = 0;
|
|
8058
|
-
let repaired = 0;
|
|
8059
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$4) {
|
|
8060
|
-
if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$4) === "hydrated") continue;
|
|
8061
|
-
for (const profile of profiles) {
|
|
8062
|
-
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
8063
|
-
let files;
|
|
8064
|
-
try {
|
|
8065
|
-
files = await promises.readdir(path.join(root, rel));
|
|
8066
|
-
} catch {
|
|
8067
|
-
continue;
|
|
8068
|
-
}
|
|
8069
|
-
hoursRead += 1;
|
|
8070
|
-
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
8071
|
-
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
8072
|
-
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
8073
|
-
if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$4 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
|
|
8074
|
-
repaired += 1;
|
|
8075
|
-
await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
|
|
8076
|
-
}
|
|
8077
|
-
}
|
|
8078
|
-
}
|
|
8079
|
-
index.markHydrated(deviceId, fromMs, toMs);
|
|
8080
|
-
if (hoursRead === 0) continue;
|
|
8081
|
-
const reuseAfter = index.hydrateReuseStats();
|
|
8082
|
-
const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
|
|
8083
|
-
const allocated = reuseAfter.rowsAllocated - reuseBefore.rowsAllocated;
|
|
8084
|
-
logger.info("recorder: hydrated a window from one recordings root", {
|
|
8085
|
-
tags: { deviceId },
|
|
8086
|
-
meta: {
|
|
8087
|
-
root,
|
|
8088
|
-
hoursRead,
|
|
8089
|
-
repaired,
|
|
8090
|
-
reused,
|
|
8091
|
-
allocated
|
|
8092
|
-
}
|
|
8141
|
+
proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
|
|
8142
|
+
proc.on("close", (code) => {
|
|
8143
|
+
settle(code === 0 ? null : /* @__PURE__ */ new Error(`ffmpeg ${ext} render exited ${code}: ${stderrTail}`));
|
|
8093
8144
|
});
|
|
8094
|
-
}
|
|
8145
|
+
});
|
|
8095
8146
|
}
|
|
8096
8147
|
//#endregion
|
|
8097
8148
|
//#region src/durable/durable-ledger.ts
|
|
@@ -10432,47 +10483,17 @@ Object.freeze({
|
|
|
10432
10483
|
},
|
|
10433
10484
|
"setDeviceConfig": { "config": { "__nested__": { "bands": [] } } }
|
|
10434
10485
|
},
|
|
10435
|
-
"storage": { "upsertLocation": {
|
|
10436
|
-
"isDefault": false,
|
|
10437
|
-
"isSystem": false
|
|
10438
|
-
} },
|
|
10486
|
+
"storage": { "upsertLocation": { "isSystem": false } },
|
|
10439
10487
|
"storage-provider": {
|
|
10440
|
-
"beginDownload": { "location": { "__nested__": {
|
|
10441
|
-
|
|
10442
|
-
|
|
10443
|
-
} } },
|
|
10444
|
-
"
|
|
10445
|
-
|
|
10446
|
-
|
|
10447
|
-
} } },
|
|
10448
|
-
"
|
|
10449
|
-
"isDefault": false,
|
|
10450
|
-
"isSystem": false
|
|
10451
|
-
} } },
|
|
10452
|
-
"exists": { "location": { "__nested__": {
|
|
10453
|
-
"isDefault": false,
|
|
10454
|
-
"isSystem": false
|
|
10455
|
-
} } },
|
|
10456
|
-
"getAvailableSpace": { "location": { "__nested__": {
|
|
10457
|
-
"isDefault": false,
|
|
10458
|
-
"isSystem": false
|
|
10459
|
-
} } },
|
|
10460
|
-
"list": { "location": { "__nested__": {
|
|
10461
|
-
"isDefault": false,
|
|
10462
|
-
"isSystem": false
|
|
10463
|
-
} } },
|
|
10464
|
-
"read": { "location": { "__nested__": {
|
|
10465
|
-
"isDefault": false,
|
|
10466
|
-
"isSystem": false
|
|
10467
|
-
} } },
|
|
10468
|
-
"resolve": { "location": { "__nested__": {
|
|
10469
|
-
"isDefault": false,
|
|
10470
|
-
"isSystem": false
|
|
10471
|
-
} } },
|
|
10472
|
-
"write": { "location": { "__nested__": {
|
|
10473
|
-
"isDefault": false,
|
|
10474
|
-
"isSystem": false
|
|
10475
|
-
} } }
|
|
10488
|
+
"beginDownload": { "location": { "__nested__": { "isSystem": false } } },
|
|
10489
|
+
"beginUpload": { "location": { "__nested__": { "isSystem": false } } },
|
|
10490
|
+
"delete": { "location": { "__nested__": { "isSystem": false } } },
|
|
10491
|
+
"exists": { "location": { "__nested__": { "isSystem": false } } },
|
|
10492
|
+
"getAvailableSpace": { "location": { "__nested__": { "isSystem": false } } },
|
|
10493
|
+
"list": { "location": { "__nested__": { "isSystem": false } } },
|
|
10494
|
+
"read": { "location": { "__nested__": { "isSystem": false } } },
|
|
10495
|
+
"resolve": { "location": { "__nested__": { "isSystem": false } } },
|
|
10496
|
+
"write": { "location": { "__nested__": { "isSystem": false } } }
|
|
10476
10497
|
},
|
|
10477
10498
|
"stream-broker": {
|
|
10478
10499
|
"produceEventMedia": {
|
|
@@ -10546,6 +10567,7 @@ Object.freeze({
|
|
|
10546
10567
|
"listTargetKinds",
|
|
10547
10568
|
"listTargets"
|
|
10548
10569
|
],
|
|
10570
|
+
"storage-occupancy": ["getOccupancy"],
|
|
10549
10571
|
"storage-provider": ["list"],
|
|
10550
10572
|
"turn-provider": ["getTurnServers"],
|
|
10551
10573
|
"user-passkeys": ["listPasskeys"]
|
|
@@ -10659,13 +10681,12 @@ var LedgerWalkReconciler = class {
|
|
|
10659
10681
|
const apply = request.apply === true;
|
|
10660
10682
|
const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
|
|
10661
10683
|
if (location === void 0) return this.refusedReport(request.locationId, apply, "location-unknown");
|
|
10662
|
-
if ((location
|
|
10684
|
+
if (locationMayBeWritten(location)) {
|
|
10663
10685
|
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: {
|
|
10664
10686
|
locationId: location.id,
|
|
10665
10687
|
root: location.root,
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
remedy: "set the location readOnly (or disable it) and re-run; a LIVE location is reconciled by the mover, under its bound (D318)"
|
|
10688
|
+
mode: location.mode,
|
|
10689
|
+
remedy: "switch the location to readonly / drain / disabled and re-run; a LIVE location is reconciled by the mover, under its bound (D318)"
|
|
10669
10690
|
} });
|
|
10670
10691
|
return this.refusedReport(location.id, apply, "source-writable");
|
|
10671
10692
|
}
|
|
@@ -11019,6 +11040,91 @@ async function readLocationCapacity(root, statfs) {
|
|
|
11019
11040
|
};
|
|
11020
11041
|
}
|
|
11021
11042
|
}
|
|
11043
|
+
/**
|
|
11044
|
+
* Coalesces location-change doorbells into refreshes, and converges on its own
|
|
11045
|
+
* whether or not any doorbell ever arrives.
|
|
11046
|
+
*/
|
|
11047
|
+
var LocationRefresher = class {
|
|
11048
|
+
deps;
|
|
11049
|
+
timer = null;
|
|
11050
|
+
running = false;
|
|
11051
|
+
/** A doorbell rang while a refresh was in flight — run exactly one more. */
|
|
11052
|
+
rearm = false;
|
|
11053
|
+
/** When the last refresh STARTED. `null` ⇒ none since this object was made. */
|
|
11054
|
+
lastStartedAt = null;
|
|
11055
|
+
disposed = false;
|
|
11056
|
+
constructor(deps) {
|
|
11057
|
+
this.deps = deps;
|
|
11058
|
+
}
|
|
11059
|
+
get now() {
|
|
11060
|
+
return this.deps.now?.() ?? Date.now();
|
|
11061
|
+
}
|
|
11062
|
+
get debounceMs() {
|
|
11063
|
+
return this.deps.debounceMs ?? 2e3;
|
|
11064
|
+
}
|
|
11065
|
+
get backstopMs() {
|
|
11066
|
+
return this.deps.backstopMs ?? 3e5;
|
|
11067
|
+
}
|
|
11068
|
+
/**
|
|
11069
|
+
* The doorbell. Never awaits the refresh: this runs on the event bus, and a
|
|
11070
|
+
* subscriber that blocks on I/O holds up every other consumer of the bus.
|
|
11071
|
+
*/
|
|
11072
|
+
ring(locationId, reason) {
|
|
11073
|
+
if (this.disposed) return;
|
|
11074
|
+
this.deps.logger.info("recorder: the storage location set changed — re-resolving where recordings may be written", { meta: {
|
|
11075
|
+
locationId,
|
|
11076
|
+
reason,
|
|
11077
|
+
coalesceMs: this.debounceMs
|
|
11078
|
+
} });
|
|
11079
|
+
if (this.timer !== null) return;
|
|
11080
|
+
this.timer = setTimeout(() => {
|
|
11081
|
+
this.timer = null;
|
|
11082
|
+
this.run("storage location change");
|
|
11083
|
+
}, this.debounceMs);
|
|
11084
|
+
}
|
|
11085
|
+
/**
|
|
11086
|
+
* The backstop, called from a pass that already runs on a timer. Refreshes
|
|
11087
|
+
* only when nothing has refreshed for {@link LOCATION_BACKSTOP_MS} — a
|
|
11088
|
+
* doorbell that already produced one resets the clock, so the two paths
|
|
11089
|
+
* never both pay.
|
|
11090
|
+
*/
|
|
11091
|
+
async convergeIfDue() {
|
|
11092
|
+
if (this.disposed) return;
|
|
11093
|
+
const last = this.lastStartedAt;
|
|
11094
|
+
if (last !== null && this.now - last < this.backstopMs) return;
|
|
11095
|
+
await this.run("periodic convergence");
|
|
11096
|
+
}
|
|
11097
|
+
/** Stop the pending doorbell. Nothing in flight is cancelled — it is idempotent. */
|
|
11098
|
+
dispose() {
|
|
11099
|
+
this.disposed = true;
|
|
11100
|
+
if (this.timer !== null) {
|
|
11101
|
+
clearTimeout(this.timer);
|
|
11102
|
+
this.timer = null;
|
|
11103
|
+
}
|
|
11104
|
+
}
|
|
11105
|
+
async run(trigger) {
|
|
11106
|
+
if (this.running) {
|
|
11107
|
+
this.rearm = true;
|
|
11108
|
+
return;
|
|
11109
|
+
}
|
|
11110
|
+
this.running = true;
|
|
11111
|
+
this.lastStartedAt = this.now;
|
|
11112
|
+
try {
|
|
11113
|
+
await this.deps.refresh();
|
|
11114
|
+
} catch (err) {
|
|
11115
|
+
this.deps.logger.warn("recorder: could not re-resolve storage locations — the previously resolved set stands", { meta: {
|
|
11116
|
+
trigger,
|
|
11117
|
+
error: errMsg(err)
|
|
11118
|
+
} });
|
|
11119
|
+
} finally {
|
|
11120
|
+
this.running = false;
|
|
11121
|
+
}
|
|
11122
|
+
if (this.rearm && !this.disposed) {
|
|
11123
|
+
this.rearm = false;
|
|
11124
|
+
await this.run("change during refresh");
|
|
11125
|
+
}
|
|
11126
|
+
}
|
|
11127
|
+
};
|
|
11022
11128
|
//#endregion
|
|
11023
11129
|
//#region src/recorder/addon/ops-log-store.ts
|
|
11024
11130
|
/**
|
|
@@ -11173,6 +11279,72 @@ function createRecordingOpsLogSink(deps) {
|
|
|
11173
11279
|
};
|
|
11174
11280
|
}
|
|
11175
11281
|
//#endregion
|
|
11282
|
+
//#region src/recorder/addon/pending-writer-moves.ts
|
|
11283
|
+
/**
|
|
11284
|
+
* The gap between the PLAN and the DISK, for as long as it stands.
|
|
11285
|
+
*
|
|
11286
|
+
* Placement applies at writer-attach boundaries only: the `.rec-tmp` staging
|
|
11287
|
+
* dir lives UNDER the chosen root so the finalize relocate is a
|
|
11288
|
+
* same-filesystem rename, and repointing a live writer would turn every
|
|
11289
|
+
* segment into the EXDEV copy+unlink fallback — 2× disk writes on the hot path
|
|
11290
|
+
* (the F1 regression). So a plan reaches a camera when it next attaches.
|
|
11291
|
+
*
|
|
11292
|
+
* For a camera on a schedule that is the next band edge, minutes away. For a
|
|
11293
|
+
* CONTINUOUSLY recording camera there is no next attach: its writer outlives
|
|
11294
|
+
* every tick, and its assignment can be re-planned onto a new disk forever
|
|
11295
|
+
* without one byte moving. That is the state this module makes visible (D387).
|
|
11296
|
+
* The alternative — detaching live writers to apply a plan — buys the move at
|
|
11297
|
+
* the cost of a recording gap on every affected camera at once, which is
|
|
11298
|
+
* exactly the wrong trade on the day an operator adds a disk to relieve
|
|
11299
|
+
* pressure.
|
|
11300
|
+
*/
|
|
11301
|
+
/** Is `dir` inside `root` (or the root itself)? A prefix compare that cannot
|
|
11302
|
+
* match `/recordings-2` against `/recordings`. */
|
|
11303
|
+
function isUnder(dir, root) {
|
|
11304
|
+
const rel = path.relative(root, dir);
|
|
11305
|
+
return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
11306
|
+
}
|
|
11307
|
+
/** The location whose root contains `dir` — the DEEPEST one, so a location
|
|
11308
|
+
* nested inside another is attributed to the nested one. */
|
|
11309
|
+
function locationOf(dir, locations) {
|
|
11310
|
+
let best = null;
|
|
11311
|
+
for (const location of locations) {
|
|
11312
|
+
if (!isUnder(dir, location.root)) continue;
|
|
11313
|
+
if (best === null || location.root.length > best.root.length) best = location;
|
|
11314
|
+
}
|
|
11315
|
+
return best?.id ?? null;
|
|
11316
|
+
}
|
|
11317
|
+
/**
|
|
11318
|
+
* Every live writer the plan wants somewhere else.
|
|
11319
|
+
*
|
|
11320
|
+
* A writer whose key the plan says nothing about is NOT pending: the pass
|
|
11321
|
+
* plans a work SUBSET, and "not in this pass" must never read as "planned
|
|
11322
|
+
* elsewhere". A writer under no known root is reported with `writingTo: null`
|
|
11323
|
+
* rather than dropped — a directory outside every resolved location is its own
|
|
11324
|
+
* fault, and a silent skip would hide it.
|
|
11325
|
+
*/
|
|
11326
|
+
function pendingWriterMoves(writers, planned, locations) {
|
|
11327
|
+
const out = [];
|
|
11328
|
+
for (const writer of writers) {
|
|
11329
|
+
const plannedFor = planned.get(`${String(writer.deviceId)}:${writer.profile}`);
|
|
11330
|
+
if (plannedFor === void 0) continue;
|
|
11331
|
+
const writingTo = locationOf(writer.outDir, locations);
|
|
11332
|
+
if (writingTo === plannedFor) continue;
|
|
11333
|
+
out.push({
|
|
11334
|
+
deviceId: writer.deviceId,
|
|
11335
|
+
profile: writer.profile,
|
|
11336
|
+
writingTo,
|
|
11337
|
+
plannedFor
|
|
11338
|
+
});
|
|
11339
|
+
}
|
|
11340
|
+
return out.toSorted((a, b) => a.deviceId - b.deviceId || a.profile.localeCompare(b.profile));
|
|
11341
|
+
}
|
|
11342
|
+
/** A stable signature of a pending set, so the report is latched on WHAT is
|
|
11343
|
+
* pending rather than repeated every 30 s tick. */
|
|
11344
|
+
function pendingMovesSignature(moves) {
|
|
11345
|
+
return moves.map((m) => `${String(m.deviceId)}:${m.profile}>${m.plannedFor}`).join(",");
|
|
11346
|
+
}
|
|
11347
|
+
//#endregion
|
|
11176
11348
|
//#region src/recorder/addon/periodic-pass.ts
|
|
11177
11349
|
function startPeriodicPass(deps) {
|
|
11178
11350
|
const now = () => deps.now?.() ?? Date.now();
|
|
@@ -11295,6 +11467,30 @@ function poolFor(demand, candidates) {
|
|
|
11295
11467
|
function poolKey(pool) {
|
|
11296
11468
|
return pool.map((c) => c.locationId).toSorted().join("|");
|
|
11297
11469
|
}
|
|
11470
|
+
/**
|
|
11471
|
+
* What one demand with NO measured rate weighs: the median of the rates that
|
|
11472
|
+
* WERE measured, and one unit when nothing in the pool was measured at all.
|
|
11473
|
+
*
|
|
11474
|
+
* Never zero, and that is the whole point (D387). `dailyBytes` is 0 for every
|
|
11475
|
+
* camera the recorder's RAM index has not accounted for — after D287 that is
|
|
11476
|
+
* most of a fleet at any moment — and a zero-byte demand is invisible to every
|
|
11477
|
+
* balance calculation here. Phase C reaches for the SMALLEST demand on an
|
|
11478
|
+
* overloaded location, which is one of those; moving 0 bytes moves neither side
|
|
11479
|
+
* towards its target, so the strict-improvement check fails and the rebalance
|
|
11480
|
+
* `break`s on its first iteration. A single measured camera in the pool was
|
|
11481
|
+
* enough to make every unmeasured one weightless and wedge the whole pass — on
|
|
11482
|
+
* a live hub that left 19 assignments pinned to an 89%-full disk with an empty
|
|
11483
|
+
* 4 TB one next to it, silently, on every 30 s tick.
|
|
11484
|
+
*
|
|
11485
|
+
* The median (rather than the mean) so one 24/7 4K camera does not decide what
|
|
11486
|
+
* an unknown camera is assumed to cost.
|
|
11487
|
+
*/
|
|
11488
|
+
function weightOfUnmeasured(demands) {
|
|
11489
|
+
const measured = demands.map((d) => d.dailyBytes).filter((bytes) => bytes > 0).toSorted((a, b) => a - b);
|
|
11490
|
+
if (measured.length === 0) return 1;
|
|
11491
|
+
const mid = Math.floor(measured.length / 2);
|
|
11492
|
+
return measured.length % 2 === 1 ? measured[mid] : (measured[mid - 1] + measured[mid]) / 2;
|
|
11493
|
+
}
|
|
11298
11494
|
/** Normalised weight per location: headroom share, or an even split when no
|
|
11299
11495
|
* location reports any headroom (nothing is known yet — never divide by 0). */
|
|
11300
11496
|
function weightsFor(pool) {
|
|
@@ -11344,8 +11540,8 @@ function planPlacement(input) {
|
|
|
11344
11540
|
});
|
|
11345
11541
|
}
|
|
11346
11542
|
for (const { pool, demands } of pools.values()) {
|
|
11347
|
-
const
|
|
11348
|
-
const bytesOf = (d) =>
|
|
11543
|
+
const unmeasured = weightOfUnmeasured(demands);
|
|
11544
|
+
const bytesOf = (d) => d.dailyBytes > 0 ? d.dailyBytes : unmeasured;
|
|
11349
11545
|
const totalBytes = demands.reduce((sum, d) => sum + bytesOf(d), 0);
|
|
11350
11546
|
const weights = weightsFor(pool);
|
|
11351
11547
|
const slots = pool.map((c) => ({
|
|
@@ -11490,19 +11686,16 @@ var RecordingPlacementBlobSchema = object({
|
|
|
11490
11686
|
/** locationId → the volume id last verified at that location's root. */
|
|
11491
11687
|
volumeIds: record(string(), string()).default({}),
|
|
11492
11688
|
/**
|
|
11493
|
-
*
|
|
11494
|
-
*
|
|
11495
|
-
*
|
|
11496
|
-
*
|
|
11497
|
-
* as a repoint: shipping this field must not invalidate assignments that were
|
|
11498
|
-
* made under an authority nothing wrote down. The first pass stamps it and
|
|
11499
|
-
* invalidates nothing; every later change of it is a repoint.
|
|
11689
|
+
* DEAD KEY (D383) — the class authority a pass planned against, back when a
|
|
11690
|
+
* class had a default distinct from its pool. Declared so an existing blob
|
|
11691
|
+
* round-trips unchanged and so its removal is a decision on the record rather
|
|
11692
|
+
* than a silent strip. Never read, never written.
|
|
11500
11693
|
*/
|
|
11501
11694
|
defaultLocations: record(string(), string()).default({}),
|
|
11502
11695
|
/** When the blob was last written (operator-facing debugging only). */
|
|
11503
11696
|
updatedAt: number().optional()
|
|
11504
11697
|
});
|
|
11505
|
-
/** Cold-start value: nothing assigned, nothing claimed
|
|
11698
|
+
/** Cold-start value: nothing assigned, nothing claimed. */
|
|
11506
11699
|
var EMPTY_PLACEMENT = {
|
|
11507
11700
|
assignments: {},
|
|
11508
11701
|
pins: {},
|
|
@@ -11551,31 +11744,27 @@ async function replaceAssignments(state, base, assignments) {
|
|
|
11551
11744
|
});
|
|
11552
11745
|
}
|
|
11553
11746
|
/**
|
|
11554
|
-
* The assignment record a pass leaves behind: the stored one
|
|
11555
|
-
*
|
|
11556
|
-
*
|
|
11747
|
+
* The assignment record a pass leaves behind: the stored one with the plan
|
|
11748
|
+
* merged over it. **Nothing is ever removed** — a pass that plans a subset
|
|
11749
|
+
* cannot delete a key it did not mention.
|
|
11557
11750
|
*
|
|
11558
|
-
* PURE, and shared by the write and the
|
|
11751
|
+
* PURE, and shared by the write and the placement census — a report computed
|
|
11559
11752
|
* from a second reading of the same rule is a report that can describe a record
|
|
11560
11753
|
* the write did not produce.
|
|
11561
11754
|
*/
|
|
11562
11755
|
function applyPlacementPass(base, pass) {
|
|
11563
11756
|
const next = new Map(Object.entries(base));
|
|
11564
|
-
for (const [key, locationId] of pass.repointed) next.set(key, locationId);
|
|
11565
11757
|
for (const [key, locationId] of pass.planned) next.set(key, locationId);
|
|
11566
11758
|
return next;
|
|
11567
11759
|
}
|
|
11568
|
-
/** Persist one work-scoped pass:
|
|
11569
|
-
*
|
|
11760
|
+
/** Persist one work-scoped pass: merge its plan over the stored record. One
|
|
11761
|
+
* write. */
|
|
11570
11762
|
async function writePlacementPass(state, base, pass) {
|
|
11571
11763
|
const assignments = {};
|
|
11572
11764
|
for (const [key, locationId] of applyPlacementPass(base.assignments, pass)) assignments[key] = locationId;
|
|
11573
|
-
const defaultLocations = {};
|
|
11574
|
-
for (const [type, locationId] of pass.defaultLocations) defaultLocations[type] = locationId;
|
|
11575
11765
|
await state.set({
|
|
11576
11766
|
...base,
|
|
11577
11767
|
assignments,
|
|
11578
|
-
defaultLocations,
|
|
11579
11768
|
updatedAt: Date.now()
|
|
11580
11769
|
});
|
|
11581
11770
|
}
|
|
@@ -11824,14 +12013,6 @@ var NO_PLAN = {
|
|
|
11824
12013
|
assignments: /* @__PURE__ */ new Map(),
|
|
11825
12014
|
changes: []
|
|
11826
12015
|
};
|
|
11827
|
-
/** Is the authority already stamped, exactly? A pass that would change the
|
|
11828
|
-
* stamp always writes, even when it moved nothing: an unstamped authority is
|
|
11829
|
-
* an undetectable repoint. */
|
|
11830
|
-
function sameStamp(stamped, authority) {
|
|
11831
|
-
if (stamped.size !== authority.size) return false;
|
|
11832
|
-
for (const [type, locationId] of authority) if (stamped.get(type) !== locationId) return false;
|
|
11833
|
-
return true;
|
|
11834
|
-
}
|
|
11835
12016
|
/** Operator pins win over the planner for high/mid of the pinned camera. */
|
|
11836
12017
|
function overlayPins(planned, pins) {
|
|
11837
12018
|
const next = new Map(planned);
|
|
@@ -11859,14 +12040,11 @@ var PlacementService = class {
|
|
|
11859
12040
|
/** The fair tie-break the PURE planner defers to (D37). Advisory state, and
|
|
11860
12041
|
* consulted only for a tie the capacity signal cannot separate. */
|
|
11861
12042
|
rotation = new PlacementRotation();
|
|
11862
|
-
/** The
|
|
11863
|
-
* announced yet
|
|
11864
|
-
|
|
11865
|
-
/** When the
|
|
11866
|
-
|
|
11867
|
-
/** Repoint episodes already announced (`type:from->to`). Cleared the moment a
|
|
11868
|
-
* pass sees no repoint at all — the next one is a NEW episode. */
|
|
11869
|
-
repointAnnounced = /* @__PURE__ */ new Set();
|
|
12043
|
+
/** The census last announced, as a stable signature. `null` ⇒ nothing
|
|
12044
|
+
* announced yet. */
|
|
12045
|
+
censusSignature = null;
|
|
12046
|
+
/** When the census was last announced. */
|
|
12047
|
+
censusReportedAt = null;
|
|
11870
12048
|
constructor(deps) {
|
|
11871
12049
|
this.deps = deps;
|
|
11872
12050
|
}
|
|
@@ -11901,15 +12079,14 @@ var PlacementService = class {
|
|
|
11901
12079
|
async place(deviceId, profile) {
|
|
11902
12080
|
const pool = this.pool();
|
|
11903
12081
|
this.reprobeAfterBackoff(pool);
|
|
11904
|
-
const
|
|
11905
|
-
const resolved = await this.assignedLocation(deviceId, profile, pool, authority) ?? resolvePlacement(pool, profile);
|
|
12082
|
+
const resolved = await this.assignedLocation(deviceId, profile, pool) ?? resolvePlacement(await this.withHeadroom(pool), profile);
|
|
11906
12083
|
const usable = await this.ensureUsable(resolved, pool, profile, deviceId);
|
|
11907
12084
|
if (this.everyLocationUnusable(pool)) {
|
|
11908
12085
|
this.reportEveryLocationUnusable(deviceId, profile, resolved, pool);
|
|
11909
12086
|
return resolved;
|
|
11910
12087
|
}
|
|
11911
12088
|
this.allUnusableReported.delete(deviceId);
|
|
11912
|
-
await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id
|
|
12089
|
+
await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id);
|
|
11913
12090
|
return usable;
|
|
11914
12091
|
}
|
|
11915
12092
|
/**
|
|
@@ -11993,35 +12170,30 @@ var PlacementService = class {
|
|
|
11993
12170
|
* to it, so an unknown flag is the only thing that counts as opted in.
|
|
11994
12171
|
*/
|
|
11995
12172
|
pool() {
|
|
11996
|
-
const { locations,
|
|
11997
|
-
if (
|
|
12173
|
+
const { locations, fellBackToDisabled } = placementPool(this.deps.locations());
|
|
12174
|
+
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) } });
|
|
11998
12175
|
return locations;
|
|
11999
12176
|
}
|
|
12000
12177
|
/**
|
|
12001
12178
|
* The stored assignment for one (camera, profile), when it is still valid.
|
|
12002
12179
|
*
|
|
12003
|
-
*
|
|
12180
|
+
* Two things invalidate it, and under the operator's model (D383) there is
|
|
12181
|
+
* nothing else that can:
|
|
12004
12182
|
*
|
|
12005
|
-
* 1. the location is absent from the current
|
|
12006
|
-
*
|
|
12007
|
-
*
|
|
12008
|
-
*
|
|
12183
|
+
* 1. the location is absent from the current POOL — deleted, or the
|
|
12184
|
+
* operator disabled it. The pool is the single write authority, so an
|
|
12185
|
+
* assignment naming a location outside it is not a divergence to report,
|
|
12186
|
+
* it is unrepresentable;
|
|
12187
|
+
* 2. the location is read-only (an operator drain).
|
|
12009
12188
|
*
|
|
12010
|
-
*
|
|
12011
|
-
*
|
|
12012
|
-
*
|
|
12013
|
-
*
|
|
12014
|
-
*
|
|
12015
|
-
*
|
|
12016
|
-
* authority. On 2026-08-29 that left two of thirteen cameras writing to the
|
|
12017
|
-
* disk the fleet was migrating off, silently (D298).
|
|
12018
|
-
*
|
|
12019
|
-
* The invalidation is a TRANSITION, not a policy: it fires only while the
|
|
12020
|
-
* stamped authority disagrees with the live one, which the next placement pass
|
|
12021
|
-
* ends by stamping. After that the plan is the authority again and a
|
|
12022
|
-
* deliberate spread is honoured — and reported (`reportDivergence`).
|
|
12189
|
+
* There used to be a third — "the class was REPOINTED under it", measured
|
|
12190
|
+
* against the class default stamped in the blob (D301). It existed only
|
|
12191
|
+
* because the default was a SECOND write authority layered over the pool and
|
|
12192
|
+
* the two could disagree. With the default deleted the disagreement cannot be
|
|
12193
|
+
* expressed: a location leaving the pool already invalidates every assignment
|
|
12194
|
+
* naming it, and the planner then moves those keys with `reason: 'gone'`.
|
|
12023
12195
|
*/
|
|
12024
|
-
async assignedLocation(deviceId, profile, pool
|
|
12196
|
+
async assignedLocation(deviceId, profile, pool) {
|
|
12025
12197
|
let blob;
|
|
12026
12198
|
try {
|
|
12027
12199
|
blob = await readPlacementState(this.deps.state);
|
|
@@ -12043,23 +12215,28 @@ var PlacementService = class {
|
|
|
12043
12215
|
}
|
|
12044
12216
|
if (assignedId === void 0) return null;
|
|
12045
12217
|
const location = pool.find((l) => l.id === assignedId && !l.readOnly) ?? null;
|
|
12046
|
-
if (location === null)
|
|
12047
|
-
const type = placementTypeForProfile(profile);
|
|
12048
|
-
const target = authority.get(type);
|
|
12049
|
-
const stamped = blob.defaultLocations[type];
|
|
12050
|
-
if (target === void 0 || stamped === void 0) return location;
|
|
12051
|
-
if (stamped === target || location.id === target) return location;
|
|
12052
|
-
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", {
|
|
12218
|
+
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.", {
|
|
12053
12219
|
tags: { deviceId },
|
|
12054
12220
|
meta: {
|
|
12055
12221
|
profile,
|
|
12056
|
-
assignedLocationId:
|
|
12057
|
-
|
|
12058
|
-
repointedTo: target,
|
|
12059
|
-
locationType: type
|
|
12222
|
+
assignedLocationId: assignedId,
|
|
12223
|
+
poolLocationIds: pool.filter((l) => !l.readOnly).map((l) => l.id)
|
|
12060
12224
|
}
|
|
12061
12225
|
});
|
|
12062
|
-
return
|
|
12226
|
+
return location;
|
|
12227
|
+
}
|
|
12228
|
+
/** The pool, with each member's measured headroom attached. One capacity read
|
|
12229
|
+
* per location; used only on the bootstrap path, never per segment. */
|
|
12230
|
+
async withHeadroom(pool) {
|
|
12231
|
+
const candidates = await this.candidatesFor(pool);
|
|
12232
|
+
const byId = new Map(candidates.map((c) => [c.locationId, c.headroomBytes]));
|
|
12233
|
+
return pool.map((location) => {
|
|
12234
|
+
const headroomBytes = byId.get(location.id);
|
|
12235
|
+
return headroomBytes === void 0 ? location : {
|
|
12236
|
+
...location,
|
|
12237
|
+
headroomBytes
|
|
12238
|
+
};
|
|
12239
|
+
});
|
|
12063
12240
|
}
|
|
12064
12241
|
/**
|
|
12065
12242
|
* Re-plan the (camera, profile) pairs in `work` over the current locations.
|
|
@@ -12081,7 +12258,6 @@ var PlacementService = class {
|
|
|
12081
12258
|
*/
|
|
12082
12259
|
async recompute(work) {
|
|
12083
12260
|
const pool = this.pool();
|
|
12084
|
-
const authority = authorityLocations(pool);
|
|
12085
12261
|
const candidates = await this.candidatesFor(pool);
|
|
12086
12262
|
const demands = work.map((item) => ({
|
|
12087
12263
|
key: assignmentKey(item.deviceId, item.profile),
|
|
@@ -12098,17 +12274,7 @@ var PlacementService = class {
|
|
|
12098
12274
|
} });
|
|
12099
12275
|
return NO_PLAN;
|
|
12100
12276
|
}
|
|
12101
|
-
const
|
|
12102
|
-
const repoints = this.detectRepoints(blob.assignments, stamped, authority, blob.pins);
|
|
12103
|
-
if (repoints.length === 0) this.repointAnnounced.clear();
|
|
12104
|
-
for (const repoint of repoints) this.announceRepoint(repoint);
|
|
12105
|
-
const repointed = /* @__PURE__ */ new Map();
|
|
12106
|
-
for (const repoint of repoints) for (const key of repoint.keys) repointed.set(key, repoint.to);
|
|
12107
|
-
const current = applyPlacementPass(blob.assignments, {
|
|
12108
|
-
repointed,
|
|
12109
|
-
planned: /* @__PURE__ */ new Map(),
|
|
12110
|
-
defaultLocations: authority
|
|
12111
|
-
});
|
|
12277
|
+
const current = new Map(Object.entries(blob.assignments));
|
|
12112
12278
|
const evacuate = new Set(candidates.filter((c) => c.headroomBytes <= 0).map((c) => c.locationId));
|
|
12113
12279
|
for (const location of pool) if (location.readOnly) evacuate.add(location.id);
|
|
12114
12280
|
const plan = planPlacement({
|
|
@@ -12118,13 +12284,9 @@ var PlacementService = class {
|
|
|
12118
12284
|
evacuate,
|
|
12119
12285
|
breakTie: (tied) => this.rotation.next(tied)
|
|
12120
12286
|
});
|
|
12121
|
-
const pass = {
|
|
12122
|
-
|
|
12123
|
-
|
|
12124
|
-
defaultLocations: authority
|
|
12125
|
-
};
|
|
12126
|
-
this.reportDivergence(applyPlacementPass(blob.assignments, pass), authority);
|
|
12127
|
-
if (plan.changes.length === 0 && repointed.size === 0 && sameStamp(stamped, authority)) return plan;
|
|
12287
|
+
const pass = { planned: overlayPins(plan.assignments, blob.pins) };
|
|
12288
|
+
this.reportCensus(applyPlacementPass(blob.assignments, pass), pool);
|
|
12289
|
+
if (plan.changes.length === 0) return plan;
|
|
12128
12290
|
for (const change of plan.changes) {
|
|
12129
12291
|
const deviceId = Number(change.key.split(":")[0]);
|
|
12130
12292
|
this.deps.logger.info("recorder placement: plan moves a profile to another location", {
|
|
@@ -12148,129 +12310,60 @@ var PlacementService = class {
|
|
|
12148
12310
|
return plan;
|
|
12149
12311
|
}
|
|
12150
12312
|
/**
|
|
12151
|
-
*
|
|
12152
|
-
*
|
|
12153
|
-
*
|
|
12154
|
-
* PURE. A class is repointed when the blob stamped an authority for it and
|
|
12155
|
-
* the authority now is a different location. A class with NO stamp is not a
|
|
12156
|
-
* repoint — it is the first pass after this shipped, and inferring a repoint
|
|
12157
|
-
* from an absent stamp would throw away every deliberate assignment on the
|
|
12158
|
-
* first tick after a deploy.
|
|
12159
|
-
*/
|
|
12160
|
-
detectRepoints(stored, stamped, authority, pins) {
|
|
12161
|
-
const out = [];
|
|
12162
|
-
for (const [type, to] of authority) {
|
|
12163
|
-
const from = stamped.get(type);
|
|
12164
|
-
if (from === void 0 || from === to) continue;
|
|
12165
|
-
const keys = [];
|
|
12166
|
-
for (const [key, locationId] of Object.entries(stored)) {
|
|
12167
|
-
const [devicePart, profilePart] = key.split(":");
|
|
12168
|
-
if (placementTypeForProfile(profilePart ?? "") !== type) continue;
|
|
12169
|
-
if (locationId === to) continue;
|
|
12170
|
-
if (type === "recordings" && devicePart !== void 0 && pins[devicePart] !== void 0) continue;
|
|
12171
|
-
keys.push(key);
|
|
12172
|
-
}
|
|
12173
|
-
out.push({
|
|
12174
|
-
type,
|
|
12175
|
-
from,
|
|
12176
|
-
to,
|
|
12177
|
-
keys
|
|
12178
|
-
});
|
|
12179
|
-
}
|
|
12180
|
-
return out;
|
|
12181
|
-
}
|
|
12182
|
-
/**
|
|
12183
|
-
* One line PER CAMERA (a repoint is asked per-camera: "did 3839 move?"), plus
|
|
12184
|
-
* one audit row per class. A repoint is a rare operator event, so the volume
|
|
12185
|
-
* is one burst of fleet-size, not a stream.
|
|
12186
|
-
*
|
|
12187
|
-
* Announced ONCE per episode: the detection is a comparison against the
|
|
12188
|
-
* stamp, so a write that fails leaves the stamp behind and the next tick sees
|
|
12189
|
-
* the same repoint again. Without the latch, a settings door that accepts
|
|
12190
|
-
* reads and refuses writes would repeat the whole burst every 30 s.
|
|
12191
|
-
*/
|
|
12192
|
-
announceRepoint(repoint) {
|
|
12193
|
-
const episode = `${repoint.type}:${repoint.from}->${repoint.to}`;
|
|
12194
|
-
if (this.repointAnnounced.has(episode)) return;
|
|
12195
|
-
this.repointAnnounced.add(episode);
|
|
12196
|
-
for (const key of repoint.keys) {
|
|
12197
|
-
const deviceId = Number(key.split(":")[0]);
|
|
12198
|
-
this.deps.logger.warn("recorder placement: recordings class REPOINTED — moving this assignment onto the class`s new authority location", {
|
|
12199
|
-
...Number.isFinite(deviceId) ? { tags: { deviceId } } : {},
|
|
12200
|
-
meta: {
|
|
12201
|
-
key,
|
|
12202
|
-
locationType: repoint.type,
|
|
12203
|
-
repointedFrom: repoint.from,
|
|
12204
|
-
repointedTo: repoint.to
|
|
12205
|
-
}
|
|
12206
|
-
});
|
|
12207
|
-
}
|
|
12208
|
-
this.deps.opsLog.append({
|
|
12209
|
-
op: "relocate",
|
|
12210
|
-
reason: "operator",
|
|
12211
|
-
deviceId: null,
|
|
12212
|
-
itemsAffected: repoint.keys.length,
|
|
12213
|
-
bytesReclaimed: 0,
|
|
12214
|
-
actor: "system",
|
|
12215
|
-
detail: `placement: ${repoint.type} repointed ${repoint.from} → ${repoint.to}; ${repoint.keys.length.toString()} assignment(s) moved (${repoint.keys.join(", ")})`
|
|
12216
|
-
});
|
|
12217
|
-
}
|
|
12218
|
-
/**
|
|
12219
|
-
* Say which (camera, profile) the plan writes somewhere other than its class
|
|
12220
|
-
* authority — per camera, WITH its denominator (D277).
|
|
12313
|
+
* WHERE THE FLEET WRITES — one line per (class, location), always with its
|
|
12314
|
+
* denominator (D277).
|
|
12221
12315
|
*
|
|
12222
|
-
* This
|
|
12223
|
-
*
|
|
12224
|
-
*
|
|
12225
|
-
*
|
|
12226
|
-
*
|
|
12227
|
-
*
|
|
12228
|
-
*
|
|
12316
|
+
* This replaced the class-authority divergence report (D301) when the default
|
|
12317
|
+
* was deleted: with every enabled location of a class a legitimate target,
|
|
12318
|
+
* "this camera does not write to THE location" is not a fact any more, while
|
|
12319
|
+
* "recordings: 9 of 13 on recordings:default, 4 on recordings:hgst" is
|
|
12320
|
+
* exactly the question an operator asks after enabling a second disk. A pool
|
|
12321
|
+
* member holding ZERO assignments is listed too — an enabled disk nothing
|
|
12322
|
+
* lands on is the failure this whole model exists to make visible.
|
|
12229
12323
|
*
|
|
12230
|
-
* Fires on a CHANGE of the
|
|
12231
|
-
* {@link
|
|
12232
|
-
* until it stops being all-aligned, and again when it recovers.
|
|
12324
|
+
* Fires on a CHANGE of the distribution, and otherwise at most once per
|
|
12325
|
+
* {@link PLACEMENT_CENSUS_REPORT_MS}.
|
|
12233
12326
|
*/
|
|
12234
|
-
|
|
12235
|
-
const
|
|
12327
|
+
reportCensus(assignments, pool) {
|
|
12328
|
+
const counts = /* @__PURE__ */ new Map();
|
|
12329
|
+
const perClass = /* @__PURE__ */ new Map();
|
|
12330
|
+
for (const location of pool) if (!location.readOnly) counts.set(location.id, 0);
|
|
12236
12331
|
for (const [key, locationId] of assignments) {
|
|
12237
|
-
|
|
12238
|
-
const
|
|
12239
|
-
|
|
12240
|
-
divergent.push({
|
|
12241
|
-
key,
|
|
12242
|
-
deviceId: Number(key.split(":")[0]),
|
|
12243
|
-
profile,
|
|
12244
|
-
locationId,
|
|
12245
|
-
authorityLocationId
|
|
12246
|
-
});
|
|
12332
|
+
counts.set(locationId, (counts.get(locationId) ?? 0) + 1);
|
|
12333
|
+
const type = placementTypeForProfile(key.split(":")[1] ?? "");
|
|
12334
|
+
perClass.set(type, (perClass.get(type) ?? 0) + 1);
|
|
12247
12335
|
}
|
|
12248
|
-
const
|
|
12249
|
-
const
|
|
12336
|
+
const typeOf = new Map(pool.map((location) => [location.id, location.type]));
|
|
12337
|
+
const lines = [...counts.entries()].toSorted(([a], [b]) => a.localeCompare(b)).map(([locationId, count]) => {
|
|
12338
|
+
const type = typeOf.get(locationId) ?? locationId.split(":")[0] ?? locationId;
|
|
12339
|
+
return {
|
|
12340
|
+
locationId,
|
|
12341
|
+
type,
|
|
12342
|
+
assignments: count,
|
|
12343
|
+
ofClass: perClass.get(type) ?? 0
|
|
12344
|
+
};
|
|
12345
|
+
});
|
|
12346
|
+
const signature = lines.map((line) => `${line.locationId}=${String(line.assignments)}`).join(",");
|
|
12250
12347
|
const now = this.now;
|
|
12251
|
-
const due = this.
|
|
12252
|
-
if (signature ===
|
|
12253
|
-
this.
|
|
12254
|
-
this.
|
|
12255
|
-
if (
|
|
12256
|
-
|
|
12257
|
-
|
|
12348
|
+
const due = this.censusReportedAt === null || now - this.censusReportedAt >= 36e5;
|
|
12349
|
+
if (signature === this.censusSignature && !due) return;
|
|
12350
|
+
this.censusSignature = signature;
|
|
12351
|
+
this.censusReportedAt = now;
|
|
12352
|
+
if (lines.length === 0) return;
|
|
12353
|
+
this.deps.logger.info("recorder placement: census — which locations the fleet writes to", { meta: { locations: lines.map((line) => ({
|
|
12354
|
+
locationId: line.locationId,
|
|
12355
|
+
type: line.type,
|
|
12356
|
+
assignments: line.assignments,
|
|
12357
|
+
ofClass: line.ofClass
|
|
12358
|
+
})) } });
|
|
12359
|
+
for (const line of lines) {
|
|
12360
|
+
if (line.assignments > 0) continue;
|
|
12361
|
+
this.deps.logger.warn("recorder placement: this location is ENABLED but holds no assignment — nothing writes to it", { meta: {
|
|
12362
|
+
locationId: line.locationId,
|
|
12363
|
+
type: line.type,
|
|
12364
|
+
ofClass: line.ofClass
|
|
12365
|
+
} });
|
|
12258
12366
|
}
|
|
12259
|
-
this.deps.logger.warn("recorder placement: assignments write somewhere other than their class authority location", { meta: {
|
|
12260
|
-
divergent: divergent.length,
|
|
12261
|
-
assignments: assignments.size,
|
|
12262
|
-
authority: Object.fromEntries(authority)
|
|
12263
|
-
} });
|
|
12264
|
-
for (const entry of divergent) this.deps.logger.warn("recorder placement: this profile does NOT write to its class authority location", {
|
|
12265
|
-
...Number.isFinite(entry.deviceId) ? { tags: { deviceId: entry.deviceId } } : {},
|
|
12266
|
-
meta: {
|
|
12267
|
-
profile: entry.profile,
|
|
12268
|
-
locationId: entry.locationId,
|
|
12269
|
-
authorityLocationId: entry.authorityLocationId,
|
|
12270
|
-
divergent: divergent.length,
|
|
12271
|
-
assignments: assignments.size
|
|
12272
|
-
}
|
|
12273
|
-
});
|
|
12274
12367
|
}
|
|
12275
12368
|
/** Bound one addon-store round trip. See {@link PLACEMENT_STORE_TIMEOUT_MS}. */
|
|
12276
12369
|
bounded(promise, label) {
|
|
@@ -12360,7 +12453,7 @@ var PlacementService = class {
|
|
|
12360
12453
|
}
|
|
12361
12454
|
if (location.maxUsedGb !== void 0) {
|
|
12362
12455
|
const used = await this.deps.usedOnLocation?.(location.id) ?? 0;
|
|
12363
|
-
limits.push(location.maxUsedGb
|
|
12456
|
+
limits.push(gbToBytes(location.maxUsedGb) - used);
|
|
12364
12457
|
}
|
|
12365
12458
|
out.push({
|
|
12366
12459
|
locationId: location.id,
|
|
@@ -12451,13 +12544,13 @@ var PlacementService = class {
|
|
|
12451
12544
|
* Persist the assignment and audit it — but only when it actually changed.
|
|
12452
12545
|
* The steady state (every attach after the first) writes nothing.
|
|
12453
12546
|
*
|
|
12454
|
-
* The per-attach line carries
|
|
12455
|
-
*
|
|
12456
|
-
*
|
|
12457
|
-
*
|
|
12458
|
-
*
|
|
12547
|
+
* The per-attach line carries the camera's location and the one it replaced,
|
|
12548
|
+
* tagged with `deviceId`, so "where does 3839 write, and did it move?" is
|
|
12549
|
+
* answerable per camera. Which locations the FLEET is spread over is the
|
|
12550
|
+
* census's job (D383) — there is no class authority for a single line to be
|
|
12551
|
+
* measured against any more.
|
|
12459
12552
|
*/
|
|
12460
|
-
async recordDecision(deviceId, profile, location, verbatim
|
|
12553
|
+
async recordDecision(deviceId, profile, location, verbatim) {
|
|
12461
12554
|
const key = assignmentKey(deviceId, profile);
|
|
12462
12555
|
let previous;
|
|
12463
12556
|
try {
|
|
@@ -12478,8 +12571,6 @@ var PlacementService = class {
|
|
|
12478
12571
|
locationId: location.id,
|
|
12479
12572
|
root: location.root,
|
|
12480
12573
|
previousLocationId: previous ?? null,
|
|
12481
|
-
authorityLocationId,
|
|
12482
|
-
divergent: authorityLocationId !== null && authorityLocationId !== location.id,
|
|
12483
12574
|
reason: verbatim ? "resolved" : "failover"
|
|
12484
12575
|
}
|
|
12485
12576
|
});
|
|
@@ -14450,6 +14541,26 @@ var RecordingController = class {
|
|
|
14450
14541
|
this.wakeups.arm(deviceId, next.atMs, next.reason);
|
|
14451
14542
|
}
|
|
14452
14543
|
/**
|
|
14544
|
+
* Where every LIVE writer is writing right now — the directory ffmpeg was
|
|
14545
|
+
* spawned against, per (device, profile).
|
|
14546
|
+
*
|
|
14547
|
+
* The plan and the disk disagree for as long as a writer outlives the tick
|
|
14548
|
+
* that re-planned it: placement applies at ATTACH BOUNDARIES only (the
|
|
14549
|
+
* `.rec-tmp` staging dir lives under the chosen root so the finalize
|
|
14550
|
+
* relocate is a same-filesystem rename), so a continuously-recording camera
|
|
14551
|
+
* keeps its root until something detaches it. That is a legitimate state and
|
|
14552
|
+
* a silent one, which is why it is READABLE from here (D387).
|
|
14553
|
+
*/
|
|
14554
|
+
activeWriterDirs() {
|
|
14555
|
+
const out = [];
|
|
14556
|
+
for (const [deviceId, recordings] of this.active) for (const r of recordings) out.push({
|
|
14557
|
+
deviceId,
|
|
14558
|
+
profile: r.profile,
|
|
14559
|
+
outDir: r.outDir
|
|
14560
|
+
});
|
|
14561
|
+
return out;
|
|
14562
|
+
}
|
|
14563
|
+
/**
|
|
14453
14564
|
* Boot restore: seed the readiness-gated queue with every persisted device and
|
|
14454
14565
|
* (re)evaluate each on every `stream-broker` ready transition. Also arms the
|
|
14455
14566
|
* periodic passes. Idempotent — safe to call once after the index is hydrated.
|
|
@@ -15377,7 +15488,7 @@ var RecordingController = class {
|
|
|
15377
15488
|
tag: `recorder:${deviceId}/${profile}`
|
|
15378
15489
|
}, nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "getStreamWithCodec"));
|
|
15379
15490
|
const streamAcquiredMs = this.now();
|
|
15380
|
-
const rtspUrl = withRecordingIntent(source.url);
|
|
15491
|
+
const rtspUrl = withRecordingIntent(source.url, RECORDING_PRE_ROLL_REQUEST_MS);
|
|
15381
15492
|
this.attachSeq++;
|
|
15382
15493
|
const consumerTag = `recorder/${deviceId}/${profile}/${this.attachSeq}`;
|
|
15383
15494
|
if (rtspUrl === source.url) this.deps.logger.info("recorder: dial carries no recording intent — no pre-roll", {
|
|
@@ -16835,6 +16946,21 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
16835
16946
|
* per episode, not one per 30 s tick. `null` ⇒ the last pass had work. */
|
|
16836
16947
|
placementWorkGap = null;
|
|
16837
16948
|
/**
|
|
16949
|
+
* When the recorder re-resolves its storage locations (D387): coalesced off
|
|
16950
|
+
* the `StorageLocationsChanged` doorbell, and converged on the placement
|
|
16951
|
+
* pass's own timer whether or not that doorbell ever arrives. Built on the
|
|
16952
|
+
* recording node only, with the rest of the write path.
|
|
16953
|
+
*/
|
|
16954
|
+
locationRefresher = null;
|
|
16955
|
+
/** Unsubscribe handle for the `StorageLocationsChanged` subscription. */
|
|
16956
|
+
locationChangeUnsub = null;
|
|
16957
|
+
/**
|
|
16958
|
+
* How many live writers are recording somewhere other than their planned
|
|
16959
|
+
* location, latched so the line is one per episode rather than one per 30 s
|
|
16960
|
+
* tick. `null` ⇒ nothing pending (or nothing measured yet).
|
|
16961
|
+
*/
|
|
16962
|
+
pendingWriterMoves = null;
|
|
16963
|
+
/**
|
|
16838
16964
|
* The calendar rungs — which days, which hours, and a day's exact ranges.
|
|
16839
16965
|
*
|
|
16840
16966
|
* Hours the RAM index has already walked (or the live hour it is
|
|
@@ -17059,8 +17185,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17059
17185
|
},
|
|
17060
17186
|
archiveHourPaths: async (deviceId, profile, locationId, hourStartMs) => await this.segmentHours?.hourPaths(deviceId, profile, locationId, hourStartMs) ?? null,
|
|
17061
17187
|
removeDir: async (locationId, relDir) => {
|
|
17062
|
-
const loc = this.resolvedLocations
|
|
17063
|
-
if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
|
|
17188
|
+
const loc = requireReadableLocation(this.resolvedLocations, locationId, "recorder");
|
|
17064
17189
|
await promises.rm(path.join(loc.root, relDir), {
|
|
17065
17190
|
recursive: true,
|
|
17066
17191
|
force: true
|
|
@@ -17098,8 +17223,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17098
17223
|
});
|
|
17099
17224
|
},
|
|
17100
17225
|
removeDirIfEmpty: async (locationId, relDir) => {
|
|
17101
|
-
const loc = this.resolvedLocations
|
|
17102
|
-
if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
|
|
17226
|
+
const loc = requireReadableLocation(this.resolvedLocations, locationId, "recorder");
|
|
17103
17227
|
await promises.rmdir(path.join(loc.root, relDir));
|
|
17104
17228
|
},
|
|
17105
17229
|
logger: {
|
|
@@ -17312,7 +17436,10 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17312
17436
|
logger: this.ctx.logger,
|
|
17313
17437
|
store: this.segmentStore,
|
|
17314
17438
|
resolveDomain: (locationId) => this.locationDomain(locationId),
|
|
17315
|
-
|
|
17439
|
+
isEvictionFrozen: (locationId) => {
|
|
17440
|
+
const location = this.resolvedLocations.find((l) => l.id === locationId);
|
|
17441
|
+
return location !== void 0 && evictionPolicyForMode(location.mode) === "never";
|
|
17442
|
+
},
|
|
17316
17443
|
deviceRegistry: async () => {
|
|
17317
17444
|
try {
|
|
17318
17445
|
const devices = await this.ctx.api.deviceManager.listAll.query({ projection: "slim" });
|
|
@@ -17492,6 +17619,10 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17492
17619
|
capability: storageEvictableCapability,
|
|
17493
17620
|
provider: evictableProvider
|
|
17494
17621
|
},
|
|
17622
|
+
{
|
|
17623
|
+
capability: storageOccupancyCapability,
|
|
17624
|
+
provider: evictableProvider
|
|
17625
|
+
},
|
|
17495
17626
|
{
|
|
17496
17627
|
capability: recordingExportCapability,
|
|
17497
17628
|
provider: exportProvider
|
|
@@ -17542,6 +17673,10 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17542
17673
|
this.sensorTriggerUnsub = null;
|
|
17543
17674
|
this.signalTriggerUnsub?.();
|
|
17544
17675
|
this.signalTriggerUnsub = null;
|
|
17676
|
+
this.locationChangeUnsub?.();
|
|
17677
|
+
this.locationChangeUnsub = null;
|
|
17678
|
+
this.locationRefresher?.dispose();
|
|
17679
|
+
this.locationRefresher = null;
|
|
17545
17680
|
await this.controller?.stop();
|
|
17546
17681
|
this.controller = null;
|
|
17547
17682
|
this.segmentStore = null;
|
|
@@ -17657,6 +17792,13 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17657
17792
|
this.ctx.logger.warn("recorder: controller start failed", { meta: { error: errMsg(err) } });
|
|
17658
17793
|
}
|
|
17659
17794
|
this.recoverStagedOrphans(stagingBootMs);
|
|
17795
|
+
if (this.locationRefresher === null) this.locationRefresher = new LocationRefresher({
|
|
17796
|
+
logger: this.ctx.logger,
|
|
17797
|
+
refresh: () => this.refreshLocations()
|
|
17798
|
+
});
|
|
17799
|
+
if (this.locationChangeUnsub === null) this.locationChangeUnsub = this.ctx.eventBus.subscribe({ category: EventCategory.StorageLocationsChanged }, (event) => {
|
|
17800
|
+
this.locationRefresher?.ring(event.data.locationId, event.data.reason);
|
|
17801
|
+
});
|
|
17660
17802
|
if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
|
|
17661
17803
|
eventBus: this.ctx.eventBus,
|
|
17662
17804
|
onMotion: (deviceId, atMs) => {
|
|
@@ -18063,6 +18205,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
18063
18205
|
*/
|
|
18064
18206
|
async recomputePlacement(deviceIds) {
|
|
18065
18207
|
if (!this.placement) return;
|
|
18208
|
+
await (this.locationRefresher?.convergeIfDue() ?? Promise.resolve());
|
|
18066
18209
|
const archive = this.segmentHours;
|
|
18067
18210
|
if (!archive) {
|
|
18068
18211
|
this.reportPlacementWorkGap("no segment-hour ledger on this node", deviceIds.length, 0);
|
|
@@ -18079,7 +18222,48 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
18079
18222
|
return;
|
|
18080
18223
|
}
|
|
18081
18224
|
this.placementWorkGap = null;
|
|
18082
|
-
await this.placement.recompute(work);
|
|
18225
|
+
const plan = await this.placement.recompute(work);
|
|
18226
|
+
this.reportPendingWriterMoves(plan.assignments);
|
|
18227
|
+
}
|
|
18228
|
+
/**
|
|
18229
|
+
* Cameras whose plan says one disk and whose live writer says another —
|
|
18230
|
+
* REPORTED, per camera, for as long as it stands (D387).
|
|
18231
|
+
*
|
|
18232
|
+
* Placement applies at attach boundaries only, so a re-planned camera moves
|
|
18233
|
+
* when it next attaches. A camera on a schedule attaches again within
|
|
18234
|
+
* minutes; a CONTINUOUSLY recording one never does, and its plan can sit
|
|
18235
|
+
* unapplied forever. Repointing a live writer is not the fix — the
|
|
18236
|
+
* `.rec-tmp` staging dir lives under the chosen root precisely so the
|
|
18237
|
+
* finalize relocate is a same-filesystem rename — and detaching the fleet to
|
|
18238
|
+
* apply a plan costs a recording gap on every affected camera at once, on
|
|
18239
|
+
* the very day the operator added a disk to relieve pressure. So the honest
|
|
18240
|
+
* behaviour is to say it, name the cameras, and let the operator decide.
|
|
18241
|
+
*
|
|
18242
|
+
* Latched on WHAT is pending: a line per 30 s tick would be its own defect.
|
|
18243
|
+
*/
|
|
18244
|
+
reportPendingWriterMoves(planned) {
|
|
18245
|
+
const controller = this.controller;
|
|
18246
|
+
if (!controller) return;
|
|
18247
|
+
const moves = pendingWriterMoves(controller.activeWriterDirs(), planned, this.resolvedLocations);
|
|
18248
|
+
const signature = pendingMovesSignature(moves);
|
|
18249
|
+
if (signature === this.pendingWriterMoves) return;
|
|
18250
|
+
this.pendingWriterMoves = signature;
|
|
18251
|
+
if (moves.length === 0) return;
|
|
18252
|
+
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", {
|
|
18253
|
+
tags: { deviceId: move.deviceId },
|
|
18254
|
+
meta: {
|
|
18255
|
+
profile: move.profile,
|
|
18256
|
+
writingTo: move.writingTo,
|
|
18257
|
+
plannedFor: move.plannedFor,
|
|
18258
|
+
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)"
|
|
18259
|
+
}
|
|
18260
|
+
});
|
|
18261
|
+
this.emitAlert({
|
|
18262
|
+
severity: "warning",
|
|
18263
|
+
title: "Recording placement waiting on a writer restart",
|
|
18264
|
+
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.`,
|
|
18265
|
+
locationId: moves[0]?.plannedFor ?? ""
|
|
18266
|
+
});
|
|
18083
18267
|
}
|
|
18084
18268
|
/**
|
|
18085
18269
|
* A placement pass that plans NOTHING says so — once per episode.
|
|
@@ -18242,12 +18426,22 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
18242
18426
|
* still be shouted about in the log even when it is unreachable.
|
|
18243
18427
|
*/
|
|
18244
18428
|
emitPlacementAlert(input) {
|
|
18245
|
-
this.
|
|
18246
|
-
id: randomUUID(),
|
|
18247
|
-
category: "storage",
|
|
18429
|
+
this.emitAlert({
|
|
18248
18430
|
severity: "error",
|
|
18249
18431
|
title: "Recordings storage location unusable",
|
|
18250
18432
|
message: input.detail,
|
|
18433
|
+
locationId: input.locationId
|
|
18434
|
+
});
|
|
18435
|
+
}
|
|
18436
|
+
/** One storage-category alert. Advisory: the LOG line is the durable record,
|
|
18437
|
+
* so a failed emit is swallowed rather than raised. */
|
|
18438
|
+
emitAlert(input) {
|
|
18439
|
+
this.ctx.api.alerts.emit.mutate({
|
|
18440
|
+
id: randomUUID(),
|
|
18441
|
+
category: "storage",
|
|
18442
|
+
severity: input.severity,
|
|
18443
|
+
title: input.title,
|
|
18444
|
+
message: input.message,
|
|
18251
18445
|
status: "active",
|
|
18252
18446
|
read: false,
|
|
18253
18447
|
createdAt: Date.now(),
|
|
@@ -18485,8 +18679,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
18485
18679
|
* manifest uses). The scrub packs read the SAME source segments as playback.
|
|
18486
18680
|
*/
|
|
18487
18681
|
resolveSegmentAbsPath(row) {
|
|
18488
|
-
const loc = this.resolvedLocations
|
|
18489
|
-
if (loc === void 0) throw new Error(`recorder scrub: unknown recordings location ${row.locationId}`);
|
|
18682
|
+
const loc = requireReadableLocation(this.resolvedLocations, row.locationId, "recorder scrub");
|
|
18490
18683
|
return path.join(loc.root, row.path);
|
|
18491
18684
|
}
|
|
18492
18685
|
/**
|