@camstack/addon-pipeline 1.2.89 → 1.2.93
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/{addon-utils-yc-chXuC.js → addon-utils-CTRpEiSd.js} +1 -1
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +2 -2
- package/dist/{dist-BJYwWEZ9.mjs → dist-6POVIYmC.mjs} +91 -4
- package/dist/{dist-n4Nbnxx5.js → dist-D9To_r_d.js} +96 -3
- package/dist/{event-loop-stall-monitor-BiPffk0D.mjs → event-loop-stall-monitor-C-VvWOxS.mjs} +1 -1
- package/dist/{event-loop-stall-monitor-DG7QY00k.js → event-loop-stall-monitor-CGacbzXq.js} +1 -1
- package/dist/{lazy-sharp-EoSOHgoj.js → lazy-sharp-C3zOnmiU.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +4 -4
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/recorder/index.js +680 -37
- package/dist/recorder/index.mjs +679 -36
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CsrMZYLB.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C2woB6bY.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Dut56EeQ.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-DgivrPsH.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DsIi4G5Q.mjs} +1 -1
- package/dist/stream-broker/{hostInit-DoFovQmc.mjs → hostInit-VCURcZLt.mjs} +2 -2
- package/dist/stream-broker/index.js +194 -30
- package/dist/stream-broker/index.mjs +194 -30
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DjuxhGo3.js → worker-protocol-Bq28qobd.js} +1 -1
- package/dist/{worker-protocol-XZ1e36rM.mjs → worker-protocol-PzAKg8mC.mjs} +1 -1
- 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-DNSELPGi.mjs +0 -26
package/dist/recorder/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const require_dist = require("../dist-
|
|
1
|
+
const require_dist = require("../dist-D9To_r_d.js");
|
|
2
2
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
3
|
-
const require_addon_utils = require("../addon-utils-
|
|
3
|
+
const require_addon_utils = require("../addon-utils-CTRpEiSd.js");
|
|
4
4
|
let node_crypto = require("node:crypto");
|
|
5
5
|
let node_child_process = require("node:child_process");
|
|
6
6
|
let node_path = require("node:path");
|
|
@@ -507,14 +507,14 @@ function parseSegmentPath(relPath) {
|
|
|
507
507
|
//#region src/recorder/recording-index.ts
|
|
508
508
|
/** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
|
|
509
509
|
* already gives us — so "have I walked this?" costs one Set lookup. */
|
|
510
|
-
var HOUR_MS$
|
|
510
|
+
var HOUR_MS$5 = 36e5;
|
|
511
511
|
/** A device walked end-to-end. Cheaper and clearer than inserting every hour of
|
|
512
512
|
* the retention window into the set. */
|
|
513
513
|
var ALL_HOURS = Symbol("all-hours");
|
|
514
514
|
/** The hour buckets a half-open span touches. */
|
|
515
515
|
function* hourBuckets(fromMs, toMs) {
|
|
516
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
517
|
-
for (let h = first; h < toMs; h += HOUR_MS$
|
|
516
|
+
const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
|
|
517
|
+
for (let h = first; h < toMs; h += HOUR_MS$5) yield h;
|
|
518
518
|
}
|
|
519
519
|
var RecordingIndex = class {
|
|
520
520
|
byDevice = /* @__PURE__ */ new Map();
|
|
@@ -630,7 +630,7 @@ var RecordingIndex = class {
|
|
|
630
630
|
});
|
|
631
631
|
}
|
|
632
632
|
this.invalidateSorted(deviceId);
|
|
633
|
-
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$
|
|
633
|
+
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$5);
|
|
634
634
|
}
|
|
635
635
|
/** Insert/replace one finalized segment (idempotent by path). */
|
|
636
636
|
addSegment(s) {
|
|
@@ -1069,6 +1069,7 @@ var RelocateEngine = class {
|
|
|
1069
1069
|
deviceId: input.deviceId ?? null,
|
|
1070
1070
|
profiles: input.profiles ? [...input.profiles] : [],
|
|
1071
1071
|
footageClass: input.footageClass,
|
|
1072
|
+
sinceMs: input.sinceMs ?? null,
|
|
1072
1073
|
throttleMbps: input.throttleMbps ?? DEFAULT_THROTTLE_MBPS,
|
|
1073
1074
|
entities: input.entities ? [...input.entities] : ["segments"],
|
|
1074
1075
|
filesMoved: 0,
|
|
@@ -1129,7 +1130,7 @@ var RelocateEngine = class {
|
|
|
1129
1130
|
const bytesPerMs = job.throttleMbps * 1024 * 1024 / 1e3;
|
|
1130
1131
|
try {
|
|
1131
1132
|
const samePhysicalRoot = await rootsPhysicallySame(from.root, to.root);
|
|
1132
|
-
const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).filter((r) => job.profiles.length === 0 || job.profiles.includes(r.profile)).filter((r) => profileBelongsToClass(r.profile, job.footageClass)).sort((a, b) => a.startMs - b.startMs);
|
|
1133
|
+
const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).filter((r) => job.profiles.length === 0 || job.profiles.includes(r.profile)).filter((r) => profileBelongsToClass(r.profile, job.footageClass)).filter((r) => job.sinceMs === null || r.startMs >= job.sinceMs).sort((a, b) => a.startMs - b.startMs);
|
|
1133
1134
|
job.filesTotal = segRows.length;
|
|
1134
1135
|
for (const row of segRows) {
|
|
1135
1136
|
if (job.cancelRequested) break;
|
|
@@ -1165,9 +1166,26 @@ var RelocateEngine = class {
|
|
|
1165
1166
|
}
|
|
1166
1167
|
}
|
|
1167
1168
|
/** Move one segment file and re-point its index row to the target location
|
|
1168
|
-
* (same location-relative path — the layout is identical on every root).
|
|
1169
|
+
* (same location-relative path — the layout is identical on every root).
|
|
1170
|
+
* A source that is already gone (evicted, never flushed, ENOSPC) is dropped
|
|
1171
|
+
* from the index and skipped — one ghost file must not abort a drain. */
|
|
1169
1172
|
async moveSegment(row, from, to, samePhysicalRoot) {
|
|
1170
|
-
if (!samePhysicalRoot)
|
|
1173
|
+
if (!samePhysicalRoot) try {
|
|
1174
|
+
await moveFile(node_path.default.join(from.root, row.path), node_path.default.join(to.root, row.path), row.bytes);
|
|
1175
|
+
} catch (err) {
|
|
1176
|
+
if (isEnoent(err)) {
|
|
1177
|
+
this.deps.index.removeSegments([row.path]);
|
|
1178
|
+
this.deps.logger.warn("relocate skipped missing source", {
|
|
1179
|
+
tags: { deviceId: row.deviceId },
|
|
1180
|
+
meta: {
|
|
1181
|
+
path: row.path,
|
|
1182
|
+
locationId: from.id
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
throw err;
|
|
1188
|
+
}
|
|
1171
1189
|
this.deps.index.removeSegments([row.path]);
|
|
1172
1190
|
this.deps.index.addSegment({
|
|
1173
1191
|
...row,
|
|
@@ -1175,6 +1193,9 @@ var RelocateEngine = class {
|
|
|
1175
1193
|
});
|
|
1176
1194
|
}
|
|
1177
1195
|
};
|
|
1196
|
+
function isEnoent(err) {
|
|
1197
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
1198
|
+
}
|
|
1178
1199
|
/** Logical migration classes are profile-derived, never location-derived:
|
|
1179
1200
|
* high+mid are `recordings`; low is `recordingsLow`. */
|
|
1180
1201
|
var profileBelongsToClass = (profile, footageClass) => {
|
|
@@ -1371,6 +1392,8 @@ var SegmentStore = class SegmentStore {
|
|
|
1371
1392
|
if (removed.length > 0) {
|
|
1372
1393
|
this.deps.index.removeSegments(removed);
|
|
1373
1394
|
this.notifyEvicted(rows);
|
|
1395
|
+
const gone = new Set(removed);
|
|
1396
|
+
this.deps.onDeindexed?.(rows.filter((r) => gone.has(r.path)));
|
|
1374
1397
|
}
|
|
1375
1398
|
await this.pruneEmptyAncestors(location, [...byBucket.keys()]);
|
|
1376
1399
|
this.deps.logger.info("recorder: eviction round complete", { meta: {
|
|
@@ -1936,7 +1959,7 @@ async function profileHasFootage(root, deviceId, profile) {
|
|
|
1936
1959
|
//#endregion
|
|
1937
1960
|
//#region src/recorder/addon/calendar-index.ts
|
|
1938
1961
|
var DAY_MS$3 = 864e5;
|
|
1939
|
-
var HOUR_MS$
|
|
1962
|
+
var HOUR_MS$4 = 36e5;
|
|
1940
1963
|
/**
|
|
1941
1964
|
* UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
|
|
1942
1965
|
*
|
|
@@ -2109,7 +2132,7 @@ var CalendarIndex = class {
|
|
|
2109
2132
|
}));
|
|
2110
2133
|
const days = /* @__PURE__ */ new Set();
|
|
2111
2134
|
for (const { day, hours } of perDay) for (const h of hours) {
|
|
2112
|
-
const at = day + h * HOUR_MS$
|
|
2135
|
+
const at = day + h * HOUR_MS$4;
|
|
2113
2136
|
if (at < fromMs || at >= toMs) continue;
|
|
2114
2137
|
days.add(localDayStart(at, tzOffsetMinutes));
|
|
2115
2138
|
}
|
|
@@ -2171,7 +2194,7 @@ var CalendarIndex = class {
|
|
|
2171
2194
|
const day = utcDayOf(utcDayMs);
|
|
2172
2195
|
for (let h = 0; h < 24; h++) {
|
|
2173
2196
|
await this.awaitQuiet();
|
|
2174
|
-
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$
|
|
2197
|
+
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$4, true);
|
|
2175
2198
|
}
|
|
2176
2199
|
}
|
|
2177
2200
|
/**
|
|
@@ -2192,10 +2215,10 @@ var CalendarIndex = class {
|
|
|
2192
2215
|
*/
|
|
2193
2216
|
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
2194
2217
|
if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
|
|
2195
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
2218
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$4) * HOUR_MS$4;
|
|
2196
2219
|
const key = `${deviceId}:${profile}:${hour}`;
|
|
2197
2220
|
const now = this.deps.now?.() ?? Date.now();
|
|
2198
|
-
const isCurrentHour = hour === Math.floor(now / HOUR_MS$
|
|
2221
|
+
const isCurrentHour = hour === Math.floor(now / HOUR_MS$4) * HOUR_MS$4;
|
|
2199
2222
|
const cached = this.hourSegs.get(key);
|
|
2200
2223
|
if (cached !== void 0) {
|
|
2201
2224
|
if (!isCurrentHour) return {
|
|
@@ -2259,7 +2282,7 @@ var CalendarIndex = class {
|
|
|
2259
2282
|
*/
|
|
2260
2283
|
async dayRanges(deviceId, profile, utcDayMs) {
|
|
2261
2284
|
const day = utcDayOf(utcDayMs);
|
|
2262
|
-
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$
|
|
2285
|
+
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$4), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
|
|
2263
2286
|
}
|
|
2264
2287
|
/**
|
|
2265
2288
|
* Warm the days either side of the one being viewed, in the BACKGROUND.
|
|
@@ -2437,7 +2460,7 @@ function activeModeForConfig(config) {
|
|
|
2437
2460
|
}
|
|
2438
2461
|
//#endregion
|
|
2439
2462
|
//#region src/recorder/addon/directory-handler.ts
|
|
2440
|
-
var HOUR_MS$
|
|
2463
|
+
var HOUR_MS$3 = 36e5;
|
|
2441
2464
|
/** A window wider than this is refused: it is a client bug, and answering it
|
|
2442
2465
|
* would walk the archive on the read path — the thing this replaces. */
|
|
2443
2466
|
var MAX_WINDOW_MS = 32 * 864e5;
|
|
@@ -2484,9 +2507,9 @@ function withDirectoryRoute(deps, next) {
|
|
|
2484
2507
|
return;
|
|
2485
2508
|
}
|
|
2486
2509
|
const rows = [];
|
|
2487
|
-
const firstHour = Math.floor(fromMs / HOUR_MS$
|
|
2510
|
+
const firstHour = Math.floor(fromMs / HOUR_MS$3) * HOUR_MS$3;
|
|
2488
2511
|
let hydrated = true;
|
|
2489
|
-
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$
|
|
2512
|
+
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$3) {
|
|
2490
2513
|
const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
|
|
2491
2514
|
if (!complete) hydrated = false;
|
|
2492
2515
|
for (const s of segments) {
|
|
@@ -4211,6 +4234,7 @@ function buildRecordingProvider(deps) {
|
|
|
4211
4234
|
rescanStorage: async ({ deviceId }) => {
|
|
4212
4235
|
const locations = await deps.refreshLocations();
|
|
4213
4236
|
await deps.hydrateDevice(deviceId, locations);
|
|
4237
|
+
await deps.reconcileHourLedger?.(deviceId);
|
|
4214
4238
|
const config = await loadDeviceConfig(deps.configStore, deviceId);
|
|
4215
4239
|
await deps.opsLog?.append({
|
|
4216
4240
|
op: "rescan",
|
|
@@ -5074,7 +5098,11 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
|
|
|
5074
5098
|
try {
|
|
5075
5099
|
entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
|
|
5076
5100
|
} catch (err) {
|
|
5077
|
-
if (err.code
|
|
5101
|
+
if (err.code === "ENOENT") {
|
|
5102
|
+
for (const location of aliases) index.hydrateDevice(deviceId, location.id, []);
|
|
5103
|
+
continue;
|
|
5104
|
+
}
|
|
5105
|
+
logger.warn("recorder: hydrateDevice walk failed for location", {
|
|
5078
5106
|
tags: { deviceId },
|
|
5079
5107
|
meta: {
|
|
5080
5108
|
locationIds: aliases.map((location) => location.id),
|
|
@@ -5102,6 +5130,7 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
|
|
|
5102
5130
|
handled += 1;
|
|
5103
5131
|
if (handled % chunkSize === 0) await yieldBetween();
|
|
5104
5132
|
}
|
|
5133
|
+
for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
|
|
5105
5134
|
let indexed = 0;
|
|
5106
5135
|
for (const [locationId, relPaths] of pathsByLocation) {
|
|
5107
5136
|
index.hydrateDevice(deviceId, locationId, relPaths);
|
|
@@ -5186,7 +5215,7 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
|
5186
5215
|
const p2 = (n) => String(n).padStart(2, "0");
|
|
5187
5216
|
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
5188
5217
|
}
|
|
5189
|
-
var HOUR_MS$
|
|
5218
|
+
var HOUR_MS$2 = 36e5;
|
|
5190
5219
|
/**
|
|
5191
5220
|
* Hydrate ONLY the hours a window spans, instead of walking the archive.
|
|
5192
5221
|
*
|
|
@@ -5221,8 +5250,8 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
5221
5250
|
});
|
|
5222
5251
|
continue;
|
|
5223
5252
|
}
|
|
5224
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
5225
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
5253
|
+
const first = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
|
|
5254
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$2) for (const profile of profiles) {
|
|
5226
5255
|
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
5227
5256
|
let files;
|
|
5228
5257
|
try {
|
|
@@ -6561,6 +6590,13 @@ var RecordingController = class {
|
|
|
6561
6590
|
* records again.
|
|
6562
6591
|
*/
|
|
6563
6592
|
unrecordableReported = /* @__PURE__ */ new Set();
|
|
6593
|
+
/**
|
|
6594
|
+
* Profiles the last attach skipped (stream not published / getStream threw).
|
|
6595
|
+
* `evaluateDevice` retries only these on the converge tick so a dead `low`
|
|
6596
|
+
* cannot keep `high` dark, without a `listAllProfileSlots` RPC on every
|
|
6597
|
+
* healthy device every 30s.
|
|
6598
|
+
*/
|
|
6599
|
+
skippedProfiles = /* @__PURE__ */ new Map();
|
|
6564
6600
|
restore = null;
|
|
6565
6601
|
/** Cheap, never-delayed: is any pinned writer producing nothing? */
|
|
6566
6602
|
livenessPass = null;
|
|
@@ -6767,7 +6803,10 @@ var RecordingController = class {
|
|
|
6767
6803
|
return;
|
|
6768
6804
|
}
|
|
6769
6805
|
this.unrecordableReported.delete(deviceId);
|
|
6770
|
-
if (this.active.has(deviceId))
|
|
6806
|
+
if (this.active.has(deviceId)) {
|
|
6807
|
+
if (this.skippedProfiles.has(deviceId)) await this.fillMissingProfiles(deviceId, config);
|
|
6808
|
+
return;
|
|
6809
|
+
}
|
|
6771
6810
|
if (this.attaching.has(deviceId)) return;
|
|
6772
6811
|
this.attaching.add(deviceId);
|
|
6773
6812
|
const attaching = this.performAttach(deviceId, config);
|
|
@@ -6818,8 +6857,12 @@ var RecordingController = class {
|
|
|
6818
6857
|
/**
|
|
6819
6858
|
* The attach body — extracted so the {@link attaching} in-flight guard in
|
|
6820
6859
|
* `evaluateDevice` wraps it cleanly. Resolves the profiles, spawns a
|
|
6821
|
-
* SegmentWriter + watcher per profile, and records the set in `active`.
|
|
6822
|
-
*
|
|
6860
|
+
* SegmentWriter + watcher per profile, and records the set in `active`.
|
|
6861
|
+
*
|
|
6862
|
+
* Per-profile: a throw from one slot is logged and skipped. Rolling back the
|
|
6863
|
+
* whole device when a single stream was dead (618 `derived` on `low`) left
|
|
6864
|
+
* ZERO footage for hours. If every profile fails, throw so the restore queue
|
|
6865
|
+
* retries the device.
|
|
6823
6866
|
*/
|
|
6824
6867
|
async performAttach(deviceId, config) {
|
|
6825
6868
|
this.restore?.add(deviceId, true);
|
|
@@ -6827,38 +6870,106 @@ var RecordingController = class {
|
|
|
6827
6870
|
if (profiles.length === 0) throw new Error(`recorder controller: no assigned broker sources for device ${deviceId}`);
|
|
6828
6871
|
const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
|
|
6829
6872
|
const recordings = [];
|
|
6830
|
-
|
|
6831
|
-
|
|
6873
|
+
const failed = [];
|
|
6874
|
+
for (const profile of profiles) try {
|
|
6875
|
+
recordings.push(await this.attachProfile(deviceId, profile, segmentSeconds));
|
|
6832
6876
|
} catch (err) {
|
|
6833
|
-
|
|
6834
|
-
|
|
6877
|
+
failed.push(profile);
|
|
6878
|
+
this.deps.logger.warn("recorder: profile attach failed — recording the remaining profiles", {
|
|
6879
|
+
tags: { deviceId },
|
|
6880
|
+
meta: {
|
|
6881
|
+
profile,
|
|
6882
|
+
error: require_dist.errMsg(err)
|
|
6883
|
+
}
|
|
6884
|
+
});
|
|
6835
6885
|
}
|
|
6886
|
+
if (recordings.length === 0) throw new Error(`recorder controller: no profile attached for device ${deviceId}` + (failed.length > 0 ? ` (failed: ${failed.join(",")})` : ""));
|
|
6836
6887
|
if (this.paused) {
|
|
6837
6888
|
await Promise.all(recordings.map((recording) => this.teardownProfile(deviceId, recording)));
|
|
6838
6889
|
return;
|
|
6839
6890
|
}
|
|
6840
6891
|
this.active.set(deviceId, recordings);
|
|
6892
|
+
if (failed.length > 0) this.skippedProfiles.set(deviceId, new Set(failed));
|
|
6893
|
+
else this.skippedProfiles.delete(deviceId);
|
|
6841
6894
|
this.deps.logger.info("recorder pinned broker source(s) for recording", {
|
|
6842
6895
|
tags: { deviceId },
|
|
6843
6896
|
meta: {
|
|
6844
|
-
profiles,
|
|
6897
|
+
profiles: recordings.map((r) => r.profile),
|
|
6898
|
+
failedProfiles: failed,
|
|
6845
6899
|
segmentSeconds,
|
|
6846
6900
|
pipelineKeys: recordings.map((r) => r.pipelineKey)
|
|
6847
6901
|
}
|
|
6848
6902
|
});
|
|
6849
6903
|
}
|
|
6850
6904
|
/**
|
|
6905
|
+
* Retry profiles the last attach skipped, without tearing down writers that
|
|
6906
|
+
* are already producing. Called from `evaluateDevice` when the device is
|
|
6907
|
+
* already in `active` and {@link skippedProfiles} is non-empty. Failures stay
|
|
6908
|
+
* logged; the next converge tick retries.
|
|
6909
|
+
*/
|
|
6910
|
+
async fillMissingProfiles(deviceId, config) {
|
|
6911
|
+
if (this.attaching.has(deviceId)) return;
|
|
6912
|
+
const current = this.active.get(deviceId);
|
|
6913
|
+
const skipped = this.skippedProfiles.get(deviceId);
|
|
6914
|
+
if (!current || !skipped || skipped.size === 0) return;
|
|
6915
|
+
this.attaching.add(deviceId);
|
|
6916
|
+
const filling = this.runFillMissingProfiles(deviceId, config, current, skipped);
|
|
6917
|
+
this.attachingTasks.set(deviceId, filling);
|
|
6918
|
+
try {
|
|
6919
|
+
await filling;
|
|
6920
|
+
} finally {
|
|
6921
|
+
this.attachingTasks.delete(deviceId);
|
|
6922
|
+
this.attaching.delete(deviceId);
|
|
6923
|
+
}
|
|
6924
|
+
}
|
|
6925
|
+
async runFillMissingProfiles(deviceId, config, current, skipped) {
|
|
6926
|
+
const have = new Set(current.map((r) => r.profile));
|
|
6927
|
+
const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
|
|
6928
|
+
const added = [];
|
|
6929
|
+
for (const profile of [...skipped]) {
|
|
6930
|
+
if (have.has(profile)) {
|
|
6931
|
+
skipped.delete(profile);
|
|
6932
|
+
continue;
|
|
6933
|
+
}
|
|
6934
|
+
try {
|
|
6935
|
+
added.push(await this.attachProfile(deviceId, profile, segmentSeconds));
|
|
6936
|
+
skipped.delete(profile);
|
|
6937
|
+
} catch (err) {
|
|
6938
|
+
this.deps.logger.warn("recorder: profile attach retry failed — keeping the healthy profiles", {
|
|
6939
|
+
tags: { deviceId },
|
|
6940
|
+
meta: {
|
|
6941
|
+
profile,
|
|
6942
|
+
error: require_dist.errMsg(err)
|
|
6943
|
+
}
|
|
6944
|
+
});
|
|
6945
|
+
}
|
|
6946
|
+
}
|
|
6947
|
+
if (skipped.size === 0) this.skippedProfiles.delete(deviceId);
|
|
6948
|
+
if (added.length === 0) return;
|
|
6949
|
+
const existing = this.active.get(deviceId);
|
|
6950
|
+
if (!existing || this.paused || this.stopped) {
|
|
6951
|
+
await Promise.all(added.map((recording) => this.teardownProfile(deviceId, recording)));
|
|
6952
|
+
return;
|
|
6953
|
+
}
|
|
6954
|
+
this.active.set(deviceId, [...existing, ...added]);
|
|
6955
|
+
this.deps.logger.info("recorder pinned additional broker source(s) for recording", {
|
|
6956
|
+
tags: { deviceId },
|
|
6957
|
+
meta: {
|
|
6958
|
+
profiles: added.map((r) => r.profile),
|
|
6959
|
+
pipelineKeys: added.map((r) => r.pipelineKey)
|
|
6960
|
+
}
|
|
6961
|
+
});
|
|
6962
|
+
}
|
|
6963
|
+
/**
|
|
6851
6964
|
* Pick which profiles to record. The broker enumerates assigned profile slots,
|
|
6852
6965
|
* deduped by physical source (`selectAssignedProfileSlots`) so the same camera
|
|
6853
|
-
* encoder is never recorded twice.
|
|
6854
|
-
*
|
|
6855
|
-
*
|
|
6966
|
+
* encoder is never recorded twice. Default is high+low among those slots
|
|
6967
|
+
* (`resolveRecordingProfiles`); an operator `override` DISABLES the
|
|
6968
|
+
* unselected profiles. A selection matching none of the assigned sources
|
|
6969
|
+
* falls back to the default/assigned set (minimum of 1 when anything is assigned).
|
|
6856
6970
|
*/
|
|
6857
6971
|
async resolveProfiles(deviceId, override) {
|
|
6858
|
-
|
|
6859
|
-
if (!override || override.length === 0) return assigned;
|
|
6860
|
-
const selected = assigned.filter((p) => override.includes(p));
|
|
6861
|
-
return selected.length > 0 ? selected : assigned;
|
|
6972
|
+
return require_dist.resolveRecordingProfiles(require_dist.selectAssignedProfileSlots(await this.deps.brokerCall(() => withDeadline(this.deps.api.streamBroker.listAllProfileSlots.query(void 0, require_dist.nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "listAllProfileSlots")), deviceId).map((slot) => slot.profile), override);
|
|
6862
6973
|
}
|
|
6863
6974
|
/**
|
|
6864
6975
|
* Attach one (device, profile): acquire the broker source, mkdir the staging
|
|
@@ -6988,6 +7099,7 @@ var RecordingController = class {
|
|
|
6988
7099
|
const recordings = this.active.get(deviceId);
|
|
6989
7100
|
if (!recordings) return;
|
|
6990
7101
|
this.active.delete(deviceId);
|
|
7102
|
+
this.skippedProfiles.delete(deviceId);
|
|
6991
7103
|
for (const r of recordings) this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
|
|
6992
7104
|
await Promise.all(recordings.map((r) => this.teardownProfile(deviceId, r)));
|
|
6993
7105
|
this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
|
|
@@ -7040,6 +7152,503 @@ function makeBrokerCall(handle) {
|
|
|
7040
7152
|
return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
|
|
7041
7153
|
}
|
|
7042
7154
|
//#endregion
|
|
7155
|
+
//#region src/durable/durable-ledger.ts
|
|
7156
|
+
/** Default reseed cap — every current consumer's row set is installation-bounded. */
|
|
7157
|
+
var DEFAULT_LOAD_LIMIT = 1e5;
|
|
7158
|
+
var DurableLedger = class DurableLedger {
|
|
7159
|
+
mirror = /* @__PURE__ */ new Map();
|
|
7160
|
+
spec;
|
|
7161
|
+
store;
|
|
7162
|
+
logger;
|
|
7163
|
+
constructor(deps) {
|
|
7164
|
+
this.spec = deps.spec;
|
|
7165
|
+
this.store = deps.store;
|
|
7166
|
+
this.logger = deps.logger;
|
|
7167
|
+
}
|
|
7168
|
+
/**
|
|
7169
|
+
* Register the collection. MUST run at boot, before any read or write: the
|
|
7170
|
+
* SQLite backend answers 412 for an undeclared collection and takes the whole
|
|
7171
|
+
* runner down with it (the addon-ai boot-crash lesson).
|
|
7172
|
+
*/
|
|
7173
|
+
static declare(store, spec) {
|
|
7174
|
+
return store.declareCollection.mutate({
|
|
7175
|
+
collection: spec.collection,
|
|
7176
|
+
columns: [...spec.columns],
|
|
7177
|
+
...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
|
|
7178
|
+
});
|
|
7179
|
+
}
|
|
7180
|
+
declare() {
|
|
7181
|
+
return DurableLedger.declare(this.store, this.spec);
|
|
7182
|
+
}
|
|
7183
|
+
/** The collection this ledger owns — for the caller's own log lines. */
|
|
7184
|
+
get collection() {
|
|
7185
|
+
return this.spec.collection;
|
|
7186
|
+
}
|
|
7187
|
+
/**
|
|
7188
|
+
* Boot reseed. Replaces the mirror with what the store holds and returns the
|
|
7189
|
+
* rows, so a caller that must hydrate something else (a watcher, a registry)
|
|
7190
|
+
* gets them without a second read.
|
|
7191
|
+
*
|
|
7192
|
+
* **A failure returns what is already mirrored** rather than clearing it —
|
|
7193
|
+
* see contract rule 2. The count is worth logging out loud at the call site:
|
|
7194
|
+
* "loaded 0" after a container recreate is the one line that explains a
|
|
7195
|
+
* silent flood.
|
|
7196
|
+
*/
|
|
7197
|
+
async load() {
|
|
7198
|
+
try {
|
|
7199
|
+
const records = await this.store.query.query({
|
|
7200
|
+
collection: this.spec.collection,
|
|
7201
|
+
filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
|
|
7202
|
+
});
|
|
7203
|
+
const next = /* @__PURE__ */ new Map();
|
|
7204
|
+
let skipped = 0;
|
|
7205
|
+
for (const record of records) {
|
|
7206
|
+
const row = this.spec.fromRecord(record.id, record.data);
|
|
7207
|
+
if (row === null) {
|
|
7208
|
+
skipped += 1;
|
|
7209
|
+
continue;
|
|
7210
|
+
}
|
|
7211
|
+
next.set(this.spec.keyOf(row), row);
|
|
7212
|
+
}
|
|
7213
|
+
this.mirror.clear();
|
|
7214
|
+
for (const [key, row] of next) this.mirror.set(key, row);
|
|
7215
|
+
if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
|
|
7216
|
+
collection: this.spec.collection,
|
|
7217
|
+
skipped
|
|
7218
|
+
} });
|
|
7219
|
+
return [...this.mirror.values()];
|
|
7220
|
+
} catch (err) {
|
|
7221
|
+
this.logger.warn("durable load failed — keeping the state already in memory", { meta: {
|
|
7222
|
+
collection: this.spec.collection,
|
|
7223
|
+
error: String(err),
|
|
7224
|
+
held: this.mirror.size
|
|
7225
|
+
} });
|
|
7226
|
+
return [...this.mirror.values()];
|
|
7227
|
+
}
|
|
7228
|
+
}
|
|
7229
|
+
/** Every mirrored row, insertion-ordered. */
|
|
7230
|
+
snapshot() {
|
|
7231
|
+
return [...this.mirror.values()];
|
|
7232
|
+
}
|
|
7233
|
+
/** The row currently held for a key, if any. Pure RAM — never I/O. */
|
|
7234
|
+
get(key) {
|
|
7235
|
+
return this.mirror.get(key);
|
|
7236
|
+
}
|
|
7237
|
+
has(key) {
|
|
7238
|
+
return this.mirror.has(key);
|
|
7239
|
+
}
|
|
7240
|
+
get size() {
|
|
7241
|
+
return this.mirror.size;
|
|
7242
|
+
}
|
|
7243
|
+
/**
|
|
7244
|
+
* Judge ONE observation against what the ledger already accepted, and advance
|
|
7245
|
+
* it.
|
|
7246
|
+
*
|
|
7247
|
+
* Synchronous on purpose: the verdict is a function of the in-RAM mirror
|
|
7248
|
+
* alone, so a decision can never be gated on an I/O that might fail (D49).
|
|
7249
|
+
* The durable write is kicked off behind it and its failure changes no
|
|
7250
|
+
* verdict.
|
|
7251
|
+
*
|
|
7252
|
+
* `no-flip` leaves the held row UNTOUCHED — including any timestamp it
|
|
7253
|
+
* carries, which therefore means "when this key last CHANGED", not "when it
|
|
7254
|
+
* last spoke". That is the timestamp anyone reading the table wants.
|
|
7255
|
+
*/
|
|
7256
|
+
observe(row) {
|
|
7257
|
+
const equalFact = this.spec.equalFact;
|
|
7258
|
+
if (equalFact === void 0) throw new Error(`DurableLedger(${this.spec.collection}): observe() requires the spec to declare equalFact`);
|
|
7259
|
+
const key = this.spec.keyOf(row);
|
|
7260
|
+
const held = this.mirror.get(key);
|
|
7261
|
+
if (held !== void 0 && equalFact(held, row)) return "no-flip";
|
|
7262
|
+
this.mirror.set(key, row);
|
|
7263
|
+
this.persist(row);
|
|
7264
|
+
return held === void 0 ? "seeded" : "flip";
|
|
7265
|
+
}
|
|
7266
|
+
/**
|
|
7267
|
+
* Upsert a row without a verdict — the write path for a ledger whose owner
|
|
7268
|
+
* has already decided the value changed.
|
|
7269
|
+
*
|
|
7270
|
+
* The order of the mirror advance and the durable write is the spec's
|
|
7271
|
+
* {@link DurableWriteMode}, not the call site's: two call sites that
|
|
7272
|
+
* disagreed about it would be two different durability guarantees on one
|
|
7273
|
+
* collection.
|
|
7274
|
+
*/
|
|
7275
|
+
async put(row) {
|
|
7276
|
+
const key = this.spec.keyOf(row);
|
|
7277
|
+
if (this.spec.writeMode === "write-behind") {
|
|
7278
|
+
this.mirror.set(key, row);
|
|
7279
|
+
await this.persist(row);
|
|
7280
|
+
return;
|
|
7281
|
+
}
|
|
7282
|
+
await this.store.set.mutate({
|
|
7283
|
+
collection: this.spec.collection,
|
|
7284
|
+
key,
|
|
7285
|
+
value: this.spec.toValue(row)
|
|
7286
|
+
});
|
|
7287
|
+
this.mirror.set(key, row);
|
|
7288
|
+
}
|
|
7289
|
+
/**
|
|
7290
|
+
* Advance the mirror WITHOUT persisting, for an owner that deliberately
|
|
7291
|
+
* coalesces its writes.
|
|
7292
|
+
*
|
|
7293
|
+
* The stationary registry is the reason this exists: a parked car is
|
|
7294
|
+
* re-confirmed on every processed frame (5–30 Hz), and persisting each
|
|
7295
|
+
* confirmation would offer thousands of commits a day to the checkpoint
|
|
7296
|
+
* lottery (D96) to maintain a handful of rows. It stages the advance and
|
|
7297
|
+
* flushes on its 5-minute sweep — 288 writes a day instead of ~10⁶.
|
|
7298
|
+
*
|
|
7299
|
+
* **The cost is stated, not hidden**: a staged value that is never flushed
|
|
7300
|
+
* is lost on a crash. An owner may only stage a field whose staleness its
|
|
7301
|
+
* own TTL absorbs. Anything that GATES work must go through {@link put} or
|
|
7302
|
+
* {@link observe}.
|
|
7303
|
+
*/
|
|
7304
|
+
stage(row) {
|
|
7305
|
+
this.mirror.set(this.spec.keyOf(row), row);
|
|
7306
|
+
}
|
|
7307
|
+
/**
|
|
7308
|
+
* Drop a key from the MIRROR only — the durable row survives.
|
|
7309
|
+
*
|
|
7310
|
+
* What a scope-unbind needs: this process stops holding the value, and a
|
|
7311
|
+
* rebind reloads it from the store. Deliberately distinct from
|
|
7312
|
+
* {@link forget}, which deletes; conflating the two is how an unbind turns
|
|
7313
|
+
* into a wipe.
|
|
7314
|
+
*/
|
|
7315
|
+
evict(key) {
|
|
7316
|
+
this.mirror.delete(key);
|
|
7317
|
+
}
|
|
7318
|
+
/**
|
|
7319
|
+
* Drop one key, mirror and row. Best-effort on the durable half: a failed
|
|
7320
|
+
* delete leaves a row that the next load will re-mirror, which is a stale
|
|
7321
|
+
* value rather than a lost one.
|
|
7322
|
+
*/
|
|
7323
|
+
async forget(key) {
|
|
7324
|
+
this.mirror.delete(key);
|
|
7325
|
+
try {
|
|
7326
|
+
await this.store.delete.mutate({
|
|
7327
|
+
collection: this.spec.collection,
|
|
7328
|
+
key
|
|
7329
|
+
});
|
|
7330
|
+
} catch (err) {
|
|
7331
|
+
this.logger.debug("durable delete failed", { meta: {
|
|
7332
|
+
collection: this.spec.collection,
|
|
7333
|
+
key,
|
|
7334
|
+
error: String(err)
|
|
7335
|
+
} });
|
|
7336
|
+
}
|
|
7337
|
+
}
|
|
7338
|
+
/**
|
|
7339
|
+
* Drop every mirrored key NOT in `activeKeys`. Returns how many rows went.
|
|
7340
|
+
*
|
|
7341
|
+
* **The caller must hold an AUTHORITATIVE active set.** A prune driven by a
|
|
7342
|
+
* fallible read is work destroyed on an error (D49/D130) — that is why this
|
|
7343
|
+
* is a method a feature opts into rather than a policy the primitive runs.
|
|
7344
|
+
* Best-effort per row: a failed delete keeps the key (retried next prune)
|
|
7345
|
+
* rather than aborting the sweep.
|
|
7346
|
+
*/
|
|
7347
|
+
async pruneExcept(activeKeys) {
|
|
7348
|
+
let pruned = 0;
|
|
7349
|
+
for (const key of [...this.mirror.keys()]) {
|
|
7350
|
+
if (activeKeys.has(key)) continue;
|
|
7351
|
+
try {
|
|
7352
|
+
await this.store.delete.mutate({
|
|
7353
|
+
collection: this.spec.collection,
|
|
7354
|
+
key
|
|
7355
|
+
});
|
|
7356
|
+
this.mirror.delete(key);
|
|
7357
|
+
pruned += 1;
|
|
7358
|
+
} catch (err) {
|
|
7359
|
+
this.logger.debug("durable prune delete failed", { meta: {
|
|
7360
|
+
collection: this.spec.collection,
|
|
7361
|
+
key,
|
|
7362
|
+
error: String(err)
|
|
7363
|
+
} });
|
|
7364
|
+
}
|
|
7365
|
+
}
|
|
7366
|
+
return pruned;
|
|
7367
|
+
}
|
|
7368
|
+
/**
|
|
7369
|
+
* Write-behind durable upsert. Best-effort and logged, never thrown at the
|
|
7370
|
+
* decision path: the mirror already holds the truth for this process, and the
|
|
7371
|
+
* worst a lost write can do is one silent re-seed after the next restart.
|
|
7372
|
+
*/
|
|
7373
|
+
async persist(row) {
|
|
7374
|
+
const deviceId = this.spec.deviceIdOf?.(row);
|
|
7375
|
+
try {
|
|
7376
|
+
await this.store.set.mutate({
|
|
7377
|
+
collection: this.spec.collection,
|
|
7378
|
+
key: this.spec.keyOf(row),
|
|
7379
|
+
value: this.spec.toValue(row)
|
|
7380
|
+
});
|
|
7381
|
+
} catch (err) {
|
|
7382
|
+
this.logger.warn("durable persist failed — this key may re-seed on boot", {
|
|
7383
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
7384
|
+
meta: {
|
|
7385
|
+
collection: this.spec.collection,
|
|
7386
|
+
key: this.spec.keyOf(row),
|
|
7387
|
+
error: String(err)
|
|
7388
|
+
}
|
|
7389
|
+
});
|
|
7390
|
+
}
|
|
7391
|
+
}
|
|
7392
|
+
};
|
|
7393
|
+
//#endregion
|
|
7394
|
+
//#region src/recorder/addon/segment-hour-ledger.ts
|
|
7395
|
+
var HOUR_MS$1 = 36e5;
|
|
7396
|
+
/**
|
|
7397
|
+
* @durable class=mirror owner=recorder
|
|
7398
|
+
* write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
|
|
7399
|
+
* retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
|
|
7400
|
+
*/
|
|
7401
|
+
var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
|
|
7402
|
+
var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
7403
|
+
{
|
|
7404
|
+
name: "key",
|
|
7405
|
+
type: "TEXT",
|
|
7406
|
+
primaryKey: true,
|
|
7407
|
+
notNull: true
|
|
7408
|
+
},
|
|
7409
|
+
{
|
|
7410
|
+
name: "deviceId",
|
|
7411
|
+
type: "INTEGER",
|
|
7412
|
+
notNull: true
|
|
7413
|
+
},
|
|
7414
|
+
{
|
|
7415
|
+
name: "profile",
|
|
7416
|
+
type: "TEXT",
|
|
7417
|
+
notNull: true
|
|
7418
|
+
},
|
|
7419
|
+
{
|
|
7420
|
+
name: "locationId",
|
|
7421
|
+
type: "TEXT",
|
|
7422
|
+
notNull: true
|
|
7423
|
+
},
|
|
7424
|
+
{
|
|
7425
|
+
name: "hourStartMs",
|
|
7426
|
+
type: "INTEGER",
|
|
7427
|
+
notNull: true
|
|
7428
|
+
},
|
|
7429
|
+
{
|
|
7430
|
+
name: "paths",
|
|
7431
|
+
type: "TEXT",
|
|
7432
|
+
notNull: true
|
|
7433
|
+
}
|
|
7434
|
+
];
|
|
7435
|
+
var RECORDING_SEGMENT_HOURS_INDEXES = [{
|
|
7436
|
+
name: "idx_recorder_segment_hours_device",
|
|
7437
|
+
columns: ["deviceId"]
|
|
7438
|
+
}, {
|
|
7439
|
+
name: "idx_recorder_segment_hours_hour",
|
|
7440
|
+
columns: ["hourStartMs"]
|
|
7441
|
+
}];
|
|
7442
|
+
function hourStartMs(startMs) {
|
|
7443
|
+
return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
|
|
7444
|
+
}
|
|
7445
|
+
function segmentHourKey(deviceId, profile, locationId, hour) {
|
|
7446
|
+
return `${deviceId}|${profile}|${locationId}|${hour}`;
|
|
7447
|
+
}
|
|
7448
|
+
function hourOf(row) {
|
|
7449
|
+
const hour = hourStartMs(row.startMs);
|
|
7450
|
+
return {
|
|
7451
|
+
hourStartMs: hour,
|
|
7452
|
+
key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
|
|
7453
|
+
};
|
|
7454
|
+
}
|
|
7455
|
+
function uniquePaths(paths) {
|
|
7456
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7457
|
+
const out = [];
|
|
7458
|
+
for (const p of paths) {
|
|
7459
|
+
if (seen.has(p)) continue;
|
|
7460
|
+
seen.add(p);
|
|
7461
|
+
out.push(p);
|
|
7462
|
+
}
|
|
7463
|
+
return out;
|
|
7464
|
+
}
|
|
7465
|
+
function samePaths(a, b) {
|
|
7466
|
+
if (a.length !== b.length) return false;
|
|
7467
|
+
const set = new Set(a);
|
|
7468
|
+
for (const p of b) if (!set.has(p)) return false;
|
|
7469
|
+
return true;
|
|
7470
|
+
}
|
|
7471
|
+
function rowToValue(row) {
|
|
7472
|
+
return {
|
|
7473
|
+
deviceId: row.deviceId,
|
|
7474
|
+
profile: row.profile,
|
|
7475
|
+
locationId: row.locationId,
|
|
7476
|
+
hourStartMs: row.hourStartMs,
|
|
7477
|
+
paths: JSON.stringify(row.paths)
|
|
7478
|
+
};
|
|
7479
|
+
}
|
|
7480
|
+
function recordToRow(key, data) {
|
|
7481
|
+
const deviceId = Number(data["deviceId"]);
|
|
7482
|
+
const profile = data["profile"];
|
|
7483
|
+
const locationId = data["locationId"];
|
|
7484
|
+
const hour = Number(data["hourStartMs"]);
|
|
7485
|
+
let raw = data["paths"];
|
|
7486
|
+
if (typeof raw === "string") try {
|
|
7487
|
+
raw = JSON.parse(raw);
|
|
7488
|
+
} catch {
|
|
7489
|
+
return null;
|
|
7490
|
+
}
|
|
7491
|
+
if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour) || !Array.isArray(raw) || !raw.every((p) => typeof p === "string" && p.length > 0)) return null;
|
|
7492
|
+
return {
|
|
7493
|
+
key,
|
|
7494
|
+
deviceId,
|
|
7495
|
+
profile,
|
|
7496
|
+
locationId,
|
|
7497
|
+
hourStartMs: hour,
|
|
7498
|
+
paths: raw
|
|
7499
|
+
};
|
|
7500
|
+
}
|
|
7501
|
+
var SPEC = {
|
|
7502
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
7503
|
+
columns: RECORDING_SEGMENT_HOURS_COLUMNS,
|
|
7504
|
+
indexes: RECORDING_SEGMENT_HOURS_INDEXES,
|
|
7505
|
+
writeMode: "write-behind",
|
|
7506
|
+
loadLimit: 1e5,
|
|
7507
|
+
keyOf: (row) => row.key,
|
|
7508
|
+
toValue: rowToValue,
|
|
7509
|
+
fromRecord: recordToRow,
|
|
7510
|
+
deviceIdOf: (row) => row.deviceId
|
|
7511
|
+
};
|
|
7512
|
+
var SegmentHourLedger = class {
|
|
7513
|
+
ledger;
|
|
7514
|
+
logger;
|
|
7515
|
+
constructor(deps) {
|
|
7516
|
+
this.logger = deps.logger;
|
|
7517
|
+
this.ledger = new DurableLedger({
|
|
7518
|
+
spec: SPEC,
|
|
7519
|
+
store: deps.store,
|
|
7520
|
+
logger: deps.logger
|
|
7521
|
+
});
|
|
7522
|
+
}
|
|
7523
|
+
static declare(store) {
|
|
7524
|
+
return DurableLedger.declare(store, SPEC);
|
|
7525
|
+
}
|
|
7526
|
+
declare() {
|
|
7527
|
+
return this.ledger.declare();
|
|
7528
|
+
}
|
|
7529
|
+
async load() {
|
|
7530
|
+
const rows = await this.ledger.load();
|
|
7531
|
+
this.logger.info("recorder: segment-hour ledger loaded", { meta: {
|
|
7532
|
+
hours: rows.length,
|
|
7533
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION
|
|
7534
|
+
} });
|
|
7535
|
+
return rows;
|
|
7536
|
+
}
|
|
7537
|
+
snapshot() {
|
|
7538
|
+
return this.ledger.snapshot();
|
|
7539
|
+
}
|
|
7540
|
+
/**
|
|
7541
|
+
* Seed {@link RecordingIndex} from the mirrored hour rows.
|
|
7542
|
+
*
|
|
7543
|
+
* Hours present in the ledger become `hydrated` so a seek in those windows
|
|
7544
|
+
* does not pay for a disk walk. Hours the ledger never held stay `unknown`
|
|
7545
|
+
* — that is the fail-toward-walk direction.
|
|
7546
|
+
*/
|
|
7547
|
+
hydrateIndex(index) {
|
|
7548
|
+
for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
7549
|
+
}
|
|
7550
|
+
/** Write-behind: a just-finalized segment joins its hour row. */
|
|
7551
|
+
async recordSegment(row) {
|
|
7552
|
+
const { key, hourStartMs: hour } = hourOf(row);
|
|
7553
|
+
const held = this.ledger.get(key);
|
|
7554
|
+
if (held !== void 0 && held.paths.includes(row.path)) return;
|
|
7555
|
+
await this.ledger.put({
|
|
7556
|
+
key,
|
|
7557
|
+
deviceId: row.deviceId,
|
|
7558
|
+
profile: row.profile,
|
|
7559
|
+
locationId: row.locationId,
|
|
7560
|
+
hourStartMs: hour,
|
|
7561
|
+
paths: held === void 0 ? [row.path] : [...held.paths, row.path]
|
|
7562
|
+
});
|
|
7563
|
+
}
|
|
7564
|
+
/**
|
|
7565
|
+
* Eviction confirmed these paths are gone from disk. Drop them; forget the
|
|
7566
|
+
* hour when it holds nothing.
|
|
7567
|
+
*/
|
|
7568
|
+
async dropSegments(rows) {
|
|
7569
|
+
const doomed = /* @__PURE__ */ new Map();
|
|
7570
|
+
for (const row of rows) {
|
|
7571
|
+
const { key } = hourOf(row);
|
|
7572
|
+
let set = doomed.get(key);
|
|
7573
|
+
if (!set) {
|
|
7574
|
+
set = /* @__PURE__ */ new Set();
|
|
7575
|
+
doomed.set(key, set);
|
|
7576
|
+
}
|
|
7577
|
+
set.add(row.path);
|
|
7578
|
+
}
|
|
7579
|
+
for (const [key, paths] of doomed) {
|
|
7580
|
+
const held = this.ledger.get(key);
|
|
7581
|
+
if (held === void 0) continue;
|
|
7582
|
+
const next = held.paths.filter((p) => !paths.has(p));
|
|
7583
|
+
if (next.length === 0) {
|
|
7584
|
+
await this.ledger.forget(key);
|
|
7585
|
+
continue;
|
|
7586
|
+
}
|
|
7587
|
+
if (next.length === held.paths.length) continue;
|
|
7588
|
+
await this.ledger.put({
|
|
7589
|
+
...held,
|
|
7590
|
+
paths: next
|
|
7591
|
+
});
|
|
7592
|
+
}
|
|
7593
|
+
}
|
|
7594
|
+
/**
|
|
7595
|
+
* After a disk walk of `deviceIds`, make the ledger match the index — with
|
|
7596
|
+
* two D148 exceptions:
|
|
7597
|
+
*
|
|
7598
|
+
* - an hour the walk did not see is dropped ONLY if it is in the past;
|
|
7599
|
+
* the current hour may still hold a live writer whose file the readdir
|
|
7600
|
+
* raced;
|
|
7601
|
+
* - the current hour UNIONs ledger paths with index paths rather than
|
|
7602
|
+
* replacing, for the same race.
|
|
7603
|
+
*
|
|
7604
|
+
* Devices not in `deviceIds` are left untouched (a partial walk must not
|
|
7605
|
+
* prune cameras it did not look at).
|
|
7606
|
+
*/
|
|
7607
|
+
async reconcileFromIndex(index, deviceIds, nowMs) {
|
|
7608
|
+
const walked = new Set(deviceIds);
|
|
7609
|
+
const currentHour = hourStartMs(nowMs);
|
|
7610
|
+
const fromIndex = /* @__PURE__ */ new Map();
|
|
7611
|
+
for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
|
|
7612
|
+
const { key, hourStartMs: hour } = hourOf(seg);
|
|
7613
|
+
const existing = fromIndex.get(key);
|
|
7614
|
+
if (existing === void 0) fromIndex.set(key, {
|
|
7615
|
+
key,
|
|
7616
|
+
deviceId: seg.deviceId,
|
|
7617
|
+
profile: seg.profile,
|
|
7618
|
+
locationId: seg.locationId,
|
|
7619
|
+
hourStartMs: hour,
|
|
7620
|
+
paths: [seg.path]
|
|
7621
|
+
});
|
|
7622
|
+
else fromIndex.set(key, {
|
|
7623
|
+
...existing,
|
|
7624
|
+
paths: [...existing.paths, seg.path]
|
|
7625
|
+
});
|
|
7626
|
+
}
|
|
7627
|
+
for (const row of this.ledger.snapshot()) {
|
|
7628
|
+
if (!walked.has(row.deviceId)) continue;
|
|
7629
|
+
const disk = fromIndex.get(row.key);
|
|
7630
|
+
if (disk === void 0) {
|
|
7631
|
+
if (row.hourStartMs === currentHour) continue;
|
|
7632
|
+
await this.ledger.forget(row.key);
|
|
7633
|
+
continue;
|
|
7634
|
+
}
|
|
7635
|
+
if (row.hourStartMs === currentHour) {
|
|
7636
|
+
const union = uniquePaths([...disk.paths, ...row.paths]);
|
|
7637
|
+
if (!samePaths(union, row.paths)) await this.ledger.put({
|
|
7638
|
+
...row,
|
|
7639
|
+
paths: union
|
|
7640
|
+
});
|
|
7641
|
+
continue;
|
|
7642
|
+
}
|
|
7643
|
+
if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
|
|
7644
|
+
}
|
|
7645
|
+
for (const [key, disk] of fromIndex) {
|
|
7646
|
+
if (this.ledger.has(key)) continue;
|
|
7647
|
+
await this.ledger.put(disk);
|
|
7648
|
+
}
|
|
7649
|
+
}
|
|
7650
|
+
};
|
|
7651
|
+
//#endregion
|
|
7043
7652
|
//#region src/recorder/addon/recording-export-provider.ts
|
|
7044
7653
|
/** Content type of every rendered export — the engine only ever writes MP4. */
|
|
7045
7654
|
var EXPORT_CONTENT_TYPE = "video/mp4";
|
|
@@ -7861,6 +8470,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
7861
8470
|
isRecordingNode = true;
|
|
7862
8471
|
index = new RecordingIndex();
|
|
7863
8472
|
/**
|
|
8473
|
+
* Durable hour-directory copy of {@link index}. Null on an inert
|
|
8474
|
+
* (non-recording) node. Boot reseeds the RAM index from this; the
|
|
8475
|
+
* deferred archive walk reconciles it against disk.
|
|
8476
|
+
*/
|
|
8477
|
+
segmentHours = null;
|
|
8478
|
+
/**
|
|
7864
8479
|
* The calendar rungs — which days, which hours, and a day's exact ranges.
|
|
7865
8480
|
*
|
|
7866
8481
|
* Separate from `RecordingIndex` on purpose: that one is the SEGMENT model
|
|
@@ -7932,6 +8547,16 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
7932
8547
|
} });
|
|
7933
8548
|
return { providers: [] };
|
|
7934
8549
|
}
|
|
8550
|
+
this.segmentHours = new SegmentHourLedger({
|
|
8551
|
+
store: this.ctx.api.settingsStore,
|
|
8552
|
+
logger: this.ctx.logger
|
|
8553
|
+
});
|
|
8554
|
+
try {
|
|
8555
|
+
await this.segmentHours.declare();
|
|
8556
|
+
} catch (err) {
|
|
8557
|
+
this.ctx.logger.warn("recorder: segment-hour ledger declare failed — index stays RAM-only this boot", { meta: { error: require_dist.errMsg(err) } });
|
|
8558
|
+
this.segmentHours = null;
|
|
8559
|
+
}
|
|
7935
8560
|
const ingestOwner = await this.resolveIngestOwner();
|
|
7936
8561
|
const consumerHostname = this.nodeId === ingestOwner.ownerNodeId ? void 0 : ingestOwner.reachableHost ?? resolveRecordingHubHostname(this.config.recordingHubHostname, process.env["CAMSTACK_HUB_URL"]);
|
|
7937
8562
|
if (this.nodeId !== ingestOwner.ownerNodeId) {
|
|
@@ -7968,7 +8593,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
7968
8593
|
onEvicted: (deviceId, atMs) => {
|
|
7969
8594
|
this.calendar.invalidate(deviceId, atMs);
|
|
7970
8595
|
},
|
|
8596
|
+
onDeindexed: (rows) => {
|
|
8597
|
+
this.segmentHours?.dropSegments(rows);
|
|
8598
|
+
},
|
|
7971
8599
|
onIndexed: (row, absPath) => {
|
|
8600
|
+
this.segmentHours?.recordSegment(row);
|
|
7972
8601
|
if (!isMfraTableServable(row.startMs, Date.now())) return;
|
|
7973
8602
|
readMfraTable(absPath, row.bytes).then((table) => {
|
|
7974
8603
|
this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
|
|
@@ -8087,6 +8716,9 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
8087
8716
|
refreshLocations: () => this.refreshLocations(),
|
|
8088
8717
|
capacity: (root) => this.locationCapacity(root),
|
|
8089
8718
|
hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
|
|
8719
|
+
reconcileHourLedger: async (deviceId) => {
|
|
8720
|
+
await this.segmentHours?.reconcileFromIndex(this.index, [deviceId], Date.now());
|
|
8721
|
+
},
|
|
8090
8722
|
calendar: this.calendar,
|
|
8091
8723
|
mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
|
|
8092
8724
|
hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
|
|
@@ -8345,6 +8977,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
8345
8977
|
this.controller?.onTrigger(deviceId, "motion", atMs);
|
|
8346
8978
|
}
|
|
8347
8979
|
});
|
|
8980
|
+
try {
|
|
8981
|
+
await this.segmentHours?.load();
|
|
8982
|
+
this.segmentHours?.hydrateIndex(this.index);
|
|
8983
|
+
} catch (err) {
|
|
8984
|
+
this.ctx.logger.warn("recorder: segment-hour ledger load failed — falling through to the archive walk", { meta: { error: require_dist.errMsg(err) } });
|
|
8985
|
+
}
|
|
8348
8986
|
let configs;
|
|
8349
8987
|
try {
|
|
8350
8988
|
configs = await readDeviceConfigs(this.configStore());
|
|
@@ -8832,6 +9470,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
8832
9470
|
locationCount: this.resolvedLocations.length,
|
|
8833
9471
|
ms: Date.now() - hydrateStartedMs
|
|
8834
9472
|
} });
|
|
9473
|
+
try {
|
|
9474
|
+
await this.segmentHours?.reconcileFromIndex(this.index, deviceIds, Date.now());
|
|
9475
|
+
} catch (err) {
|
|
9476
|
+
this.ctx.logger.warn("recorder: segment-hour ledger reconcile failed — walk remains the authority", { meta: { error: require_dist.errMsg(err) } });
|
|
9477
|
+
}
|
|
8835
9478
|
const loggedDomains = /* @__PURE__ */ new Set();
|
|
8836
9479
|
for (const loc of this.resolvedLocations) {
|
|
8837
9480
|
const domain = this.locationDomain(loc.id);
|