@camstack/addon-pipeline 1.2.99 → 1.2.101
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/recorder/index.js +370 -130
- package/dist/recorder/index.mjs +371 -131
- package/dist/retire-root-keys-DMolfhsP.mjs +308 -0
- package/dist/retire-root-keys-KE6D6Xh_.js +319 -0
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B7ERJocD.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C4fRkYcj.mjs} +1 -1
- package/dist/stream-broker/{hostInit-Cm2rl8-e.mjs → hostInit-CS6xpCdb.mjs} +1 -1
- package/dist/stream-broker/index.js +284 -40
- package/dist/stream-broker/index.mjs +284 -40
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { $ as recordingExportCapability, C as RecordingConfigSchema, Ct as nodePin, Et as selectAssignedProfileSlots, Ft as object, It as record, L as deriveRecordingMode, Lt as string, Pt as number, Q as recordingCapability, S as RECORDING_EXPORT_MAX_READ_BYTES, _t as DeviceType, ct as errMsg, f as EVENT_PAD_MS, it as storageEvictableCapability,
|
|
1
|
+
import { $ as recordingExportCapability, C as RecordingConfigSchema, Ct as nodePin, Et as selectAssignedProfileSlots, Ft as object, It as record, L as deriveRecordingMode, Lt as string, Pt as number, Q as recordingCapability, S as RECORDING_EXPORT_MAX_READ_BYTES, _t as DeviceType, ct as errMsg, f as EVENT_PAD_MS, it as storageEvictableCapability, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, mt as BaseAddon, nt as resolveRecordingProfiles, v as OpsLogEntrySchema, yt as hydrateSchema, zt as EventCategory } from "../dist-gXdWP96z.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
|
+
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DMolfhsP.mjs";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { spawn } from "node:child_process";
|
|
6
7
|
import path from "node:path";
|
|
@@ -1373,6 +1374,17 @@ function encodeDirectory(payload) {
|
|
|
1373
1374
|
}
|
|
1374
1375
|
//#endregion
|
|
1375
1376
|
//#region src/recorder/segment-store.ts
|
|
1377
|
+
/**
|
|
1378
|
+
* Below this a file cannot be a media segment: one fMP4 `moof` + `mdat`
|
|
1379
|
+
* carrying a single sample already exceeds this by an order of magnitude.
|
|
1380
|
+
* What it actually catches is the structural stub — ffmpeg opens the next
|
|
1381
|
+
* segment the instant it closes the previous one, so a killed writer always
|
|
1382
|
+
* leaves one just-opened file (observed: 28-byte bare `styp` boxes). The boot
|
|
1383
|
+
* recovery plans by NAME and would otherwise index it with a nominal duration,
|
|
1384
|
+
* painting the timeline over footage that decodes to nothing — the same
|
|
1385
|
+
* failure `MAX_GAP_FACTOR` in staging-reconcile exists to prevent via gaps.
|
|
1386
|
+
*/
|
|
1387
|
+
var MIN_INDEXABLE_SEGMENT_BYTES = 64;
|
|
1376
1388
|
var SegmentStore = class SegmentStore {
|
|
1377
1389
|
deps;
|
|
1378
1390
|
constructor(deps) {
|
|
@@ -1396,7 +1408,16 @@ var SegmentStore = class SegmentStore {
|
|
|
1396
1408
|
});
|
|
1397
1409
|
return null;
|
|
1398
1410
|
}
|
|
1399
|
-
if (bytes
|
|
1411
|
+
if (bytes < MIN_INDEXABLE_SEGMENT_BYTES) {
|
|
1412
|
+
this.deps.logger.warn("segment too small to index — left in staging", {
|
|
1413
|
+
tags: { deviceId: input.deviceId },
|
|
1414
|
+
meta: {
|
|
1415
|
+
bytes,
|
|
1416
|
+
path: input.flatAbsPath
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1400
1421
|
const relPath = segmentRelPath(input.deviceId, input.profile, input.startMs, input.durMs, bytes);
|
|
1401
1422
|
const toAbs = `${input.locationRoot}/${relPath}`;
|
|
1402
1423
|
try {
|
|
@@ -2460,70 +2481,75 @@ var CalendarIndex = class {
|
|
|
2460
2481
|
//#endregion
|
|
2461
2482
|
//#region src/recorder/addon/config-store.ts
|
|
2462
2483
|
/**
|
|
2463
|
-
*
|
|
2464
|
-
*
|
|
2465
|
-
*
|
|
2466
|
-
*
|
|
2467
|
-
*
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2484
|
+
* @durable class=config owner=recorder
|
|
2485
|
+
* write="one row per camera, written by `saveDeviceConfig` (the `recording` cap's
|
|
2486
|
+
* setConfig / band authoring) — the only writer"
|
|
2487
|
+
* retention="none by age. A row goes only when the operator deletes the camera's
|
|
2488
|
+
* configuration; an unconfigured camera has no row and reads as DEFAULT_DEVICE_CONFIG"
|
|
2489
|
+
*/
|
|
2490
|
+
var RECORDING_CONFIGS_COLLECTION = "recorder:recording-configs";
|
|
2491
|
+
var RECORDING_CONFIGS_SPEC = {
|
|
2492
|
+
collection: RECORDING_CONFIGS_COLLECTION,
|
|
2493
|
+
schema: RecordingConfigSchema.strip(),
|
|
2494
|
+
columns: [
|
|
2495
|
+
{
|
|
2496
|
+
name: "deviceId",
|
|
2497
|
+
type: "INTEGER",
|
|
2498
|
+
notNull: true
|
|
2499
|
+
},
|
|
2500
|
+
{
|
|
2501
|
+
name: "enabled",
|
|
2502
|
+
type: "BOOLEAN",
|
|
2503
|
+
notNull: true
|
|
2504
|
+
},
|
|
2505
|
+
{
|
|
2506
|
+
name: "mode",
|
|
2507
|
+
type: "TEXT"
|
|
2508
|
+
}
|
|
2509
|
+
],
|
|
2510
|
+
project: (key, value) => ({
|
|
2511
|
+
deviceId: Number(key),
|
|
2512
|
+
enabled: value.enabled,
|
|
2513
|
+
mode: value.mode ?? null
|
|
2514
|
+
}),
|
|
2515
|
+
deviceIdColumn: "deviceId",
|
|
2516
|
+
loadLimit: 2e4
|
|
2517
|
+
};
|
|
2495
2518
|
/** Cold-start default for a camera that has never been configured: disabled, no bands. */
|
|
2496
2519
|
var DEFAULT_DEVICE_CONFIG = {
|
|
2497
2520
|
enabled: false,
|
|
2498
2521
|
bands: []
|
|
2499
2522
|
};
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2523
|
+
/** Build the handle. `declare()` MUST run once at boot before any read/write. */
|
|
2524
|
+
function recordingConfigsState(store, logger) {
|
|
2525
|
+
return new RowMapStore({
|
|
2526
|
+
spec: RECORDING_CONFIGS_SPEC,
|
|
2527
|
+
store,
|
|
2528
|
+
logger
|
|
2529
|
+
});
|
|
2504
2530
|
}
|
|
2505
2531
|
/** Read every persisted device config, keyed by numeric deviceId. */
|
|
2506
2532
|
async function readDeviceConfigs(state) {
|
|
2507
|
-
const
|
|
2533
|
+
const rows = await state.readAll();
|
|
2508
2534
|
const map = /* @__PURE__ */ new Map();
|
|
2509
|
-
for (const [key, config] of
|
|
2535
|
+
for (const [key, config] of rows) {
|
|
2510
2536
|
const id = Number(key);
|
|
2511
2537
|
if (Number.isInteger(id)) map.set(id, config);
|
|
2512
2538
|
}
|
|
2513
2539
|
return map;
|
|
2514
2540
|
}
|
|
2515
2541
|
/**
|
|
2516
|
-
* The persisted config for a device — `bands` is always
|
|
2517
|
-
* schema defaults it to `[]`). An unconfigured device returns
|
|
2542
|
+
* The persisted config for a device — ONE keyed row read. `bands` is always
|
|
2543
|
+
* materialized (the schema defaults it to `[]`). An unconfigured device returns
|
|
2518
2544
|
* {@link DEFAULT_DEVICE_CONFIG}.
|
|
2519
2545
|
*/
|
|
2520
2546
|
async function loadDeviceConfig(state, deviceId) {
|
|
2521
|
-
return
|
|
2547
|
+
return await state.read(String(deviceId)) ?? DEFAULT_DEVICE_CONFIG;
|
|
2522
2548
|
}
|
|
2523
2549
|
/**
|
|
2524
2550
|
* The devices whose stored config claims to record (`enabled` + a non-`off`
|
|
2525
2551
|
* derived `mode`) but has no band left — the one shape this store cannot
|
|
2526
|
-
* repair. It can only come from a pre-retirement
|
|
2552
|
+
* repair. It can only come from a pre-retirement config whose legacy authoring
|
|
2527
2553
|
* fields were stripped on read: the camera records NOTHING until its bands are
|
|
2528
2554
|
* re-authored, so the recorder shouts about it once at boot
|
|
2529
2555
|
* ({@link warnLostBands}) instead of failing silently.
|
|
@@ -2540,7 +2566,7 @@ async function warnLostBands(state, logger) {
|
|
|
2540
2566
|
logger.warn(`recording config: device(s) ${lost.join(", ")} claim a recording mode but have NO bands — a pre-retirement config that lost its legacy authoring fields. Nothing is recorded for them until their bands are re-authored.`);
|
|
2541
2567
|
}
|
|
2542
2568
|
/**
|
|
2543
|
-
* Persist (insert or replace) a device's config. Validates against
|
|
2569
|
+
* Persist (insert or replace) a device's config — ONE row. Validates against
|
|
2544
2570
|
* `RecordingConfigSchema` at the boundary (untrusted cap input). Returns the
|
|
2545
2571
|
* validated config that was persisted.
|
|
2546
2572
|
*/
|
|
@@ -2550,9 +2576,7 @@ async function saveDeviceConfig(state, deviceId, config) {
|
|
|
2550
2576
|
...parsed,
|
|
2551
2577
|
mode: parsed.enabled ? activeModeForConfig(parsed) : "off"
|
|
2552
2578
|
};
|
|
2553
|
-
|
|
2554
|
-
map.set(deviceId, validated);
|
|
2555
|
-
await state.set(serialize$1(map));
|
|
2579
|
+
await state.write(String(deviceId), validated);
|
|
2556
2580
|
return validated;
|
|
2557
2581
|
}
|
|
2558
2582
|
/**
|
|
@@ -2919,61 +2943,122 @@ function buildExportArgs(input) {
|
|
|
2919
2943
|
/**
|
|
2920
2944
|
* Recording-export persistence for the recorder addon.
|
|
2921
2945
|
*
|
|
2922
|
-
*
|
|
2923
|
-
*
|
|
2924
|
-
*
|
|
2925
|
-
*
|
|
2926
|
-
*
|
|
2927
|
-
*
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
* The
|
|
2931
|
-
*
|
|
2932
|
-
*
|
|
2933
|
-
*
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2946
|
+
* ONE ROW PER EXPORT in `recorder:exports`, through {@link RowMapStore}. History
|
|
2947
|
+
* rows are never removed by the system (audit) — `deleteExport` / the janitor
|
|
2948
|
+
* flip `state` and clear the file, keeping the row.
|
|
2949
|
+
*
|
|
2950
|
+
* ## What this replaced, and why it mattered here more than anywhere
|
|
2951
|
+
*
|
|
2952
|
+
* Every job used to live in one key of the recorder's `addon-settings` / `root`
|
|
2953
|
+
* blob — 201 833 bytes on the reference hub, of which the export history was
|
|
2954
|
+
* 50 441. The engine patches `progressPct` roughly once a second for an
|
|
2955
|
+
* in-flight render, so moving one integer read, parsed, re-serialised and
|
|
2956
|
+
* rewrote 201 KB, per second.
|
|
2957
|
+
*
|
|
2958
|
+
* Worse than the cost was the CLOBBER. Both halves of a read-modify-write are
|
|
2959
|
+
* async, so two overlapping cycles read the same snapshot and the second `set`
|
|
2960
|
+
* discarded whatever the first added. On 2026-08-14 a `createExport` for camera
|
|
2961
|
+
* 615 landed between one progress patch's read and its write, the row was
|
|
2962
|
+
* erased, and the poll answered `unknown export "547ea182-…"`; the scheduler
|
|
2963
|
+
* read the vanished row as a failed render and re-queued forever. A per-row
|
|
2964
|
+
* upsert makes that class of loss unrepresentable — a write names ONE row.
|
|
2965
|
+
*
|
|
2966
|
+
* What is still needed is a per-ID serialiser: two patches to the SAME export
|
|
2967
|
+
* still read-modify-write the same row. That is what {@link serialize} bounds,
|
|
2968
|
+
* and it is now per (store, id) rather than per store, so two exports never
|
|
2969
|
+
* queue behind each other.
|
|
2970
|
+
*/
|
|
2971
|
+
/**
|
|
2972
|
+
* @durable class=ledger owner=recorder
|
|
2973
|
+
* write="one row per export job, inserted by `createExport` and rewritten by the
|
|
2974
|
+
* render engine's progress/state patches and by the download hook"
|
|
2975
|
+
* retention="the SYSTEM never deletes a row — it is the export AUDIT. `deleteExport`
|
|
2976
|
+
* and the janitor flip `state` to 'deleted'/'expired' and remove the FILE, keeping
|
|
2977
|
+
* the row. `removeExport` exists for an operator-driven purge and nothing calls it
|
|
2978
|
+
* on a schedule."
|
|
2979
|
+
*/
|
|
2980
|
+
var RECORDING_EXPORTS_COLLECTION = "recorder:exports";
|
|
2981
|
+
/**
|
|
2982
|
+
* Bound for the whole-history read. Explicit because `settings-store.query`
|
|
2983
|
+
* caps an unbounded read at 2 000 rows SILENTLY, and an export list that
|
|
2984
|
+
* silently loses its oldest half looks exactly like a purge.
|
|
2985
|
+
*/
|
|
2986
|
+
var RECORDING_EXPORTS_LIMIT = 2e4;
|
|
2987
|
+
var RECORDING_EXPORTS_SPEC = {
|
|
2988
|
+
collection: RECORDING_EXPORTS_COLLECTION,
|
|
2989
|
+
schema: ExportRecordSchema,
|
|
2990
|
+
columns: [
|
|
2991
|
+
{
|
|
2992
|
+
name: "deviceId",
|
|
2993
|
+
type: "INTEGER",
|
|
2994
|
+
notNull: true
|
|
2995
|
+
},
|
|
2996
|
+
{
|
|
2997
|
+
name: "createdAt",
|
|
2998
|
+
type: "INTEGER",
|
|
2999
|
+
notNull: true
|
|
3000
|
+
},
|
|
3001
|
+
{
|
|
3002
|
+
name: "state",
|
|
3003
|
+
type: "TEXT",
|
|
3004
|
+
notNull: true
|
|
3005
|
+
}
|
|
3006
|
+
],
|
|
3007
|
+
indexes: [{
|
|
3008
|
+
name: "idx_recorder_exports_device",
|
|
3009
|
+
columns: ["deviceId"]
|
|
3010
|
+
}, {
|
|
3011
|
+
name: "idx_recorder_exports_created",
|
|
3012
|
+
columns: ["createdAt"]
|
|
3013
|
+
}],
|
|
3014
|
+
project: (_key, value) => ({
|
|
3015
|
+
deviceId: value.deviceId,
|
|
3016
|
+
createdAt: value.createdAt,
|
|
3017
|
+
state: value.state
|
|
3018
|
+
}),
|
|
3019
|
+
deviceIdColumn: "deviceId",
|
|
3020
|
+
loadLimit: RECORDING_EXPORTS_LIMIT
|
|
3021
|
+
};
|
|
3022
|
+
/** Build the handle. `declare()` MUST run once at boot before any read/write. */
|
|
3023
|
+
function recordingExportsState(store, logger) {
|
|
3024
|
+
return new RowMapStore({
|
|
3025
|
+
spec: RECORDING_EXPORTS_SPEC,
|
|
3026
|
+
store,
|
|
3027
|
+
logger
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
2938
3030
|
/**
|
|
2939
|
-
* One in-flight mutation chain per
|
|
2940
|
-
*
|
|
2941
|
-
* EVERY mutation here is a read-modify-write of the SAME blob, and both halves
|
|
2942
|
-
* are async — so two overlapping cycles read the same snapshot and the second
|
|
2943
|
-
* `set` discards whatever the first one added. Last writer wins, silently.
|
|
3031
|
+
* One in-flight mutation chain per (handle, export id).
|
|
2944
3032
|
*
|
|
2945
|
-
*
|
|
2946
|
-
*
|
|
2947
|
-
*
|
|
2948
|
-
*
|
|
2949
|
-
*
|
|
2950
|
-
*
|
|
3033
|
+
* A row upsert removed the cross-export clobber; it does not order two
|
|
3034
|
+
* read-modify-writes of the SAME row. `patchExport` reads the row, merges and
|
|
3035
|
+
* writes — so two overlapping patches on one export still race, and the loser's
|
|
3036
|
+
* fields vanish. Serialising per ID keeps those pairs atomic while leaving
|
|
3037
|
+
* unrelated exports fully concurrent (the old chain was per HANDLE, so a slow
|
|
3038
|
+
* patch queued every other export behind it).
|
|
2951
3039
|
*
|
|
2952
|
-
*
|
|
2953
|
-
*
|
|
2954
|
-
* so two recorders in one process never contend. A `WeakMap` keeps a disposed
|
|
2955
|
-
* handle collectable. Reads stay OUTSIDE the chain — a blob replacement is
|
|
2956
|
-
* atomic, so a reader always sees one consistent generation.
|
|
3040
|
+
* A `WeakMap` keyed by the handle keeps a disposed store collectable; the inner
|
|
3041
|
+
* map is keyed by export id.
|
|
2957
3042
|
*/
|
|
2958
3043
|
var mutationChains = /* @__PURE__ */ new WeakMap();
|
|
2959
|
-
/** Run `work` after every mutation already queued for `state`, never before. */
|
|
2960
|
-
async function serialize(state, work) {
|
|
2961
|
-
const
|
|
2962
|
-
|
|
3044
|
+
/** Run `work` after every mutation already queued for `(state, id)`, never before. */
|
|
3045
|
+
async function serialize(state, id, work) {
|
|
3046
|
+
const existing = mutationChains.get(state);
|
|
3047
|
+
const queued = existing ?? /* @__PURE__ */ new Map();
|
|
3048
|
+
if (existing === void 0) mutationChains.set(state, queued);
|
|
3049
|
+
const next = (queued.get(id) ?? Promise.resolve()).then(work, work);
|
|
3050
|
+
const settled = next.catch(() => void 0);
|
|
3051
|
+
queued.set(id, settled);
|
|
3052
|
+
settled.then(() => {
|
|
3053
|
+
if (queued.get(id) === settled) queued.delete(id);
|
|
3054
|
+
});
|
|
2963
3055
|
return next;
|
|
2964
3056
|
}
|
|
2965
|
-
/** Read every persisted export row, keyed by export id. */
|
|
2966
|
-
async function readExports(state) {
|
|
2967
|
-
const blob = await state.get();
|
|
2968
|
-
return new Map(Object.entries(blob));
|
|
2969
|
-
}
|
|
2970
3057
|
/** Insert or replace one export row. Validates at the boundary. Returns it. */
|
|
2971
3058
|
async function upsertExport(state, record) {
|
|
2972
3059
|
const validated = ExportRecordSchema.parse(record);
|
|
2973
|
-
return serialize(state, async () => {
|
|
2974
|
-
|
|
2975
|
-
map.set(validated.id, validated);
|
|
2976
|
-
await state.set(Object.fromEntries(map));
|
|
3060
|
+
return serialize(state, validated.id, async () => {
|
|
3061
|
+
await state.write(validated.id, validated);
|
|
2977
3062
|
return validated;
|
|
2978
3063
|
});
|
|
2979
3064
|
}
|
|
@@ -2982,28 +3067,37 @@ async function upsertExport(state, record) {
|
|
|
2982
3067
|
* record, or null when the id is unknown.
|
|
2983
3068
|
*/
|
|
2984
3069
|
async function patchExport(state, id, patch) {
|
|
2985
|
-
return serialize(state, async () => {
|
|
2986
|
-
const
|
|
2987
|
-
|
|
2988
|
-
if (!existing) return null;
|
|
3070
|
+
return serialize(state, id, async () => {
|
|
3071
|
+
const existing = await state.read(id);
|
|
3072
|
+
if (existing === null) return null;
|
|
2989
3073
|
const merged = ExportRecordSchema.parse({
|
|
2990
3074
|
...existing,
|
|
2991
3075
|
...patch
|
|
2992
3076
|
});
|
|
2993
|
-
|
|
2994
|
-
await state.set(Object.fromEntries(map));
|
|
3077
|
+
await state.write(id, merged);
|
|
2995
3078
|
return merged;
|
|
2996
3079
|
});
|
|
2997
3080
|
}
|
|
2998
|
-
/** Read one export row by id
|
|
3081
|
+
/** Read one export row by id — ONE keyed row read. */
|
|
2999
3082
|
async function getExportRecord(state, id) {
|
|
3000
|
-
return
|
|
3083
|
+
return state.read(id);
|
|
3001
3084
|
}
|
|
3002
3085
|
/**
|
|
3003
3086
|
* List export rows, newest-first, optionally scoped to one device.
|
|
3087
|
+
*
|
|
3088
|
+
* Scoped to a device this is an INDEXED query on the projected `deviceId`
|
|
3089
|
+
* column — not a parse of every export in the installation followed by a filter
|
|
3090
|
+
* in JS.
|
|
3004
3091
|
*/
|
|
3005
3092
|
async function listExportsFor(state, deviceId) {
|
|
3006
|
-
return
|
|
3093
|
+
return (await state.readWhere({
|
|
3094
|
+
...deviceId === void 0 ? {} : { where: { deviceId } },
|
|
3095
|
+
orderBy: {
|
|
3096
|
+
field: "createdAt",
|
|
3097
|
+
direction: "desc"
|
|
3098
|
+
},
|
|
3099
|
+
limit: RECORDING_EXPORTS_LIMIT
|
|
3100
|
+
})).map((entry) => entry.value);
|
|
3007
3101
|
}
|
|
3008
3102
|
//#endregion
|
|
3009
3103
|
//#region src/recorder/addon/spawn-failure.ts
|
|
@@ -4770,39 +4864,117 @@ function runFfmpeg(renderDeps, args, ext) {
|
|
|
4770
4864
|
/**
|
|
4771
4865
|
* Recordings ops-log persistence for the recorder addon.
|
|
4772
4866
|
*
|
|
4773
|
-
*
|
|
4774
|
-
*
|
|
4775
|
-
*
|
|
4776
|
-
*
|
|
4777
|
-
*
|
|
4778
|
-
*
|
|
4867
|
+
* ONE ROW PER OPERATION in `recorder:ops-log`, through {@link RowMapStore}. The
|
|
4868
|
+
* ring is still bounded — `maxEntries` newest rows survive — but the eviction
|
|
4869
|
+
* is now a single `DELETE … WHERE at BETWEEN`, and a listing is an indexed
|
|
4870
|
+
* `WHERE deviceId … ORDER BY at DESC LIMIT`.
|
|
4871
|
+
*
|
|
4872
|
+
* ## What this replaced
|
|
4873
|
+
*
|
|
4874
|
+
* The ring lived in ONE key of the recorder's `addon-settings` / `root` blob,
|
|
4875
|
+
* as a flat JSON array. On the reference hub it was 147 437 bytes of that row's
|
|
4876
|
+
* 201 833 — so appending one audit line read, parsed, re-serialised and rewrote
|
|
4877
|
+
* ~200 KB, and so did every unrelated reader of that row (`resolveConfig`,
|
|
4878
|
+
* `loadDeviceConfig`, the export progress patch). The ops log is the CHEAPEST
|
|
4879
|
+
* thing in the recorder and it was paying for the most expensive.
|
|
4880
|
+
*
|
|
4881
|
+
* Writes stay BEST-EFFORT: `createRecordingOpsLogSink().append` never throws —
|
|
4882
|
+
* a failed audit write must not fail the operation it records.
|
|
4883
|
+
*/
|
|
4884
|
+
/**
|
|
4885
|
+
* @durable class=audit owner=recorder
|
|
4886
|
+
* write="one row per recordings operation (prune, evict, relocate, export, …), appended
|
|
4887
|
+
* best-effort by `createRecordingOpsLogSink().append` — never read back by the system,
|
|
4888
|
+
* only listed by an operator through the `recording.listOpsLog` cap method"
|
|
4889
|
+
* retention="a bounded ring: every append trims to the newest OPS_LOG_RING_DEFAULT_MAX
|
|
4890
|
+
* (500) rows with one `deleteWhere` on `at`. Rows tied on the cutoff millisecond
|
|
4891
|
+
* survive, so the ring may hold a few MORE than the cap and never fewer."
|
|
4892
|
+
*/
|
|
4893
|
+
var RECORDING_OPS_LOG_COLLECTION = "recorder:ops-log";
|
|
4894
|
+
var RECORDING_OPS_LOG_SPEC = {
|
|
4895
|
+
collection: RECORDING_OPS_LOG_COLLECTION,
|
|
4896
|
+
schema: OpsLogEntrySchema,
|
|
4897
|
+
columns: [{
|
|
4898
|
+
name: "at",
|
|
4899
|
+
type: "INTEGER",
|
|
4900
|
+
notNull: true
|
|
4901
|
+
}, {
|
|
4902
|
+
name: "deviceId",
|
|
4903
|
+
type: "INTEGER"
|
|
4904
|
+
}],
|
|
4905
|
+
indexes: [{
|
|
4906
|
+
name: "idx_recorder_ops_log_at",
|
|
4907
|
+
columns: ["at"]
|
|
4908
|
+
}, {
|
|
4909
|
+
name: "idx_recorder_ops_log_device",
|
|
4910
|
+
columns: ["deviceId"]
|
|
4911
|
+
}],
|
|
4912
|
+
project: (_key, value) => ({
|
|
4913
|
+
at: value.at,
|
|
4914
|
+
deviceId: value.deviceId
|
|
4915
|
+
}),
|
|
4916
|
+
deviceIdColumn: "deviceId",
|
|
4917
|
+
loadLimit: 2e4
|
|
4918
|
+
};
|
|
4919
|
+
/** Build the handle. `declare()` MUST run once at boot before any read/write. */
|
|
4920
|
+
function recordingOpsLogState(store, logger) {
|
|
4921
|
+
return new RowMapStore({
|
|
4922
|
+
spec: RECORDING_OPS_LOG_SPEC,
|
|
4923
|
+
store,
|
|
4924
|
+
logger
|
|
4925
|
+
});
|
|
4926
|
+
}
|
|
4927
|
+
/**
|
|
4928
|
+
* Trim the ring to the newest `maxEntries` rows. Returns how many went.
|
|
4929
|
+
*
|
|
4930
|
+
* The cursor is the OLDEST row that must SURVIVE — offset `maxEntries - 1`
|
|
4931
|
+
* newest-first — and the delete is strictly older than it (`[0, at - 1]`).
|
|
4779
4932
|
*
|
|
4780
|
-
*
|
|
4781
|
-
*
|
|
4933
|
+
* Strictly, because `whereBetween` is inclusive and the filter surface has no
|
|
4934
|
+
* `<`: including the cursor's own millisecond would take every row tied on it,
|
|
4935
|
+
* and a ring whose rows all shared one millisecond would be deleted whole.
|
|
4936
|
+
* Erring this way keeps a few EXTRA audit rows on a tie and never fewer than
|
|
4937
|
+
* the cap.
|
|
4782
4938
|
*/
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4939
|
+
async function trimOpsLog(state, maxEntries = 500) {
|
|
4940
|
+
if (await state.count() <= maxEntries) return 0;
|
|
4941
|
+
const oldestKept = (await state.readWhere({
|
|
4942
|
+
orderBy: {
|
|
4943
|
+
field: "at",
|
|
4944
|
+
direction: "desc"
|
|
4945
|
+
},
|
|
4946
|
+
limit: 1,
|
|
4947
|
+
offset: maxEntries - 1
|
|
4948
|
+
}))[0];
|
|
4949
|
+
if (oldestKept === void 0) return 0;
|
|
4950
|
+
return state.deleteWhere({ whereBetween: { at: [0, oldestKept.value.at - 1] } });
|
|
4951
|
+
}
|
|
4787
4952
|
/**
|
|
4788
|
-
* Append one row and persist,
|
|
4789
|
-
*
|
|
4790
|
-
* kept oldest-first on disk (the tail is newest); {@link listOpsLog} reverses.
|
|
4953
|
+
* Append one row and persist, then trim the ring. Validates at the boundary.
|
|
4954
|
+
* Returns the stored row.
|
|
4791
4955
|
*/
|
|
4792
4956
|
async function appendOpsLog(state, entry, maxEntries = 500) {
|
|
4793
4957
|
const validated = OpsLogEntrySchema.parse(entry);
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
await state.set(bounded);
|
|
4958
|
+
await state.write(validated.id, validated);
|
|
4959
|
+
await trimOpsLog(state, maxEntries);
|
|
4797
4960
|
return validated;
|
|
4798
4961
|
}
|
|
4799
4962
|
/**
|
|
4800
4963
|
* List ops-log rows newest-first, optionally scoped to one device, capped at
|
|
4801
4964
|
* `limit` (default {@link OPS_LOG_DEFAULT_LIMIT}).
|
|
4965
|
+
*
|
|
4966
|
+
* Scoped to a device this is an INDEXED query, not a read of the whole ring
|
|
4967
|
+
* followed by a filter and a sort in JS.
|
|
4802
4968
|
*/
|
|
4803
4969
|
async function listOpsLog(state, query) {
|
|
4804
|
-
|
|
4805
|
-
|
|
4970
|
+
return (await state.readWhere({
|
|
4971
|
+
...query.deviceId === void 0 ? {} : { where: { deviceId: query.deviceId } },
|
|
4972
|
+
orderBy: {
|
|
4973
|
+
field: "at",
|
|
4974
|
+
direction: "desc"
|
|
4975
|
+
},
|
|
4976
|
+
limit: query.limit ?? 200
|
|
4977
|
+
})).map((entry) => entry.value);
|
|
4806
4978
|
}
|
|
4807
4979
|
/**
|
|
4808
4980
|
* Build the recorder's ops-log sink. `append` stamps `domain:'recording'`,
|
|
@@ -7945,12 +8117,22 @@ var RecordingController = class {
|
|
|
7945
8117
|
}
|
|
7946
8118
|
/** Stop one profile's writer + watcher and release its broker lease. */
|
|
7947
8119
|
async teardownProfile(deviceId, r) {
|
|
8120
|
+
const teardownStartMs = Date.now();
|
|
7948
8121
|
try {
|
|
7949
8122
|
await r.writer.stopAndWait();
|
|
7950
8123
|
} catch {}
|
|
8124
|
+
const writerStopMs = Date.now() - teardownStartMs;
|
|
7951
8125
|
try {
|
|
7952
8126
|
await r.watcher.flush?.();
|
|
7953
8127
|
} catch {}
|
|
8128
|
+
this.deps.logger.info("recorder: profile teardown drained", {
|
|
8129
|
+
tags: { deviceId },
|
|
8130
|
+
meta: {
|
|
8131
|
+
profile: r.profile,
|
|
8132
|
+
writerStopMs,
|
|
8133
|
+
flushMs: Date.now() - teardownStartMs - writerStopMs
|
|
8134
|
+
}
|
|
8135
|
+
});
|
|
7954
8136
|
try {
|
|
7955
8137
|
r.watcher.stop();
|
|
7956
8138
|
} catch {}
|
|
@@ -8060,6 +8242,25 @@ async function readLocationCapacity(root, statfs) {
|
|
|
8060
8242
|
}
|
|
8061
8243
|
}
|
|
8062
8244
|
//#endregion
|
|
8245
|
+
//#region src/recorder/addon/retired-root-keys.ts
|
|
8246
|
+
var RECORDER_RETIRED_ROOT_KEYS = [
|
|
8247
|
+
{
|
|
8248
|
+
key: "recordingOpsLog",
|
|
8249
|
+
successor: RECORDING_OPS_LOG_COLLECTION,
|
|
8250
|
+
reason: "the audit ring is one row per operation since the flatten; as a blob it was 147 KB of the 201 KB root row, re-parsed by every unrelated reader of that row"
|
|
8251
|
+
},
|
|
8252
|
+
{
|
|
8253
|
+
key: "recordingExports",
|
|
8254
|
+
successor: RECORDING_EXPORTS_COLLECTION,
|
|
8255
|
+
reason: "one row per export job since the flatten; as a blob a once-a-second progress patch rewrote 201 KB, and an overlapping createExport erased the row (2026-08-14)"
|
|
8256
|
+
},
|
|
8257
|
+
{
|
|
8258
|
+
key: "recordingConfigs",
|
|
8259
|
+
successor: RECORDING_CONFIGS_COLLECTION,
|
|
8260
|
+
reason: "one row per camera since the flatten; as a blob, asking what camera 617 records parsed every camera in the installation"
|
|
8261
|
+
}
|
|
8262
|
+
];
|
|
8263
|
+
//#endregion
|
|
8063
8264
|
//#region src/durable/durable-ledger.ts
|
|
8064
8265
|
/** Default reseed cap — every current consumer's row set is installation-bounded. */
|
|
8065
8266
|
var DEFAULT_LOAD_LIMIT = 1e5;
|
|
@@ -9305,6 +9506,7 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9305
9506
|
return entries;
|
|
9306
9507
|
};
|
|
9307
9508
|
const walkStartedMs = Date.now();
|
|
9509
|
+
const segmentSecondsMap = await deps.segmentSecondsByDevice();
|
|
9308
9510
|
const work = [];
|
|
9309
9511
|
for (const location of locations) {
|
|
9310
9512
|
const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
|
|
@@ -9315,7 +9517,7 @@ async function recoverAllStagedOrphans(deps) {
|
|
|
9315
9517
|
if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
|
|
9316
9518
|
const profileDirs = await listPaced(`${stagingRoot}/${deviceDir}`);
|
|
9317
9519
|
if (profileDirs === null) continue;
|
|
9318
|
-
const segmentSeconds =
|
|
9520
|
+
const segmentSeconds = segmentSecondsMap.get(deviceId) ?? deps.defaultSegmentSeconds;
|
|
9319
9521
|
for (const profile of profileDirs) {
|
|
9320
9522
|
if (!KNOWN_PROFILES.has(profile)) continue;
|
|
9321
9523
|
work.push({
|
|
@@ -9622,6 +9824,8 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
9622
9824
|
} });
|
|
9623
9825
|
return { providers: [] };
|
|
9624
9826
|
}
|
|
9827
|
+
await this.declareRowCollections();
|
|
9828
|
+
await this.purgeFlattenedSettingsKeys();
|
|
9625
9829
|
this.segmentHours = new SegmentHourLedger({
|
|
9626
9830
|
store: this.ctx.api.settingsStore,
|
|
9627
9831
|
logger: this.ctx.logger,
|
|
@@ -10358,13 +10562,14 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
10358
10562
|
},
|
|
10359
10563
|
logger: this.ctx.logger,
|
|
10360
10564
|
bootMs,
|
|
10361
|
-
|
|
10565
|
+
segmentSecondsByDevice: async () => {
|
|
10566
|
+
const out = /* @__PURE__ */ new Map();
|
|
10362
10567
|
try {
|
|
10363
|
-
|
|
10364
|
-
} catch {
|
|
10365
|
-
|
|
10366
|
-
}
|
|
10568
|
+
for (const [id, config] of await readDeviceConfigs(this.configStore())) if (config.segmentSeconds !== void 0) out.set(id, config.segmentSeconds);
|
|
10569
|
+
} catch {}
|
|
10570
|
+
return out;
|
|
10367
10571
|
},
|
|
10572
|
+
defaultSegmentSeconds: this.config.segmentSeconds,
|
|
10368
10573
|
shouldStop: () => this.segmentStore === null
|
|
10369
10574
|
});
|
|
10370
10575
|
} catch (err) {
|
|
@@ -10589,17 +10794,52 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
10589
10794
|
this.fullWalkInFlight = false;
|
|
10590
10795
|
}
|
|
10591
10796
|
}
|
|
10797
|
+
/**
|
|
10798
|
+
* Declare the three row collections that replaced the `recordingConfigs`,
|
|
10799
|
+
* `recordingExports` and `recordingOpsLog` blob keys. Idempotent — a
|
|
10800
|
+
* re-declaration of the same shape is a no-op in the engine.
|
|
10801
|
+
*/
|
|
10802
|
+
async declareRowCollections() {
|
|
10803
|
+
await this.configStore().declare();
|
|
10804
|
+
await this.exportsStore().declare();
|
|
10805
|
+
await this.opsLogStore().declare();
|
|
10806
|
+
}
|
|
10807
|
+
/**
|
|
10808
|
+
* Drop the flattened keys from `recorder:addon-settings` / `root`, each gated
|
|
10809
|
+
* on its own successor collection being non-empty.
|
|
10810
|
+
*
|
|
10811
|
+
* `recordingPlacement` is NOT here: it stays a blob key. It is 1.4 KB of
|
|
10812
|
+
* per-(camera, profile) assignments plus a handful of volume ids, written
|
|
10813
|
+
* only when placement changes — and the row has to survive anyway, because it
|
|
10814
|
+
* is where `BaseAddon.resolveConfig` reads the operator's config fields from.
|
|
10815
|
+
* The problem was a 201 KB row, not a blob.
|
|
10816
|
+
*
|
|
10817
|
+
* Best-effort: a purge that throws must not take recording down. The keys are
|
|
10818
|
+
* dead weight, not a fault.
|
|
10819
|
+
*/
|
|
10820
|
+
async purgeFlattenedSettingsKeys() {
|
|
10821
|
+
try {
|
|
10822
|
+
await retireRootKeys({
|
|
10823
|
+
store: this.ctx.api.settingsStore,
|
|
10824
|
+
addonId: RECORDER_ADDON_ID,
|
|
10825
|
+
logger: this.ctx.logger,
|
|
10826
|
+
specs: RECORDER_RETIRED_ROOT_KEYS
|
|
10827
|
+
});
|
|
10828
|
+
} catch (err) {
|
|
10829
|
+
this.ctx.logger.warn("recorder: retiring the flattened settings keys failed", { meta: { error: errMsg(err) } });
|
|
10830
|
+
}
|
|
10831
|
+
}
|
|
10592
10832
|
configStore() {
|
|
10593
|
-
return this.
|
|
10833
|
+
return recordingConfigsState(this.ctx.api.settingsStore, this.ctx.logger);
|
|
10594
10834
|
}
|
|
10595
10835
|
/** Durable handle over the exportId→ExportRecord blob (same `BaseAddon.state()`
|
|
10596
10836
|
* wrapper as the config store). */
|
|
10597
10837
|
exportsStore() {
|
|
10598
|
-
return this.
|
|
10838
|
+
return recordingExportsState(this.ctx.api.settingsStore, this.ctx.logger);
|
|
10599
10839
|
}
|
|
10600
10840
|
/** Durable handle over the bounded recordings ops-log ring. */
|
|
10601
10841
|
opsLogStore() {
|
|
10602
|
-
return this.
|
|
10842
|
+
return recordingOpsLogState(this.ctx.api.settingsStore, this.ctx.logger);
|
|
10603
10843
|
}
|
|
10604
10844
|
/** Durable handle over the recorder-owned placement blob (assignments + volume ids). */
|
|
10605
10845
|
placementStore() {
|