@camstack/addon-pipeline 1.2.219 → 1.2.221
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 +661 -7
- package/dist/audio-analyzer/index.mjs +658 -5
- package/dist/chunk-emK7D4bc.js +35 -0
- package/dist/{default-detection-model-d7-Q_LMo.js → default-detection-model-B1NT1A38.js} +1 -1
- package/dist/{default-detection-model-Dsk1a5pY.mjs → default-detection-model-DkVFgubU.mjs} +1 -1
- package/dist/detection-pipeline/index.js +10 -9
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-wtNhtVVo.js → dist-ATDfcxgL.js} +282 -39
- package/dist/{dist-CKK0CBUW.mjs → dist-CEFC3PkP.mjs} +265 -5
- package/dist/{lazy-sharp-zoQ07I43.js → lazy-sharp-BuwKlQLR.js} +2 -2
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-BduNi21Z.js → node-C5jnTgpv.js} +4 -3
- package/dist/{node-CrNBWFbA.mjs → node-DR3fHqUq.mjs} +1 -1
- package/dist/pipeline-runner/index.js +222 -157
- package/dist/pipeline-runner/index.mjs +218 -154
- package/dist/{process-memory-Dwl0MFPH.js → process-memory-DQbCPnMA.js} +3 -2
- package/dist/{process-memory-BRQOtDN1.mjs → process-memory-DqYj5IAb.mjs} +1 -1
- package/dist/recorder/index.js +344 -127
- package/dist/recorder/index.mjs +342 -126
- package/dist/{segment-demux-js-D2o9ShNL.mjs → segment-demux-js-CddDwD69.mjs} +1 -1
- package/dist/{segment-demux-js-CFOU3biG.js → segment-demux-js-o9KBAz9p.js} +4 -3
- package/dist/session-decode/decode-worker-child.js +155 -74
- package/dist/session-decode/decode-worker-child.mjs +154 -73
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DN_IoShh.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D5XnLZWR.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CyMYDKfM.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-DICVQkNi.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CKUGpDns.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-n7c9pNlt.mjs → hostInit-C-VdcJS2.mjs} +3 -3
- package/dist/stream-broker/index.js +231 -112
- package/dist/stream-broker/index.mjs +224 -106
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/worker-protocol-B2MfQLlu.js +184 -0
- package/dist/worker-protocol-C_W-P_g-.mjs +161 -0
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-7hqrrd88.mjs +0 -26
- package/dist/worker-protocol-Cx3Nqreq.mjs +0 -264
- package/dist/worker-protocol-iWTFnnUM.js +0 -299
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-
|
|
1
|
+
import { $ as evictionPolicyOfLocation, Ct as recordingExportCapability, D as RecordingConfigSchema, Dt as resolveLocationMode, E as RECORDING_EXPORT_MAX_READ_BYTES, F as batteryCapability, G as deriveRecordingMode, H as defineCustomActions, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingSignalStatusSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyForMode, S as OpsLogEntrySchema, St as recordingCapability, U as deriveBatteryPresence, Wt as BaseAddon, X as evaluateSensorEdge, Zt as hydrateSchema, an as sleep$1, at as loadContributionCapability, cn as boolean, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as migrateLegacyDeviceSignalConfig, fn as number, ft as modeMayWrite, g as ExportRecordSchema, gn as string, hn as record, i as BatteryStatusSchema, in as selectAssignedProfileSlots, it as isFirstLevelMacroClass, jt as storageEvictableCapability, kt as resolveRecordingProfiles, lt as mayReadLocation, mn as preprocess, on as _enum, pn as object, qt as DeviceType, sn as array, tn as nodePin, tt as gbToBytes, ut as mayWriteToLocation, vn as EventCategory, wt as recordingSignalCapability, z as customAction } from "../dist-CEFC3PkP.mjs";
|
|
2
|
+
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DR3fHqUq.mjs";
|
|
3
3
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
4
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 RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
|
|
@@ -1768,51 +1768,20 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
|
1768
1768
|
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
1769
1769
|
}
|
|
1770
1770
|
var HOUR_MS$7 = 36e5;
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
* `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
|
|
1775
|
-
* subtree — ">1M files and takes minutes" per its own comment. Until it lands,
|
|
1776
|
-
* every read of past footage sees an empty index, which the server reports as
|
|
1777
|
-
* "no footage": measured after a deploy, a camera-day held 7 segments and grew
|
|
1778
|
-
* only at the rate of NEW writes for ~9 minutes.
|
|
1779
|
-
*
|
|
1780
|
-
* The path convention makes the window addressable directly: one SHALLOW
|
|
1781
|
-
* `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
|
|
1782
|
-
* background full walk still runs, for the consumers that need the whole
|
|
1783
|
-
* archive (footprint, eviction domains, retention).
|
|
1784
|
-
*
|
|
1785
|
-
* Hours already walked are skipped, so a drag across an hour costs nothing the
|
|
1786
|
-
* second time.
|
|
1787
|
-
*
|
|
1788
|
-
* ## The ledger is the first stop for a CLOSED hour (D287)
|
|
1789
|
-
*
|
|
1790
|
-
* Boot no longer replays the archive into the index, so a ledger-known hour
|
|
1791
|
-
* arrives here `unknown` and pays ONE point read of its hour row instead of a
|
|
1792
|
-
* `readdir` — measured at ~650 ms per hour directory on the live share under
|
|
1793
|
-
* load, against the array the scrub feeder is competing for. Hours the ledger
|
|
1794
|
-
* does not know, and every hour if the read fails, fall through to the walk
|
|
1795
|
-
* exactly as before: the failure direction is toward the disk, never toward an
|
|
1796
|
-
* hour that renders empty because nobody read it.
|
|
1797
|
-
*
|
|
1798
|
-
* **The live hour is never served from the ledger.** It is the one hour whose
|
|
1799
|
-
* durable row is knowably behind the disk: a segment finalized after the last
|
|
1800
|
-
* persist and before a crash is on the array and not in the row. Boot used to
|
|
1801
|
-
* mark every ledger hour `hydrated`, which made that tail invisible to
|
|
1802
|
-
* playback and the timeline FOREVER, not merely under-reported. Excluding the
|
|
1803
|
-
* live hour costs exactly one hour-directory read per process — the walk marks
|
|
1804
|
-
* it, so the second reader pays nothing — and it is what turns a lost tail
|
|
1805
|
-
* back into a recoverable one.
|
|
1806
|
-
*
|
|
1807
|
-
* A CLOSED hour already walked is skipped, which is what makes that exclusion
|
|
1808
|
-
* affordable: without it, every window touching the live hour would re-walk
|
|
1809
|
-
* all of its hours. The past is immutable, so re-reading a closed hour
|
|
1810
|
-
* directory can only ever return what the index already holds; eviction
|
|
1811
|
-
* removes rows through the index directly.
|
|
1812
|
-
*/
|
|
1813
|
-
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
|
|
1814
|
-
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1771
|
+
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
|
|
1772
|
+
const verify = options?.verifyClosedHours === true;
|
|
1773
|
+
if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1815
1774
|
const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
|
|
1775
|
+
/** Per (locationId, profile, hour): what the LEDGER claimed, for the diff. */
|
|
1776
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
1777
|
+
const claimKey = (locationId, hour) => `${locationId}:${hour}`;
|
|
1778
|
+
/**
|
|
1779
|
+
* Closed hours the ledger answered for with a row nobody SEALED (D451): what
|
|
1780
|
+
* a writer had persisted when it was last heard from, which is a legitimate
|
|
1781
|
+
* answer and not a complete one. Each is read from the array once and the
|
|
1782
|
+
* row is unioned and sealed, so the second reader pays a point read.
|
|
1783
|
+
*/
|
|
1784
|
+
const unsealed = /* @__PURE__ */ new Set();
|
|
1816
1785
|
if (ledger !== void 0) {
|
|
1817
1786
|
const startedMs = Date.now();
|
|
1818
1787
|
const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
|
|
@@ -1821,6 +1790,8 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1821
1790
|
for (const hour of hours) {
|
|
1822
1791
|
if (hour.hourStartMs >= liveHour) continue;
|
|
1823
1792
|
index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
1793
|
+
claimed.set(claimKey(hour.locationId, hour.hourStartMs), hour.paths.length);
|
|
1794
|
+
if (!hour.sealed) unsealed.add(hour.hourStartMs);
|
|
1824
1795
|
segments += hour.paths.length;
|
|
1825
1796
|
served += 1;
|
|
1826
1797
|
}
|
|
@@ -1829,10 +1800,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1829
1800
|
meta: {
|
|
1830
1801
|
hours: served,
|
|
1831
1802
|
segments,
|
|
1803
|
+
unsealedHours: unsealed.size,
|
|
1832
1804
|
ms: Date.now() - startedMs
|
|
1833
1805
|
}
|
|
1834
1806
|
});
|
|
1835
|
-
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1807
|
+
if (!verify && unsealed.size === 0 && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1836
1808
|
}
|
|
1837
1809
|
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
1838
1810
|
const deviceDir = path.join(root, String(deviceId));
|
|
@@ -1853,8 +1825,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1853
1825
|
const reuseBefore = index.hydrateReuseStats();
|
|
1854
1826
|
let hoursRead = 0;
|
|
1855
1827
|
let repaired = 0;
|
|
1828
|
+
let forgottenSegments = 0;
|
|
1829
|
+
let forgottenHours = 0;
|
|
1830
|
+
let sealed = 0;
|
|
1856
1831
|
for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
|
|
1857
|
-
if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
|
|
1832
|
+
if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
|
|
1858
1833
|
for (const profile of profiles) {
|
|
1859
1834
|
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
1860
1835
|
let files;
|
|
@@ -1867,13 +1842,35 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1867
1842
|
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
1868
1843
|
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
1869
1844
|
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
1870
|
-
if (ledger
|
|
1871
|
-
|
|
1872
|
-
|
|
1845
|
+
if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$7 > Date.now()) continue;
|
|
1846
|
+
const claimedHere = claimed.get(claimKey(locationId, hour));
|
|
1847
|
+
const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
|
|
1848
|
+
if (outcome === null) continue;
|
|
1849
|
+
if (outcome.created) repaired += 1;
|
|
1850
|
+
else if (claimedHere !== void 0 && outcome.added > 0) {
|
|
1851
|
+
forgottenSegments += outcome.added;
|
|
1852
|
+
forgottenHours += 1;
|
|
1873
1853
|
}
|
|
1854
|
+
if (outcome.sealedNow) sealed += 1;
|
|
1874
1855
|
}
|
|
1875
1856
|
}
|
|
1876
1857
|
index.markHydrated(deviceId, fromMs, toMs);
|
|
1858
|
+
if (verify || forgottenSegments > 0) {
|
|
1859
|
+
const message = "recorder: verified the hour ledger against the array";
|
|
1860
|
+
const entry = {
|
|
1861
|
+
tags: { deviceId },
|
|
1862
|
+
meta: {
|
|
1863
|
+
root,
|
|
1864
|
+
hoursRead,
|
|
1865
|
+
forgottenSegments,
|
|
1866
|
+
forgottenHours,
|
|
1867
|
+
sealed,
|
|
1868
|
+
verify
|
|
1869
|
+
}
|
|
1870
|
+
};
|
|
1871
|
+
if (forgottenSegments > 0) logger.warn(message, entry);
|
|
1872
|
+
else logger.info(message, entry);
|
|
1873
|
+
}
|
|
1877
1874
|
if (hoursRead === 0) continue;
|
|
1878
1875
|
const reuseAfter = index.hydrateReuseStats();
|
|
1879
1876
|
const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
|
|
@@ -1884,6 +1881,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1884
1881
|
root,
|
|
1885
1882
|
hoursRead,
|
|
1886
1883
|
repaired,
|
|
1884
|
+
sealed,
|
|
1887
1885
|
reused,
|
|
1888
1886
|
allocated
|
|
1889
1887
|
}
|
|
@@ -7148,7 +7146,8 @@ async function probeVariantCodec(readPrefix, cache, deviceId, profile, firstPres
|
|
|
7148
7146
|
* checks every one, playback does not. See the comment at the branch.
|
|
7149
7147
|
*/
|
|
7150
7148
|
async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMode) {
|
|
7151
|
-
|
|
7149
|
+
const verifyClosedHours = uriMode === "absolute";
|
|
7150
|
+
if (verifyClosedHours || deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs, verifyClosedHours);
|
|
7152
7151
|
const hydration = deps.index.hydrationOf(deviceId, fromMs, toMs);
|
|
7153
7152
|
const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
|
|
7154
7153
|
const candidates = deps.index.segmentsOverlapping(deviceId, profile, fromMs, toMs).flatMap((s) => {
|
|
@@ -8528,7 +8527,7 @@ var macrotask$1 = () => new Promise((resolve) => {
|
|
|
8528
8527
|
});
|
|
8529
8528
|
/**
|
|
8530
8529
|
* @durable class=mirror owner=recorder
|
|
8531
|
-
* write="write-behind on every finalized segment (hour-row upsert);
|
|
8530
|
+
* write="write-behind on every finalized segment (hour-row upsert, serialised per hour); the writer SEALS the hour it leaves (one write per hour); a disk walk of a closed hour unions what it found and seals; eviction drops paths"
|
|
8532
8531
|
* retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
|
|
8533
8532
|
*/
|
|
8534
8533
|
var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
|
|
@@ -8597,7 +8596,17 @@ var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
|
8597
8596
|
type: "INTEGER",
|
|
8598
8597
|
notNull: true,
|
|
8599
8598
|
defaultValue: 0
|
|
8600
|
-
}
|
|
8599
|
+
},
|
|
8600
|
+
(
|
|
8601
|
+
/** {@link SegmentHourRow.sealed}, as 0/1. A row written before the column
|
|
8602
|
+
* existed reads 0 — unsealed — which is the direction that costs one
|
|
8603
|
+
* directory read, never a missing hour. */
|
|
8604
|
+
{
|
|
8605
|
+
name: "sealed",
|
|
8606
|
+
type: "INTEGER",
|
|
8607
|
+
notNull: true,
|
|
8608
|
+
defaultValue: 0
|
|
8609
|
+
})
|
|
8601
8610
|
];
|
|
8602
8611
|
var RECORDING_SEGMENT_HOURS_INDEXES = [
|
|
8603
8612
|
{
|
|
@@ -8615,6 +8624,32 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
|
|
|
8615
8624
|
{
|
|
8616
8625
|
name: "idx_recorder_segment_hours_loc_hour",
|
|
8617
8626
|
columns: ["locationId", "hourStartMs"]
|
|
8627
|
+
}),
|
|
8628
|
+
(
|
|
8629
|
+
/**
|
|
8630
|
+
* The accounting read: `SUM(bytes)`, `SUM(segments)`, `MIN(minStartMs)`,
|
|
8631
|
+
* `MAX(maxStartMs)` over `locationId IN (…)` — one row back, and with this
|
|
8632
|
+
* index not one table row read to produce it (D447).
|
|
8633
|
+
*
|
|
8634
|
+
* `idx_recorder_segment_hours_loc_hour` finds the same range, but the four
|
|
8635
|
+
* aggregates live in the table row, next to `paths` — up to 360 segment
|
|
8636
|
+
* paths of ~80 characters per hour — so the eviction index served the
|
|
8637
|
+
* accounting read at the price of ~30 KB of table page per hour row. On the
|
|
8638
|
+
* live hub that was `durationMs=1001…2902` once a MINUTE on
|
|
8639
|
+
* `hub/sqlite-settings` (174 slow-call WARNs in ten hours on 2026-09-10/11),
|
|
8640
|
+
* every one of them holding the thread every configuration read in the
|
|
8641
|
+
* cluster waits on. The index is ~50 bytes a row over ~19 k rows — nothing —
|
|
8642
|
+
* and the first declare after it ships builds it in well under a second.
|
|
8643
|
+
*/
|
|
8644
|
+
{
|
|
8645
|
+
name: "idx_recorder_segment_hours_loc_accounting",
|
|
8646
|
+
columns: [
|
|
8647
|
+
"locationId",
|
|
8648
|
+
"bytes",
|
|
8649
|
+
"segments",
|
|
8650
|
+
"minStartMs",
|
|
8651
|
+
"maxStartMs"
|
|
8652
|
+
]
|
|
8618
8653
|
})
|
|
8619
8654
|
];
|
|
8620
8655
|
/** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
|
|
@@ -8673,6 +8708,7 @@ function hourAggregate(paths) {
|
|
|
8673
8708
|
function segmentHourRow(base) {
|
|
8674
8709
|
return {
|
|
8675
8710
|
...base,
|
|
8711
|
+
sealed: base.sealed ?? false,
|
|
8676
8712
|
pathsResident: true,
|
|
8677
8713
|
...hourAggregate(base.paths)
|
|
8678
8714
|
};
|
|
@@ -8785,7 +8821,8 @@ var SEGMENT_HOUR_SUMMARY_COLUMNS = [
|
|
|
8785
8821
|
"bytes",
|
|
8786
8822
|
"segments",
|
|
8787
8823
|
"minStartMs",
|
|
8788
|
-
"maxStartMs"
|
|
8824
|
+
"maxStartMs",
|
|
8825
|
+
"sealed"
|
|
8789
8826
|
];
|
|
8790
8827
|
/** Summary rows folded into the mirror between yields. */
|
|
8791
8828
|
var LOAD_CHUNK = 5e3;
|
|
@@ -8847,6 +8884,19 @@ function hourOf(row) {
|
|
|
8847
8884
|
key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
|
|
8848
8885
|
};
|
|
8849
8886
|
}
|
|
8887
|
+
/** The identity of one writer: a camera's profile. Location is not part of it —
|
|
8888
|
+
* a relocated writer is the same writer on a new root. */
|
|
8889
|
+
function writerKey(deviceId, profile) {
|
|
8890
|
+
return `${deviceId}|${profile}`;
|
|
8891
|
+
}
|
|
8892
|
+
function detailOf(row) {
|
|
8893
|
+
return {
|
|
8894
|
+
locationId: row.locationId,
|
|
8895
|
+
hourStartMs: row.hourStartMs,
|
|
8896
|
+
paths: row.paths,
|
|
8897
|
+
sealed: row.sealed
|
|
8898
|
+
};
|
|
8899
|
+
}
|
|
8850
8900
|
/**
|
|
8851
8901
|
* The `hourStartMs` of one probe record.
|
|
8852
8902
|
*
|
|
@@ -8884,9 +8934,15 @@ function rowToValue(row) {
|
|
|
8884
8934
|
bytes: row.bytes,
|
|
8885
8935
|
segments: row.segments,
|
|
8886
8936
|
minStartMs: row.minStartMs,
|
|
8887
|
-
maxStartMs: row.maxStartMs
|
|
8937
|
+
maxStartMs: row.maxStartMs,
|
|
8938
|
+
sealed: row.sealed ? 1 : 0
|
|
8888
8939
|
};
|
|
8889
8940
|
}
|
|
8941
|
+
/** The stored 0/1 (or a boolean from a fake) as the flag. Anything else —
|
|
8942
|
+
* including the absence a pre-D451 row shows — is unsealed. */
|
|
8943
|
+
function sealedOf(raw) {
|
|
8944
|
+
return raw === 1 || raw === true;
|
|
8945
|
+
}
|
|
8890
8946
|
function recordToRow(key, data) {
|
|
8891
8947
|
const deviceId = Number(data["deviceId"]);
|
|
8892
8948
|
const profile = data["profile"];
|
|
@@ -8905,7 +8961,8 @@ function recordToRow(key, data) {
|
|
|
8905
8961
|
profile,
|
|
8906
8962
|
locationId,
|
|
8907
8963
|
hourStartMs: hour,
|
|
8908
|
-
paths: raw
|
|
8964
|
+
paths: raw,
|
|
8965
|
+
sealed: sealedOf(data["sealed"])
|
|
8909
8966
|
});
|
|
8910
8967
|
}
|
|
8911
8968
|
/** The paths of a summary row: one shared empty array, never a per-row
|
|
@@ -8940,6 +8997,7 @@ function summaryToRow(key, data) {
|
|
|
8940
8997
|
hourStartMs: hour,
|
|
8941
8998
|
paths: NO_PATHS,
|
|
8942
8999
|
pathsResident: false,
|
|
9000
|
+
sealed: sealedOf(data["sealed"]),
|
|
8943
9001
|
bytes: finiteOr0(data["bytes"]),
|
|
8944
9002
|
segments: finiteOr0(data["segments"]),
|
|
8945
9003
|
minStartMs: finiteOr0(data["minStartMs"]),
|
|
@@ -8970,6 +9028,16 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
8970
9028
|
loadChunk;
|
|
8971
9029
|
yieldBetween;
|
|
8972
9030
|
rowCap;
|
|
9031
|
+
/**
|
|
9032
|
+
* One lane per hour key: every mutation of a row runs after the previous one
|
|
9033
|
+
* on the same key has finished. Two appends that both read the row and both
|
|
9034
|
+
* wrote it back used to keep only the second — the sparse rows D451 found.
|
|
9035
|
+
*/
|
|
9036
|
+
lanes = /* @__PURE__ */ new Map();
|
|
9037
|
+
/** Per (device, profile): the hour its writer last appended to. */
|
|
9038
|
+
writerHours = /* @__PURE__ */ new Map();
|
|
9039
|
+
/** Hour keys a write was REFUSED for in this process — never sealed by it. */
|
|
9040
|
+
refused = /* @__PURE__ */ new Set();
|
|
8973
9041
|
constructor(deps) {
|
|
8974
9042
|
this.logger = deps.logger;
|
|
8975
9043
|
this.store = deps.store;
|
|
@@ -9152,57 +9220,170 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
9152
9220
|
if (row.deviceId !== deviceId) continue;
|
|
9153
9221
|
if (row.hourStartMs < first || row.hourStartMs >= toMs) continue;
|
|
9154
9222
|
if (row.pathsResident) {
|
|
9155
|
-
out.push(
|
|
9156
|
-
locationId: row.locationId,
|
|
9157
|
-
hourStartMs: row.hourStartMs,
|
|
9158
|
-
paths: row.paths
|
|
9159
|
-
});
|
|
9223
|
+
out.push(detailOf(row));
|
|
9160
9224
|
continue;
|
|
9161
9225
|
}
|
|
9162
9226
|
wanted.push(row.key);
|
|
9163
9227
|
}
|
|
9164
|
-
for (const row of await this.materialize(wanted, deviceId)) out.push(
|
|
9165
|
-
locationId: row.locationId,
|
|
9166
|
-
hourStartMs: row.hourStartMs,
|
|
9167
|
-
paths: row.paths
|
|
9168
|
-
});
|
|
9228
|
+
for (const row of await this.materialize(wanted, deviceId)) out.push(detailOf(row));
|
|
9169
9229
|
return out;
|
|
9170
9230
|
}
|
|
9171
9231
|
/**
|
|
9172
|
-
*
|
|
9232
|
+
* Union a CLOSED hour the storage walk read into its row, and SEAL it.
|
|
9173
9233
|
*
|
|
9174
9234
|
* The reader repairs the durable index it just reconstructed, so the next
|
|
9175
9235
|
* cold read of this hour is a point read instead of another `readdir`
|
|
9176
|
-
* (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2)
|
|
9177
|
-
*
|
|
9178
|
-
*
|
|
9179
|
-
*
|
|
9180
|
-
*
|
|
9181
|
-
*
|
|
9182
|
-
* D148
|
|
9236
|
+
* (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2), and it
|
|
9237
|
+
* seals the row because a directory read of a closed hour is the one
|
|
9238
|
+
* complete answer there is. The caller guarantees the hour is CLOSED — a
|
|
9239
|
+
* live hour's directory races its writer, so a walk of it is a lower bound.
|
|
9240
|
+
*
|
|
9241
|
+
* A row the ledger already holds is UNIONED, never replaced: the walk can
|
|
9242
|
+
* only add (D148) — a path the row names and the directory lacks stays,
|
|
9243
|
+
* because "the reader did not see it" is not evidence it is gone. This is
|
|
9244
|
+
* the repair D445 described and did not perform: its predecessor returned
|
|
9245
|
+
* on any known key, so a row known INCOMPLETELY — the only kind that needs
|
|
9246
|
+
* repairing — was exactly the row it refused to touch, and the export's
|
|
9247
|
+
* `forgottenSegments=9758` was counted again the next morning.
|
|
9248
|
+
*
|
|
9249
|
+
* `null` when the row is known and its detail could not be read: nothing
|
|
9250
|
+
* is written from a row whose paths are not resident.
|
|
9183
9251
|
*/
|
|
9184
|
-
async
|
|
9252
|
+
async adoptWalkedHour(deviceId, profile, locationId, hour, paths) {
|
|
9185
9253
|
const key = segmentHourKey(deviceId, profile, locationId, hour);
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9254
|
+
return this.serialized(key, async () => {
|
|
9255
|
+
if (!this.ledger.has(key)) {
|
|
9256
|
+
if (paths.length === 0) return {
|
|
9257
|
+
created: false,
|
|
9258
|
+
added: 0,
|
|
9259
|
+
sealedNow: false
|
|
9260
|
+
};
|
|
9261
|
+
const row = segmentHourRow({
|
|
9262
|
+
key,
|
|
9263
|
+
deviceId,
|
|
9264
|
+
profile,
|
|
9265
|
+
locationId,
|
|
9266
|
+
hourStartMs: hour,
|
|
9267
|
+
paths: uniquePaths(paths),
|
|
9268
|
+
sealed: true
|
|
9269
|
+
});
|
|
9270
|
+
await this.ledger.put(row);
|
|
9271
|
+
this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
|
|
9272
|
+
tags: { deviceId },
|
|
9273
|
+
meta: {
|
|
9274
|
+
profile,
|
|
9275
|
+
locationId,
|
|
9276
|
+
hourStartMs: hour,
|
|
9277
|
+
segments: row.segments,
|
|
9278
|
+
bytes: row.bytes
|
|
9279
|
+
}
|
|
9280
|
+
});
|
|
9281
|
+
return {
|
|
9282
|
+
created: true,
|
|
9283
|
+
added: row.paths.length,
|
|
9284
|
+
sealedNow: true
|
|
9285
|
+
};
|
|
9286
|
+
}
|
|
9287
|
+
const held = await this.resident(key, deviceId);
|
|
9288
|
+
if (held === null) return null;
|
|
9289
|
+
const union = uniquePaths([...held.paths, ...paths]);
|
|
9290
|
+
const added = union.length - held.paths.length;
|
|
9291
|
+
if (added === 0 && held.sealed) return {
|
|
9292
|
+
created: false,
|
|
9293
|
+
added: 0,
|
|
9294
|
+
sealedNow: false
|
|
9295
|
+
};
|
|
9296
|
+
await this.ledger.put(segmentHourRow({
|
|
9297
|
+
...held,
|
|
9298
|
+
paths: union,
|
|
9299
|
+
sealed: true
|
|
9300
|
+
}));
|
|
9301
|
+
return {
|
|
9302
|
+
created: false,
|
|
9303
|
+
added,
|
|
9304
|
+
sealedNow: !held.sealed
|
|
9305
|
+
};
|
|
9194
9306
|
});
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
|
|
9201
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
9307
|
+
}
|
|
9308
|
+
/**
|
|
9309
|
+
* The writer of `(deviceId, profile)` stopped gracefully: seal the hour it
|
|
9310
|
+
* was appending to. One write, and only if the row has every finalize this
|
|
9311
|
+
* ledger was handed for it.
|
|
9312
|
+
*/
|
|
9313
|
+
async sealWriter(deviceId, profile) {
|
|
9314
|
+
const last = this.writerHours.get(writerKey(deviceId, profile));
|
|
9315
|
+
if (last === void 0) return;
|
|
9316
|
+
await this.sealHour(last);
|
|
9317
|
+
}
|
|
9318
|
+
/**
|
|
9319
|
+
* Every mutation queued so far has landed — including the seals a rollover
|
|
9320
|
+
* fires without waiting. Awaited at shutdown so the hour a writer just
|
|
9321
|
+
* closed is sealed before the process is gone.
|
|
9322
|
+
*/
|
|
9323
|
+
async flush() {
|
|
9324
|
+
while (this.lanes.size > 0) await Promise.all([...this.lanes.values()]);
|
|
9325
|
+
}
|
|
9326
|
+
/**
|
|
9327
|
+
* A finalize this ledger was NEVER handed — the fire-and-forget lane refused
|
|
9328
|
+
* it. The hour's row is short by that path and this process is the only
|
|
9329
|
+
* party that knows, so the hour must not be sealed by it; a reader walks it
|
|
9330
|
+
* once instead, which is exactly what an unsealed row is for.
|
|
9331
|
+
*/
|
|
9332
|
+
markRefused(row) {
|
|
9333
|
+
this.refused.add(hourOf(row).key);
|
|
9334
|
+
}
|
|
9335
|
+
/** One write: the row as held, flagged sealed. Serialised on its key so it
|
|
9336
|
+
* cannot land over an append that read the row before it. */
|
|
9337
|
+
async sealHour(target) {
|
|
9338
|
+
await this.serialized(target.key, async () => {
|
|
9339
|
+
if (this.refused.has(target.key)) {
|
|
9340
|
+
this.logger.warn("recorder: hour left unsealed — a ledger write for it was refused, so its row is short and a reader must walk it", {
|
|
9341
|
+
tags: { deviceId: target.deviceId },
|
|
9342
|
+
meta: {
|
|
9343
|
+
profile: target.profile,
|
|
9344
|
+
locationId: target.locationId,
|
|
9345
|
+
hourStartMs: target.hourStartMs
|
|
9346
|
+
}
|
|
9347
|
+
});
|
|
9348
|
+
return;
|
|
9204
9349
|
}
|
|
9350
|
+
const held = await this.resident(target.key, target.deviceId);
|
|
9351
|
+
if (held === null || held.sealed) return;
|
|
9352
|
+
await this.ledger.put({
|
|
9353
|
+
...held,
|
|
9354
|
+
sealed: true
|
|
9355
|
+
});
|
|
9356
|
+
});
|
|
9357
|
+
}
|
|
9358
|
+
/**
|
|
9359
|
+
* Remember which hour this writer is on; when it MOVES FORWARD, seal the
|
|
9360
|
+
* hour it left. Backwards is a late finalize (a recovered orphan, a replay)
|
|
9361
|
+
* into an hour already accounted for, and seals nothing.
|
|
9362
|
+
*/
|
|
9363
|
+
noteWriter(next) {
|
|
9364
|
+
const id = writerKey(next.deviceId, next.profile);
|
|
9365
|
+
const last = this.writerHours.get(id);
|
|
9366
|
+
if (last !== void 0 && last.key === next.key) return;
|
|
9367
|
+
if (last === void 0 || next.hourStartMs > last.hourStartMs) this.writerHours.set(id, next);
|
|
9368
|
+
if (last !== void 0 && next.hourStartMs > last.hourStartMs) this.sealHour(last);
|
|
9369
|
+
}
|
|
9370
|
+
/**
|
|
9371
|
+
* Run `fn` after every mutation already queued on `key`.
|
|
9372
|
+
*
|
|
9373
|
+
* A row is read, appended to and written back with awaits in between; two
|
|
9374
|
+
* such runs on one key interleave, and the second write carries a paths
|
|
9375
|
+
* array that never saw the first — the write-behind's own lost update. The
|
|
9376
|
+
* lane makes "read, append, write" one step per key. A rejected step must
|
|
9377
|
+
* not poison the lane: the next step still runs.
|
|
9378
|
+
*/
|
|
9379
|
+
serialized(key, fn) {
|
|
9380
|
+
const run = (this.lanes.get(key) ?? Promise.resolve()).then(fn, fn);
|
|
9381
|
+
const settled = run.then(() => void 0, () => void 0);
|
|
9382
|
+
this.lanes.set(key, settled);
|
|
9383
|
+
settled.then(() => {
|
|
9384
|
+
if (this.lanes.get(key) === settled) this.lanes.delete(key);
|
|
9205
9385
|
});
|
|
9386
|
+
return run;
|
|
9206
9387
|
}
|
|
9207
9388
|
/**
|
|
9208
9389
|
* Fetch whole rows for `keys` and promote them in the mirror.
|
|
@@ -9797,30 +9978,40 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
9797
9978
|
*/
|
|
9798
9979
|
async recordSegment(row) {
|
|
9799
9980
|
const { key, hourStartMs: hour } = hourOf(row);
|
|
9800
|
-
|
|
9801
|
-
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
|
|
9806
|
-
|
|
9807
|
-
|
|
9808
|
-
|
|
9809
|
-
|
|
9810
|
-
}
|
|
9811
|
-
const held = await this.resident(key, row.deviceId);
|
|
9812
|
-
if (held === null) {
|
|
9813
|
-
this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
|
|
9814
|
-
tags: { deviceId: row.deviceId },
|
|
9815
|
-
meta: {
|
|
9981
|
+
this.noteWriter({
|
|
9982
|
+
key,
|
|
9983
|
+
deviceId: row.deviceId,
|
|
9984
|
+
profile: row.profile,
|
|
9985
|
+
locationId: row.locationId,
|
|
9986
|
+
hourStartMs: hour
|
|
9987
|
+
});
|
|
9988
|
+
await this.serialized(key, async () => {
|
|
9989
|
+
if (!this.ledger.has(key)) {
|
|
9990
|
+
await this.ledger.put(segmentHourRow({
|
|
9816
9991
|
key,
|
|
9817
|
-
|
|
9818
|
-
|
|
9819
|
-
|
|
9820
|
-
|
|
9821
|
-
|
|
9822
|
-
|
|
9823
|
-
|
|
9992
|
+
deviceId: row.deviceId,
|
|
9993
|
+
profile: row.profile,
|
|
9994
|
+
locationId: row.locationId,
|
|
9995
|
+
hourStartMs: hour,
|
|
9996
|
+
paths: [row.path]
|
|
9997
|
+
}));
|
|
9998
|
+
return;
|
|
9999
|
+
}
|
|
10000
|
+
const held = await this.resident(key, row.deviceId);
|
|
10001
|
+
if (held === null) {
|
|
10002
|
+
this.refused.add(key);
|
|
10003
|
+
this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
|
|
10004
|
+
tags: { deviceId: row.deviceId },
|
|
10005
|
+
meta: {
|
|
10006
|
+
key,
|
|
10007
|
+
path: row.path
|
|
10008
|
+
}
|
|
10009
|
+
});
|
|
10010
|
+
return;
|
|
10011
|
+
}
|
|
10012
|
+
if (held.paths.includes(row.path)) return;
|
|
10013
|
+
await this.ledger.put(appendSegmentPath(held, row.path));
|
|
10014
|
+
});
|
|
9824
10015
|
}
|
|
9825
10016
|
/**
|
|
9826
10017
|
* Eviction confirmed these paths are gone from disk. Drop them; forget the
|
|
@@ -9996,7 +10187,8 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
9996
10187
|
profile: seg.profile,
|
|
9997
10188
|
locationId: seg.locationId,
|
|
9998
10189
|
hourStartMs: hour,
|
|
9999
|
-
paths: [seg.path]
|
|
10190
|
+
paths: [seg.path],
|
|
10191
|
+
sealed: hour !== currentHour
|
|
10000
10192
|
}));
|
|
10001
10193
|
else fromIndex.set(key, appendSegmentPath(existing, seg.path));
|
|
10002
10194
|
}
|
|
@@ -10517,10 +10709,16 @@ var GhostLocationSweeper = class {
|
|
|
10517
10709
|
};
|
|
10518
10710
|
Object.freeze({
|
|
10519
10711
|
"addons": { "getLogs": { "limit": 100 } },
|
|
10520
|
-
"audio-analyzer": {
|
|
10521
|
-
"
|
|
10522
|
-
|
|
10523
|
-
|
|
10712
|
+
"audio-analyzer": {
|
|
10713
|
+
"analyseChunk": { "settings": { "__nested__": {
|
|
10714
|
+
"minConfidence": .3,
|
|
10715
|
+
"allowedClasses": []
|
|
10716
|
+
} } },
|
|
10717
|
+
"attachDevice": { "settings": { "__nested__": {
|
|
10718
|
+
"minConfidence": .3,
|
|
10719
|
+
"allowedClasses": []
|
|
10720
|
+
} } }
|
|
10721
|
+
},
|
|
10524
10722
|
"audio-codec": {
|
|
10525
10723
|
"pullEncoded": { "maxCount": 8 },
|
|
10526
10724
|
"pullPcm": { "maxCount": 8 }
|
|
@@ -15921,6 +16119,17 @@ var RecordingController = class {
|
|
|
15921
16119
|
flushMs: Date.now() - teardownStartMs - writerStopMs
|
|
15922
16120
|
}
|
|
15923
16121
|
});
|
|
16122
|
+
try {
|
|
16123
|
+
await this.deps.onWriterClosed?.(deviceId, r.profile);
|
|
16124
|
+
} catch (err) {
|
|
16125
|
+
this.deps.logger.warn("recorder: sealing the closed writer’s hour failed — it stays unsealed", {
|
|
16126
|
+
tags: { deviceId },
|
|
16127
|
+
meta: {
|
|
16128
|
+
profile: r.profile,
|
|
16129
|
+
error: errMsg(err)
|
|
16130
|
+
}
|
|
16131
|
+
});
|
|
16132
|
+
}
|
|
15924
16133
|
try {
|
|
15925
16134
|
r.watcher.stop();
|
|
15926
16135
|
} catch {}
|
|
@@ -17437,10 +17646,13 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17437
17646
|
},
|
|
17438
17647
|
onIndexed: (row, absPath) => {
|
|
17439
17648
|
const ledger = this.segmentHours;
|
|
17440
|
-
if (ledger && !this.ledgerWriteGate.run(() => ledger.recordSegment(row)))
|
|
17441
|
-
|
|
17442
|
-
|
|
17443
|
-
|
|
17649
|
+
if (ledger && !this.ledgerWriteGate.run(() => ledger.recordSegment(row))) {
|
|
17650
|
+
ledger.markRefused(row);
|
|
17651
|
+
this.ctx.logger.debug("recorder: hour-ledger write dropped — lane saturated; the hour stays unsealed and a reader walks it", {
|
|
17652
|
+
tags: { deviceId: row.deviceId },
|
|
17653
|
+
meta: { path: row.path }
|
|
17654
|
+
});
|
|
17655
|
+
}
|
|
17444
17656
|
if (!isMfraTableServable(row.startMs, Date.now())) return;
|
|
17445
17657
|
if (!this.mfraCaptureGate.run(async () => {
|
|
17446
17658
|
try {
|
|
@@ -17568,6 +17780,9 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17568
17780
|
locations: () => this.resolvedLocations,
|
|
17569
17781
|
placeProfile: (deviceId, profile) => this.placeProfile(deviceId, profile),
|
|
17570
17782
|
onPlacementTick: (deviceIds) => this.recomputePlacement(deviceIds),
|
|
17783
|
+
onWriterClosed: async (deviceId, profile) => {
|
|
17784
|
+
await this.segmentHours?.sealWriter(deviceId, profile);
|
|
17785
|
+
},
|
|
17571
17786
|
spawn,
|
|
17572
17787
|
costRegistry: this.costRegistry,
|
|
17573
17788
|
segmentSeconds: this.config.segmentSeconds,
|
|
@@ -17637,7 +17852,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17637
17852
|
},
|
|
17638
17853
|
calendar: this.calendar,
|
|
17639
17854
|
mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
|
|
17640
|
-
hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0),
|
|
17855
|
+
hydrateWindow: (deviceId, fromMs, toMs, verifyClosedHours) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0, { verifyClosedHours: verifyClosedHours === true }),
|
|
17641
17856
|
onConfigChanged: async (deviceId) => {
|
|
17642
17857
|
await this.refreshSensorTriggerBindings();
|
|
17643
17858
|
await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());
|
|
@@ -17917,6 +18132,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17917
18132
|
this.locationRefresher?.dispose();
|
|
17918
18133
|
this.locationRefresher = null;
|
|
17919
18134
|
await this.controller?.stop();
|
|
18135
|
+
await this.segmentHours?.flush();
|
|
17920
18136
|
this.controller = null;
|
|
17921
18137
|
this.segmentStore = null;
|
|
17922
18138
|
if (this.exportSweepTimer !== null) {
|