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