@camstack/addon-pipeline 1.2.218 → 1.2.220
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 +49 -5
- package/dist/audio-analyzer/index.mjs +46 -3
- package/dist/chunk-emK7D4bc.js +35 -0
- package/dist/{default-detection-model-AJyhoTLF.mjs → default-detection-model-DBNEc3iw.mjs} +1 -1
- package/dist/{default-detection-model-Bf45EQ98.js → default-detection-model-x6oNMBDH.js} +1 -1
- package/dist/detection-pipeline/index.js +10 -9
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-BbvtiBNc.js → dist-ByPsWlTa.js} +57 -38
- package/dist/{dist-Dl6GntEh.mjs → dist-ClihWGOZ.mjs} +57 -3
- package/dist/{lazy-sharp-L9Lcr62v.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-CbCVHEvJ.js → node-CNPnaux4.js} +4 -3
- package/dist/{node-CEyw52D2.mjs → node-DiaTmz84.mjs} +1 -1
- package/dist/pipeline-runner/index.js +222 -157
- package/dist/pipeline-runner/index.mjs +218 -154
- package/dist/{process-memory-DBxdiCSF.js → process-memory-9KqRtUHt.js} +3 -2
- package/dist/{process-memory-Du00R6-e.mjs → process-memory-D4t8hpJd.mjs} +1 -1
- package/dist/recorder/index.js +334 -123
- package/dist/recorder/index.mjs +332 -122
- package/dist/{segment-demux-js-B278aBtB.mjs → segment-demux-js-CFFXVae4.mjs} +1 -1
- package/dist/{segment-demux-js-D7bjzY8M.js → segment-demux-js-CZefXDLx.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 +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BnmbfnAd.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CtdpN7Sz.mjs} +3 -3
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-yJ2CyixO.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-7hqrrd88.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-MIKQqs86.mjs → hostInit-McoUiljf.mjs} +3 -3
- package/dist/stream-broker/index.js +24 -25
- package/dist/stream-broker/index.mjs +17 -19
- 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/worker-protocol-BEbD5adq.mjs +0 -264
- package/dist/worker-protocol-C4rh5uiS.js +0 -299
package/dist/recorder/index.js
CHANGED
|
@@ -2,15 +2,16 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const
|
|
6
|
-
const
|
|
5
|
+
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
6
|
+
const require_dist = require("../dist-ByPsWlTa.js");
|
|
7
|
+
const require_node = require("../node-CNPnaux4.js");
|
|
7
8
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
8
9
|
const require_restream_intent = require("../restream-intent-B1Difcnq.js");
|
|
9
10
|
const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
|
|
10
11
|
let node_crypto = require("node:crypto");
|
|
11
12
|
let node_fs = require("node:fs");
|
|
12
13
|
let node_path = require("node:path");
|
|
13
|
-
node_path =
|
|
14
|
+
node_path = require_chunk.__toESM(node_path);
|
|
14
15
|
let node_child_process = require("node:child_process");
|
|
15
16
|
let node_fs_promises = require("node:fs/promises");
|
|
16
17
|
let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
|
|
@@ -1773,51 +1774,20 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
|
1773
1774
|
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
1774
1775
|
}
|
|
1775
1776
|
var HOUR_MS$7 = 36e5;
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
* `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
|
|
1780
|
-
* subtree — ">1M files and takes minutes" per its own comment. Until it lands,
|
|
1781
|
-
* every read of past footage sees an empty index, which the server reports as
|
|
1782
|
-
* "no footage": measured after a deploy, a camera-day held 7 segments and grew
|
|
1783
|
-
* only at the rate of NEW writes for ~9 minutes.
|
|
1784
|
-
*
|
|
1785
|
-
* The path convention makes the window addressable directly: one SHALLOW
|
|
1786
|
-
* `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
|
|
1787
|
-
* background full walk still runs, for the consumers that need the whole
|
|
1788
|
-
* archive (footprint, eviction domains, retention).
|
|
1789
|
-
*
|
|
1790
|
-
* Hours already walked are skipped, so a drag across an hour costs nothing the
|
|
1791
|
-
* second time.
|
|
1792
|
-
*
|
|
1793
|
-
* ## The ledger is the first stop for a CLOSED hour (D287)
|
|
1794
|
-
*
|
|
1795
|
-
* Boot no longer replays the archive into the index, so a ledger-known hour
|
|
1796
|
-
* arrives here `unknown` and pays ONE point read of its hour row instead of a
|
|
1797
|
-
* `readdir` — measured at ~650 ms per hour directory on the live share under
|
|
1798
|
-
* load, against the array the scrub feeder is competing for. Hours the ledger
|
|
1799
|
-
* does not know, and every hour if the read fails, fall through to the walk
|
|
1800
|
-
* exactly as before: the failure direction is toward the disk, never toward an
|
|
1801
|
-
* hour that renders empty because nobody read it.
|
|
1802
|
-
*
|
|
1803
|
-
* **The live hour is never served from the ledger.** It is the one hour whose
|
|
1804
|
-
* durable row is knowably behind the disk: a segment finalized after the last
|
|
1805
|
-
* persist and before a crash is on the array and not in the row. Boot used to
|
|
1806
|
-
* mark every ledger hour `hydrated`, which made that tail invisible to
|
|
1807
|
-
* playback and the timeline FOREVER, not merely under-reported. Excluding the
|
|
1808
|
-
* live hour costs exactly one hour-directory read per process — the walk marks
|
|
1809
|
-
* it, so the second reader pays nothing — and it is what turns a lost tail
|
|
1810
|
-
* back into a recoverable one.
|
|
1811
|
-
*
|
|
1812
|
-
* A CLOSED hour already walked is skipped, which is what makes that exclusion
|
|
1813
|
-
* affordable: without it, every window touching the live hour would re-walk
|
|
1814
|
-
* all of its hours. The past is immutable, so re-reading a closed hour
|
|
1815
|
-
* directory can only ever return what the index already holds; eviction
|
|
1816
|
-
* removes rows through the index directly.
|
|
1817
|
-
*/
|
|
1818
|
-
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
|
|
1819
|
-
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1777
|
+
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
|
|
1778
|
+
const verify = options?.verifyClosedHours === true;
|
|
1779
|
+
if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1820
1780
|
const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
|
|
1781
|
+
/** Per (locationId, profile, hour): what the LEDGER claimed, for the diff. */
|
|
1782
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
1783
|
+
const claimKey = (locationId, hour) => `${locationId}:${hour}`;
|
|
1784
|
+
/**
|
|
1785
|
+
* Closed hours the ledger answered for with a row nobody SEALED (D451): what
|
|
1786
|
+
* a writer had persisted when it was last heard from, which is a legitimate
|
|
1787
|
+
* answer and not a complete one. Each is read from the array once and the
|
|
1788
|
+
* row is unioned and sealed, so the second reader pays a point read.
|
|
1789
|
+
*/
|
|
1790
|
+
const unsealed = /* @__PURE__ */ new Set();
|
|
1821
1791
|
if (ledger !== void 0) {
|
|
1822
1792
|
const startedMs = Date.now();
|
|
1823
1793
|
const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
|
|
@@ -1826,6 +1796,8 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1826
1796
|
for (const hour of hours) {
|
|
1827
1797
|
if (hour.hourStartMs >= liveHour) continue;
|
|
1828
1798
|
index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
1799
|
+
claimed.set(claimKey(hour.locationId, hour.hourStartMs), hour.paths.length);
|
|
1800
|
+
if (!hour.sealed) unsealed.add(hour.hourStartMs);
|
|
1829
1801
|
segments += hour.paths.length;
|
|
1830
1802
|
served += 1;
|
|
1831
1803
|
}
|
|
@@ -1834,10 +1806,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1834
1806
|
meta: {
|
|
1835
1807
|
hours: served,
|
|
1836
1808
|
segments,
|
|
1809
|
+
unsealedHours: unsealed.size,
|
|
1837
1810
|
ms: Date.now() - startedMs
|
|
1838
1811
|
}
|
|
1839
1812
|
});
|
|
1840
|
-
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1813
|
+
if (!verify && unsealed.size === 0 && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1841
1814
|
}
|
|
1842
1815
|
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
1843
1816
|
const deviceDir = node_path.default.join(root, String(deviceId));
|
|
@@ -1858,8 +1831,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1858
1831
|
const reuseBefore = index.hydrateReuseStats();
|
|
1859
1832
|
let hoursRead = 0;
|
|
1860
1833
|
let repaired = 0;
|
|
1834
|
+
let forgottenSegments = 0;
|
|
1835
|
+
let forgottenHours = 0;
|
|
1836
|
+
let sealed = 0;
|
|
1861
1837
|
for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
|
|
1862
|
-
if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
|
|
1838
|
+
if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
|
|
1863
1839
|
for (const profile of profiles) {
|
|
1864
1840
|
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
1865
1841
|
let files;
|
|
@@ -1872,13 +1848,35 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1872
1848
|
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
1873
1849
|
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
1874
1850
|
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
1875
|
-
if (ledger
|
|
1876
|
-
|
|
1877
|
-
|
|
1851
|
+
if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$7 > Date.now()) continue;
|
|
1852
|
+
const claimedHere = claimed.get(claimKey(locationId, hour));
|
|
1853
|
+
const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
|
|
1854
|
+
if (outcome === null) continue;
|
|
1855
|
+
if (outcome.created) repaired += 1;
|
|
1856
|
+
else if (claimedHere !== void 0 && outcome.added > 0) {
|
|
1857
|
+
forgottenSegments += outcome.added;
|
|
1858
|
+
forgottenHours += 1;
|
|
1878
1859
|
}
|
|
1860
|
+
if (outcome.sealedNow) sealed += 1;
|
|
1879
1861
|
}
|
|
1880
1862
|
}
|
|
1881
1863
|
index.markHydrated(deviceId, fromMs, toMs);
|
|
1864
|
+
if (verify || forgottenSegments > 0) {
|
|
1865
|
+
const message = "recorder: verified the hour ledger against the array";
|
|
1866
|
+
const entry = {
|
|
1867
|
+
tags: { deviceId },
|
|
1868
|
+
meta: {
|
|
1869
|
+
root,
|
|
1870
|
+
hoursRead,
|
|
1871
|
+
forgottenSegments,
|
|
1872
|
+
forgottenHours,
|
|
1873
|
+
sealed,
|
|
1874
|
+
verify
|
|
1875
|
+
}
|
|
1876
|
+
};
|
|
1877
|
+
if (forgottenSegments > 0) logger.warn(message, entry);
|
|
1878
|
+
else logger.info(message, entry);
|
|
1879
|
+
}
|
|
1882
1880
|
if (hoursRead === 0) continue;
|
|
1883
1881
|
const reuseAfter = index.hydrateReuseStats();
|
|
1884
1882
|
const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
|
|
@@ -1889,6 +1887,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1889
1887
|
root,
|
|
1890
1888
|
hoursRead,
|
|
1891
1889
|
repaired,
|
|
1890
|
+
sealed,
|
|
1892
1891
|
reused,
|
|
1893
1892
|
allocated
|
|
1894
1893
|
}
|
|
@@ -7154,7 +7153,8 @@ async function probeVariantCodec(readPrefix, cache, deviceId, profile, firstPres
|
|
|
7154
7153
|
* checks every one, playback does not. See the comment at the branch.
|
|
7155
7154
|
*/
|
|
7156
7155
|
async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMode) {
|
|
7157
|
-
|
|
7156
|
+
const verifyClosedHours = uriMode === "absolute";
|
|
7157
|
+
if (verifyClosedHours || deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs, verifyClosedHours);
|
|
7158
7158
|
const hydration = deps.index.hydrationOf(deviceId, fromMs, toMs);
|
|
7159
7159
|
const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
|
|
7160
7160
|
const candidates = deps.index.segmentsOverlapping(deviceId, profile, fromMs, toMs).flatMap((s) => {
|
|
@@ -8534,7 +8534,7 @@ var macrotask$1 = () => new Promise((resolve) => {
|
|
|
8534
8534
|
});
|
|
8535
8535
|
/**
|
|
8536
8536
|
* @durable class=mirror owner=recorder
|
|
8537
|
-
* write="write-behind on every finalized segment (hour-row upsert);
|
|
8537
|
+
* 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"
|
|
8538
8538
|
* retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
|
|
8539
8539
|
*/
|
|
8540
8540
|
var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
|
|
@@ -8603,7 +8603,17 @@ var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
|
8603
8603
|
type: "INTEGER",
|
|
8604
8604
|
notNull: true,
|
|
8605
8605
|
defaultValue: 0
|
|
8606
|
-
}
|
|
8606
|
+
},
|
|
8607
|
+
(
|
|
8608
|
+
/** {@link SegmentHourRow.sealed}, as 0/1. A row written before the column
|
|
8609
|
+
* existed reads 0 — unsealed — which is the direction that costs one
|
|
8610
|
+
* directory read, never a missing hour. */
|
|
8611
|
+
{
|
|
8612
|
+
name: "sealed",
|
|
8613
|
+
type: "INTEGER",
|
|
8614
|
+
notNull: true,
|
|
8615
|
+
defaultValue: 0
|
|
8616
|
+
})
|
|
8607
8617
|
];
|
|
8608
8618
|
var RECORDING_SEGMENT_HOURS_INDEXES = [
|
|
8609
8619
|
{
|
|
@@ -8621,6 +8631,32 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
|
|
|
8621
8631
|
{
|
|
8622
8632
|
name: "idx_recorder_segment_hours_loc_hour",
|
|
8623
8633
|
columns: ["locationId", "hourStartMs"]
|
|
8634
|
+
}),
|
|
8635
|
+
(
|
|
8636
|
+
/**
|
|
8637
|
+
* The accounting read: `SUM(bytes)`, `SUM(segments)`, `MIN(minStartMs)`,
|
|
8638
|
+
* `MAX(maxStartMs)` over `locationId IN (…)` — one row back, and with this
|
|
8639
|
+
* index not one table row read to produce it (D447).
|
|
8640
|
+
*
|
|
8641
|
+
* `idx_recorder_segment_hours_loc_hour` finds the same range, but the four
|
|
8642
|
+
* aggregates live in the table row, next to `paths` — up to 360 segment
|
|
8643
|
+
* paths of ~80 characters per hour — so the eviction index served the
|
|
8644
|
+
* accounting read at the price of ~30 KB of table page per hour row. On the
|
|
8645
|
+
* live hub that was `durationMs=1001…2902` once a MINUTE on
|
|
8646
|
+
* `hub/sqlite-settings` (174 slow-call WARNs in ten hours on 2026-09-10/11),
|
|
8647
|
+
* every one of them holding the thread every configuration read in the
|
|
8648
|
+
* cluster waits on. The index is ~50 bytes a row over ~19 k rows — nothing —
|
|
8649
|
+
* and the first declare after it ships builds it in well under a second.
|
|
8650
|
+
*/
|
|
8651
|
+
{
|
|
8652
|
+
name: "idx_recorder_segment_hours_loc_accounting",
|
|
8653
|
+
columns: [
|
|
8654
|
+
"locationId",
|
|
8655
|
+
"bytes",
|
|
8656
|
+
"segments",
|
|
8657
|
+
"minStartMs",
|
|
8658
|
+
"maxStartMs"
|
|
8659
|
+
]
|
|
8624
8660
|
})
|
|
8625
8661
|
];
|
|
8626
8662
|
/** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
|
|
@@ -8679,6 +8715,7 @@ function hourAggregate(paths) {
|
|
|
8679
8715
|
function segmentHourRow(base) {
|
|
8680
8716
|
return {
|
|
8681
8717
|
...base,
|
|
8718
|
+
sealed: base.sealed ?? false,
|
|
8682
8719
|
pathsResident: true,
|
|
8683
8720
|
...hourAggregate(base.paths)
|
|
8684
8721
|
};
|
|
@@ -8791,7 +8828,8 @@ var SEGMENT_HOUR_SUMMARY_COLUMNS = [
|
|
|
8791
8828
|
"bytes",
|
|
8792
8829
|
"segments",
|
|
8793
8830
|
"minStartMs",
|
|
8794
|
-
"maxStartMs"
|
|
8831
|
+
"maxStartMs",
|
|
8832
|
+
"sealed"
|
|
8795
8833
|
];
|
|
8796
8834
|
/** Summary rows folded into the mirror between yields. */
|
|
8797
8835
|
var LOAD_CHUNK = 5e3;
|
|
@@ -8853,6 +8891,19 @@ function hourOf(row) {
|
|
|
8853
8891
|
key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
|
|
8854
8892
|
};
|
|
8855
8893
|
}
|
|
8894
|
+
/** The identity of one writer: a camera's profile. Location is not part of it —
|
|
8895
|
+
* a relocated writer is the same writer on a new root. */
|
|
8896
|
+
function writerKey(deviceId, profile) {
|
|
8897
|
+
return `${deviceId}|${profile}`;
|
|
8898
|
+
}
|
|
8899
|
+
function detailOf(row) {
|
|
8900
|
+
return {
|
|
8901
|
+
locationId: row.locationId,
|
|
8902
|
+
hourStartMs: row.hourStartMs,
|
|
8903
|
+
paths: row.paths,
|
|
8904
|
+
sealed: row.sealed
|
|
8905
|
+
};
|
|
8906
|
+
}
|
|
8856
8907
|
/**
|
|
8857
8908
|
* The `hourStartMs` of one probe record.
|
|
8858
8909
|
*
|
|
@@ -8890,9 +8941,15 @@ function rowToValue(row) {
|
|
|
8890
8941
|
bytes: row.bytes,
|
|
8891
8942
|
segments: row.segments,
|
|
8892
8943
|
minStartMs: row.minStartMs,
|
|
8893
|
-
maxStartMs: row.maxStartMs
|
|
8944
|
+
maxStartMs: row.maxStartMs,
|
|
8945
|
+
sealed: row.sealed ? 1 : 0
|
|
8894
8946
|
};
|
|
8895
8947
|
}
|
|
8948
|
+
/** The stored 0/1 (or a boolean from a fake) as the flag. Anything else —
|
|
8949
|
+
* including the absence a pre-D451 row shows — is unsealed. */
|
|
8950
|
+
function sealedOf(raw) {
|
|
8951
|
+
return raw === 1 || raw === true;
|
|
8952
|
+
}
|
|
8896
8953
|
function recordToRow(key, data) {
|
|
8897
8954
|
const deviceId = Number(data["deviceId"]);
|
|
8898
8955
|
const profile = data["profile"];
|
|
@@ -8911,7 +8968,8 @@ function recordToRow(key, data) {
|
|
|
8911
8968
|
profile,
|
|
8912
8969
|
locationId,
|
|
8913
8970
|
hourStartMs: hour,
|
|
8914
|
-
paths: raw
|
|
8971
|
+
paths: raw,
|
|
8972
|
+
sealed: sealedOf(data["sealed"])
|
|
8915
8973
|
});
|
|
8916
8974
|
}
|
|
8917
8975
|
/** The paths of a summary row: one shared empty array, never a per-row
|
|
@@ -8946,6 +9004,7 @@ function summaryToRow(key, data) {
|
|
|
8946
9004
|
hourStartMs: hour,
|
|
8947
9005
|
paths: NO_PATHS,
|
|
8948
9006
|
pathsResident: false,
|
|
9007
|
+
sealed: sealedOf(data["sealed"]),
|
|
8949
9008
|
bytes: finiteOr0(data["bytes"]),
|
|
8950
9009
|
segments: finiteOr0(data["segments"]),
|
|
8951
9010
|
minStartMs: finiteOr0(data["minStartMs"]),
|
|
@@ -8976,6 +9035,16 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
8976
9035
|
loadChunk;
|
|
8977
9036
|
yieldBetween;
|
|
8978
9037
|
rowCap;
|
|
9038
|
+
/**
|
|
9039
|
+
* One lane per hour key: every mutation of a row runs after the previous one
|
|
9040
|
+
* on the same key has finished. Two appends that both read the row and both
|
|
9041
|
+
* wrote it back used to keep only the second — the sparse rows D451 found.
|
|
9042
|
+
*/
|
|
9043
|
+
lanes = /* @__PURE__ */ new Map();
|
|
9044
|
+
/** Per (device, profile): the hour its writer last appended to. */
|
|
9045
|
+
writerHours = /* @__PURE__ */ new Map();
|
|
9046
|
+
/** Hour keys a write was REFUSED for in this process — never sealed by it. */
|
|
9047
|
+
refused = /* @__PURE__ */ new Set();
|
|
8979
9048
|
constructor(deps) {
|
|
8980
9049
|
this.logger = deps.logger;
|
|
8981
9050
|
this.store = deps.store;
|
|
@@ -9158,59 +9227,172 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
9158
9227
|
if (row.deviceId !== deviceId) continue;
|
|
9159
9228
|
if (row.hourStartMs < first || row.hourStartMs >= toMs) continue;
|
|
9160
9229
|
if (row.pathsResident) {
|
|
9161
|
-
out.push(
|
|
9162
|
-
locationId: row.locationId,
|
|
9163
|
-
hourStartMs: row.hourStartMs,
|
|
9164
|
-
paths: row.paths
|
|
9165
|
-
});
|
|
9230
|
+
out.push(detailOf(row));
|
|
9166
9231
|
continue;
|
|
9167
9232
|
}
|
|
9168
9233
|
wanted.push(row.key);
|
|
9169
9234
|
}
|
|
9170
|
-
for (const row of await this.materialize(wanted, deviceId)) out.push(
|
|
9171
|
-
locationId: row.locationId,
|
|
9172
|
-
hourStartMs: row.hourStartMs,
|
|
9173
|
-
paths: row.paths
|
|
9174
|
-
});
|
|
9235
|
+
for (const row of await this.materialize(wanted, deviceId)) out.push(detailOf(row));
|
|
9175
9236
|
return out;
|
|
9176
9237
|
}
|
|
9177
9238
|
/**
|
|
9178
|
-
*
|
|
9239
|
+
* Union a CLOSED hour the storage walk read into its row, and SEAL it.
|
|
9179
9240
|
*
|
|
9180
9241
|
* The reader repairs the durable index it just reconstructed, so the next
|
|
9181
9242
|
* cold read of this hour is a point read instead of another `readdir`
|
|
9182
|
-
* (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2)
|
|
9183
|
-
*
|
|
9184
|
-
*
|
|
9185
|
-
*
|
|
9186
|
-
*
|
|
9187
|
-
*
|
|
9188
|
-
* D148
|
|
9243
|
+
* (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2), and it
|
|
9244
|
+
* seals the row because a directory read of a closed hour is the one
|
|
9245
|
+
* complete answer there is. The caller guarantees the hour is CLOSED — a
|
|
9246
|
+
* live hour's directory races its writer, so a walk of it is a lower bound.
|
|
9247
|
+
*
|
|
9248
|
+
* A row the ledger already holds is UNIONED, never replaced: the walk can
|
|
9249
|
+
* only add (D148) — a path the row names and the directory lacks stays,
|
|
9250
|
+
* because "the reader did not see it" is not evidence it is gone. This is
|
|
9251
|
+
* the repair D445 described and did not perform: its predecessor returned
|
|
9252
|
+
* on any known key, so a row known INCOMPLETELY — the only kind that needs
|
|
9253
|
+
* repairing — was exactly the row it refused to touch, and the export's
|
|
9254
|
+
* `forgottenSegments=9758` was counted again the next morning.
|
|
9255
|
+
*
|
|
9256
|
+
* `null` when the row is known and its detail could not be read: nothing
|
|
9257
|
+
* is written from a row whose paths are not resident.
|
|
9189
9258
|
*/
|
|
9190
|
-
async
|
|
9259
|
+
async adoptWalkedHour(deviceId, profile, locationId, hour, paths) {
|
|
9191
9260
|
const key = segmentHourKey(deviceId, profile, locationId, hour);
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
9261
|
+
return this.serialized(key, async () => {
|
|
9262
|
+
if (!this.ledger.has(key)) {
|
|
9263
|
+
if (paths.length === 0) return {
|
|
9264
|
+
created: false,
|
|
9265
|
+
added: 0,
|
|
9266
|
+
sealedNow: false
|
|
9267
|
+
};
|
|
9268
|
+
const row = segmentHourRow({
|
|
9269
|
+
key,
|
|
9270
|
+
deviceId,
|
|
9271
|
+
profile,
|
|
9272
|
+
locationId,
|
|
9273
|
+
hourStartMs: hour,
|
|
9274
|
+
paths: uniquePaths(paths),
|
|
9275
|
+
sealed: true
|
|
9276
|
+
});
|
|
9277
|
+
await this.ledger.put(row);
|
|
9278
|
+
this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
|
|
9279
|
+
tags: { deviceId },
|
|
9280
|
+
meta: {
|
|
9281
|
+
profile,
|
|
9282
|
+
locationId,
|
|
9283
|
+
hourStartMs: hour,
|
|
9284
|
+
segments: row.segments,
|
|
9285
|
+
bytes: row.bytes
|
|
9286
|
+
}
|
|
9287
|
+
});
|
|
9288
|
+
return {
|
|
9289
|
+
created: true,
|
|
9290
|
+
added: row.paths.length,
|
|
9291
|
+
sealedNow: true
|
|
9292
|
+
};
|
|
9293
|
+
}
|
|
9294
|
+
const held = await this.resident(key, deviceId);
|
|
9295
|
+
if (held === null) return null;
|
|
9296
|
+
const union = uniquePaths([...held.paths, ...paths]);
|
|
9297
|
+
const added = union.length - held.paths.length;
|
|
9298
|
+
if (added === 0 && held.sealed) return {
|
|
9299
|
+
created: false,
|
|
9300
|
+
added: 0,
|
|
9301
|
+
sealedNow: false
|
|
9302
|
+
};
|
|
9303
|
+
await this.ledger.put(segmentHourRow({
|
|
9304
|
+
...held,
|
|
9305
|
+
paths: union,
|
|
9306
|
+
sealed: true
|
|
9307
|
+
}));
|
|
9308
|
+
return {
|
|
9309
|
+
created: false,
|
|
9310
|
+
added,
|
|
9311
|
+
sealedNow: !held.sealed
|
|
9312
|
+
};
|
|
9200
9313
|
});
|
|
9201
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
9204
|
-
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
|
|
9314
|
+
}
|
|
9315
|
+
/**
|
|
9316
|
+
* The writer of `(deviceId, profile)` stopped gracefully: seal the hour it
|
|
9317
|
+
* was appending to. One write, and only if the row has every finalize this
|
|
9318
|
+
* ledger was handed for it.
|
|
9319
|
+
*/
|
|
9320
|
+
async sealWriter(deviceId, profile) {
|
|
9321
|
+
const last = this.writerHours.get(writerKey(deviceId, profile));
|
|
9322
|
+
if (last === void 0) return;
|
|
9323
|
+
await this.sealHour(last);
|
|
9324
|
+
}
|
|
9325
|
+
/**
|
|
9326
|
+
* Every mutation queued so far has landed — including the seals a rollover
|
|
9327
|
+
* fires without waiting. Awaited at shutdown so the hour a writer just
|
|
9328
|
+
* closed is sealed before the process is gone.
|
|
9329
|
+
*/
|
|
9330
|
+
async flush() {
|
|
9331
|
+
while (this.lanes.size > 0) await Promise.all([...this.lanes.values()]);
|
|
9332
|
+
}
|
|
9333
|
+
/**
|
|
9334
|
+
* A finalize this ledger was NEVER handed — the fire-and-forget lane refused
|
|
9335
|
+
* it. The hour's row is short by that path and this process is the only
|
|
9336
|
+
* party that knows, so the hour must not be sealed by it; a reader walks it
|
|
9337
|
+
* once instead, which is exactly what an unsealed row is for.
|
|
9338
|
+
*/
|
|
9339
|
+
markRefused(row) {
|
|
9340
|
+
this.refused.add(hourOf(row).key);
|
|
9341
|
+
}
|
|
9342
|
+
/** One write: the row as held, flagged sealed. Serialised on its key so it
|
|
9343
|
+
* cannot land over an append that read the row before it. */
|
|
9344
|
+
async sealHour(target) {
|
|
9345
|
+
await this.serialized(target.key, async () => {
|
|
9346
|
+
if (this.refused.has(target.key)) {
|
|
9347
|
+
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", {
|
|
9348
|
+
tags: { deviceId: target.deviceId },
|
|
9349
|
+
meta: {
|
|
9350
|
+
profile: target.profile,
|
|
9351
|
+
locationId: target.locationId,
|
|
9352
|
+
hourStartMs: target.hourStartMs
|
|
9353
|
+
}
|
|
9354
|
+
});
|
|
9355
|
+
return;
|
|
9210
9356
|
}
|
|
9357
|
+
const held = await this.resident(target.key, target.deviceId);
|
|
9358
|
+
if (held === null || held.sealed) return;
|
|
9359
|
+
await this.ledger.put({
|
|
9360
|
+
...held,
|
|
9361
|
+
sealed: true
|
|
9362
|
+
});
|
|
9211
9363
|
});
|
|
9212
9364
|
}
|
|
9213
9365
|
/**
|
|
9366
|
+
* Remember which hour this writer is on; when it MOVES FORWARD, seal the
|
|
9367
|
+
* hour it left. Backwards is a late finalize (a recovered orphan, a replay)
|
|
9368
|
+
* into an hour already accounted for, and seals nothing.
|
|
9369
|
+
*/
|
|
9370
|
+
noteWriter(next) {
|
|
9371
|
+
const id = writerKey(next.deviceId, next.profile);
|
|
9372
|
+
const last = this.writerHours.get(id);
|
|
9373
|
+
if (last !== void 0 && last.key === next.key) return;
|
|
9374
|
+
if (last === void 0 || next.hourStartMs > last.hourStartMs) this.writerHours.set(id, next);
|
|
9375
|
+
if (last !== void 0 && next.hourStartMs > last.hourStartMs) this.sealHour(last);
|
|
9376
|
+
}
|
|
9377
|
+
/**
|
|
9378
|
+
* Run `fn` after every mutation already queued on `key`.
|
|
9379
|
+
*
|
|
9380
|
+
* A row is read, appended to and written back with awaits in between; two
|
|
9381
|
+
* such runs on one key interleave, and the second write carries a paths
|
|
9382
|
+
* array that never saw the first — the write-behind's own lost update. The
|
|
9383
|
+
* lane makes "read, append, write" one step per key. A rejected step must
|
|
9384
|
+
* not poison the lane: the next step still runs.
|
|
9385
|
+
*/
|
|
9386
|
+
serialized(key, fn) {
|
|
9387
|
+
const run = (this.lanes.get(key) ?? Promise.resolve()).then(fn, fn);
|
|
9388
|
+
const settled = run.then(() => void 0, () => void 0);
|
|
9389
|
+
this.lanes.set(key, settled);
|
|
9390
|
+
settled.then(() => {
|
|
9391
|
+
if (this.lanes.get(key) === settled) this.lanes.delete(key);
|
|
9392
|
+
});
|
|
9393
|
+
return run;
|
|
9394
|
+
}
|
|
9395
|
+
/**
|
|
9214
9396
|
* Fetch whole rows for `keys` and promote them in the mirror.
|
|
9215
9397
|
*
|
|
9216
9398
|
* Paged and yielding: an export window can name thousands of hours, and one
|
|
@@ -9803,30 +9985,40 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
9803
9985
|
*/
|
|
9804
9986
|
async recordSegment(row) {
|
|
9805
9987
|
const { key, hourStartMs: hour } = hourOf(row);
|
|
9806
|
-
|
|
9807
|
-
|
|
9808
|
-
|
|
9809
|
-
|
|
9810
|
-
|
|
9811
|
-
|
|
9812
|
-
|
|
9813
|
-
|
|
9814
|
-
|
|
9815
|
-
|
|
9816
|
-
}
|
|
9817
|
-
const held = await this.resident(key, row.deviceId);
|
|
9818
|
-
if (held === null) {
|
|
9819
|
-
this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
|
|
9820
|
-
tags: { deviceId: row.deviceId },
|
|
9821
|
-
meta: {
|
|
9988
|
+
this.noteWriter({
|
|
9989
|
+
key,
|
|
9990
|
+
deviceId: row.deviceId,
|
|
9991
|
+
profile: row.profile,
|
|
9992
|
+
locationId: row.locationId,
|
|
9993
|
+
hourStartMs: hour
|
|
9994
|
+
});
|
|
9995
|
+
await this.serialized(key, async () => {
|
|
9996
|
+
if (!this.ledger.has(key)) {
|
|
9997
|
+
await this.ledger.put(segmentHourRow({
|
|
9822
9998
|
key,
|
|
9823
|
-
|
|
9824
|
-
|
|
9825
|
-
|
|
9826
|
-
|
|
9827
|
-
|
|
9828
|
-
|
|
9829
|
-
|
|
9999
|
+
deviceId: row.deviceId,
|
|
10000
|
+
profile: row.profile,
|
|
10001
|
+
locationId: row.locationId,
|
|
10002
|
+
hourStartMs: hour,
|
|
10003
|
+
paths: [row.path]
|
|
10004
|
+
}));
|
|
10005
|
+
return;
|
|
10006
|
+
}
|
|
10007
|
+
const held = await this.resident(key, row.deviceId);
|
|
10008
|
+
if (held === null) {
|
|
10009
|
+
this.refused.add(key);
|
|
10010
|
+
this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
|
|
10011
|
+
tags: { deviceId: row.deviceId },
|
|
10012
|
+
meta: {
|
|
10013
|
+
key,
|
|
10014
|
+
path: row.path
|
|
10015
|
+
}
|
|
10016
|
+
});
|
|
10017
|
+
return;
|
|
10018
|
+
}
|
|
10019
|
+
if (held.paths.includes(row.path)) return;
|
|
10020
|
+
await this.ledger.put(appendSegmentPath(held, row.path));
|
|
10021
|
+
});
|
|
9830
10022
|
}
|
|
9831
10023
|
/**
|
|
9832
10024
|
* Eviction confirmed these paths are gone from disk. Drop them; forget the
|
|
@@ -10002,7 +10194,8 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
10002
10194
|
profile: seg.profile,
|
|
10003
10195
|
locationId: seg.locationId,
|
|
10004
10196
|
hourStartMs: hour,
|
|
10005
|
-
paths: [seg.path]
|
|
10197
|
+
paths: [seg.path],
|
|
10198
|
+
sealed: hour !== currentHour
|
|
10006
10199
|
}));
|
|
10007
10200
|
else fromIndex.set(key, appendSegmentPath(existing, seg.path));
|
|
10008
10201
|
}
|
|
@@ -15927,6 +16120,17 @@ var RecordingController = class {
|
|
|
15927
16120
|
flushMs: Date.now() - teardownStartMs - writerStopMs
|
|
15928
16121
|
}
|
|
15929
16122
|
});
|
|
16123
|
+
try {
|
|
16124
|
+
await this.deps.onWriterClosed?.(deviceId, r.profile);
|
|
16125
|
+
} catch (err) {
|
|
16126
|
+
this.deps.logger.warn("recorder: sealing the closed writer’s hour failed — it stays unsealed", {
|
|
16127
|
+
tags: { deviceId },
|
|
16128
|
+
meta: {
|
|
16129
|
+
profile: r.profile,
|
|
16130
|
+
error: require_dist.errMsg(err)
|
|
16131
|
+
}
|
|
16132
|
+
});
|
|
16133
|
+
}
|
|
15930
16134
|
try {
|
|
15931
16135
|
r.watcher.stop();
|
|
15932
16136
|
} catch {}
|
|
@@ -17443,10 +17647,13 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
17443
17647
|
},
|
|
17444
17648
|
onIndexed: (row, absPath) => {
|
|
17445
17649
|
const ledger = this.segmentHours;
|
|
17446
|
-
if (ledger && !this.ledgerWriteGate.run(() => ledger.recordSegment(row)))
|
|
17447
|
-
|
|
17448
|
-
|
|
17449
|
-
|
|
17650
|
+
if (ledger && !this.ledgerWriteGate.run(() => ledger.recordSegment(row))) {
|
|
17651
|
+
ledger.markRefused(row);
|
|
17652
|
+
this.ctx.logger.debug("recorder: hour-ledger write dropped — lane saturated; the hour stays unsealed and a reader walks it", {
|
|
17653
|
+
tags: { deviceId: row.deviceId },
|
|
17654
|
+
meta: { path: row.path }
|
|
17655
|
+
});
|
|
17656
|
+
}
|
|
17450
17657
|
if (!isMfraTableServable(row.startMs, Date.now())) return;
|
|
17451
17658
|
if (!this.mfraCaptureGate.run(async () => {
|
|
17452
17659
|
try {
|
|
@@ -17574,6 +17781,9 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
17574
17781
|
locations: () => this.resolvedLocations,
|
|
17575
17782
|
placeProfile: (deviceId, profile) => this.placeProfile(deviceId, profile),
|
|
17576
17783
|
onPlacementTick: (deviceIds) => this.recomputePlacement(deviceIds),
|
|
17784
|
+
onWriterClosed: async (deviceId, profile) => {
|
|
17785
|
+
await this.segmentHours?.sealWriter(deviceId, profile);
|
|
17786
|
+
},
|
|
17577
17787
|
spawn: node_child_process.spawn,
|
|
17578
17788
|
costRegistry: this.costRegistry,
|
|
17579
17789
|
segmentSeconds: this.config.segmentSeconds,
|
|
@@ -17643,7 +17853,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
17643
17853
|
},
|
|
17644
17854
|
calendar: this.calendar,
|
|
17645
17855
|
mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
|
|
17646
|
-
hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0),
|
|
17856
|
+
hydrateWindow: (deviceId, fromMs, toMs, verifyClosedHours) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0, { verifyClosedHours: verifyClosedHours === true }),
|
|
17647
17857
|
onConfigChanged: async (deviceId) => {
|
|
17648
17858
|
await this.refreshSensorTriggerBindings();
|
|
17649
17859
|
await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());
|
|
@@ -17923,6 +18133,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
17923
18133
|
this.locationRefresher?.dispose();
|
|
17924
18134
|
this.locationRefresher = null;
|
|
17925
18135
|
await this.controller?.stop();
|
|
18136
|
+
await this.segmentHours?.flush();
|
|
17926
18137
|
this.controller = null;
|
|
17927
18138
|
this.segmentStore = null;
|
|
17928
18139
|
if (this.exportSweepTimer !== null) {
|