@camstack/addon-pipeline 1.2.144 → 1.2.146
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +33 -32
- package/dist/detection-pipeline/index.mjs +5 -4
- package/dist/{dist-Dpy1KP1q.js → dist-DML3mpn9.js} +6 -6
- package/dist/{dist-uX-YDOrc.mjs → dist-cZopp8Xm.mjs} +6 -6
- package/dist/event-loop-stall-monitor-DXLNBMkY.mjs +138 -0
- package/dist/event-loop-stall-monitor-DaUBcb13.js +149 -0
- package/dist/{lazy-sharp-zDl97I3e.js → lazy-sharp-BzXIjfN8.js} +1 -1
- package/dist/{event-loop-stall-monitor-6YKQeg7_.mjs → local-frame-registry-Ctv_TzmF.mjs} +2 -139
- package/dist/{event-loop-stall-monitor-D947d97M.js → local-frame-registry-DO8-LuSo.js} +1 -150
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-DuMWMV01.mjs → node-Br6p9KvD.mjs} +1 -1
- package/dist/{node-v9ZAGK2b.js → node-OvVqe9eh.js} +1 -1
- package/dist/pipeline-runner/index.js +14 -13
- package/dist/pipeline-runner/index.mjs +5 -4
- package/dist/{process-memory-CNISjywZ.mjs → process-memory-DX25Z60Q.mjs} +1 -1
- package/dist/{process-memory-ClAfQ1Nl.js → process-memory-NWKrKfmi.js} +1 -1
- package/dist/recorder/index.js +563 -41
- package/dist/recorder/index.mjs +563 -41
- package/dist/{segment-demux-js-Bpedn-T8.js → segment-demux-js-CerfMroc.js} +1 -1
- package/dist/{segment-demux-js-CCMG51zw.mjs → segment-demux-js-DGmFQ1Zj.mjs} +1 -1
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-MRLUZgkR.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DQNgiTjz.mjs} +3 -3
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-w0ytBmjx.mjs → hostInit-NShcJE25.mjs} +3 -3
- package/dist/stream-broker/index.js +3 -3
- package/dist/stream-broker/index.mjs +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-Cd-G92Qt.mjs → worker-protocol-CUCQ1IPR.mjs} +1 -1
- package/dist/{worker-protocol-Cn5x8dbW.js → worker-protocol-Dt3zWCQY.js} +1 -1
- package/package.json +1 -1
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { $ as isFirstLevelMacroClass, $t as record, B as defineCustomActions, Bt as nodePin, E as RecordingConfigSchema, It as hydrateSchema, J as evaluateSensorEdge, Kt as array, L as customAction, N as batteryCapability, Nt as DeviceFeature, Pt as DeviceType, Qt as object, T as RECORDING_EXPORT_MAX_READ_BYTES, U as deriveRecordingMode, Ut as selectAssignedProfileSlots, V as deriveBatteryPresence, Zt as number, bt as storageEvictableCapability, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, en as string, et as loadContributionCapability, g as ExportRecordSchema, i as BatteryStatusSchema, jt as BaseAddon, mt as recordingExportCapability, nn as EventCategory, pt as recordingCapability, qt as boolean, vt as resolveRecordingProfiles, wt as errMsg, x as OpsLogEntrySchema } from "../dist-
|
|
2
|
-
import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-
|
|
1
|
+
import { $ as isFirstLevelMacroClass, $t as record, B as defineCustomActions, Bt as nodePin, E as RecordingConfigSchema, It as hydrateSchema, J as evaluateSensorEdge, Kt as array, L as customAction, N as batteryCapability, Nt as DeviceFeature, Pt as DeviceType, Qt as object, T as RECORDING_EXPORT_MAX_READ_BYTES, U as deriveRecordingMode, Ut as selectAssignedProfileSlots, V as deriveBatteryPresence, Zt as number, bt as storageEvictableCapability, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, en as string, et as loadContributionCapability, g as ExportRecordSchema, i as BatteryStatusSchema, jt as BaseAddon, mt as recordingExportCapability, nn as EventCategory, pt as recordingCapability, qt as boolean, vt as resolveRecordingProfiles, wt as errMsg, x as OpsLogEntrySchema } from "../dist-cZopp8Xm.mjs";
|
|
2
|
+
import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-Br6p9KvD.mjs";
|
|
3
3
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
4
4
|
import { r as withRecordingIntent } from "../restream-intent-B4BXZra7.mjs";
|
|
5
|
+
import { n as startEventLoopStallMonitor } from "../event-loop-stall-monitor-DXLNBMkY.mjs";
|
|
5
6
|
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DMolfhsP.mjs";
|
|
6
7
|
import { randomUUID } from "node:crypto";
|
|
7
8
|
import { createReadStream, promises, realpathSync } from "node:fs";
|
|
@@ -1071,6 +1072,45 @@ var RecordingIndex = class {
|
|
|
1071
1072
|
};
|
|
1072
1073
|
}
|
|
1073
1074
|
/**
|
|
1075
|
+
* How many materialised sorted views exist and how many row slots they hold.
|
|
1076
|
+
*
|
|
1077
|
+
* The question this answers: **what is in `large_object_space`?** Production
|
|
1078
|
+
* on 2026-08-29 read ~490 MB there, stable across four samples five minutes
|
|
1079
|
+
* apart — retained, not churn — against 110 MB on hub-main with a comparable
|
|
1080
|
+
* total heap. V8 only puts an object there when it does not fit a normal
|
|
1081
|
+
* page (~256 KB and up), and a sorted view is the recorder's obvious
|
|
1082
|
+
* candidate: one `SegmentRow[]` per (device, profile-or-all), materialised
|
|
1083
|
+
* on first read and then kept live. At ~296 k archive rows a single
|
|
1084
|
+
* all-profiles view is a contiguous array of hundreds of thousands of
|
|
1085
|
+
* pointers — megabytes, in one allocation, per device.
|
|
1086
|
+
*
|
|
1087
|
+
* Slots, not bytes: the array's own allocation is `rowSlots × 8 B` on a
|
|
1088
|
+
* 64-bit heap, and the rows it points at are counted once in `byDevice`
|
|
1089
|
+
* regardless. Reporting a byte estimate would be inventing precision this
|
|
1090
|
+
* cannot have; the slot count is the thing that is actually known and it is
|
|
1091
|
+
* what a reader needs to decide whether the views are the 490 MB.
|
|
1092
|
+
*/
|
|
1093
|
+
sortedViewCensus() {
|
|
1094
|
+
let views = 0;
|
|
1095
|
+
let rowSlots = 0;
|
|
1096
|
+
let largestRows = 0;
|
|
1097
|
+
let largestDeviceId = 0;
|
|
1098
|
+
for (const [deviceId, byKey] of this.sortedByDevice) for (const rows of byKey.values()) {
|
|
1099
|
+
views += 1;
|
|
1100
|
+
rowSlots += rows.length;
|
|
1101
|
+
if (rows.length > largestRows) {
|
|
1102
|
+
largestRows = rows.length;
|
|
1103
|
+
largestDeviceId = deviceId;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return {
|
|
1107
|
+
views,
|
|
1108
|
+
rowSlots,
|
|
1109
|
+
largestRows,
|
|
1110
|
+
largestDeviceId
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1074
1114
|
* Ordered insert of ONE NEW row into every live view it belongs to (the
|
|
1075
1115
|
* all-profiles view and its own profile's view). Views not yet materialised
|
|
1076
1116
|
* stay unmaterialised — their first read pays the one build. PRECONDITION:
|
|
@@ -2028,7 +2068,7 @@ var SegmentStore = class SegmentStore {
|
|
|
2028
2068
|
return counts;
|
|
2029
2069
|
};
|
|
2030
2070
|
const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
|
|
2031
|
-
const yieldBetween = this.deps.yieldBetween ?? macrotask$
|
|
2071
|
+
const yieldBetween = this.deps.yieldBetween ?? macrotask$3;
|
|
2032
2072
|
let bucketsRemoved = 0;
|
|
2033
2073
|
let handled = 0;
|
|
2034
2074
|
for (const [bucketDir, bucketRows] of byBucket) {
|
|
@@ -2166,7 +2206,7 @@ var SegmentStore = class SegmentStore {
|
|
|
2166
2206
|
return indexedInBucket === indexedVictims.size;
|
|
2167
2207
|
}
|
|
2168
2208
|
};
|
|
2169
|
-
var macrotask$
|
|
2209
|
+
var macrotask$3 = () => new Promise((resolve) => {
|
|
2170
2210
|
setImmediate(resolve);
|
|
2171
2211
|
});
|
|
2172
2212
|
//#endregion
|
|
@@ -7193,7 +7233,7 @@ async function resolveRecordingsLocations(api, logger) {
|
|
|
7193
7233
|
* (best-effort), so one bad volume never blocks the rest of the hydrate.
|
|
7194
7234
|
*/
|
|
7195
7235
|
async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
|
|
7196
|
-
const yieldBetween = options.yieldBetween ?? macrotask$
|
|
7236
|
+
const yieldBetween = options.yieldBetween ?? macrotask$2;
|
|
7197
7237
|
const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
|
|
7198
7238
|
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
7199
7239
|
const deviceDir = path.join(root, String(deviceId));
|
|
@@ -7261,7 +7301,7 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
|
|
|
7261
7301
|
}
|
|
7262
7302
|
/** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
|
|
7263
7303
|
var HYDRATE_CHUNK = 2e4;
|
|
7264
|
-
var macrotask$
|
|
7304
|
+
var macrotask$2 = () => new Promise((resolve) => {
|
|
7265
7305
|
setImmediate(resolve);
|
|
7266
7306
|
});
|
|
7267
7307
|
/** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
|
|
@@ -7287,9 +7327,56 @@ var HOUR_MS$2 = 36e5;
|
|
|
7287
7327
|
*
|
|
7288
7328
|
* Hours already walked are skipped, so a drag across an hour costs nothing the
|
|
7289
7329
|
* second time.
|
|
7290
|
-
|
|
7291
|
-
|
|
7330
|
+
*
|
|
7331
|
+
* ## The ledger is the first stop for a CLOSED hour (D287)
|
|
7332
|
+
*
|
|
7333
|
+
* Boot no longer replays the archive into the index, so a ledger-known hour
|
|
7334
|
+
* arrives here `unknown` and pays ONE point read of its hour row instead of a
|
|
7335
|
+
* `readdir` — measured at ~650 ms per hour directory on the live share under
|
|
7336
|
+
* load, against the array the scrub feeder is competing for. Hours the ledger
|
|
7337
|
+
* does not know, and every hour if the read fails, fall through to the walk
|
|
7338
|
+
* exactly as before: the failure direction is toward the disk, never toward an
|
|
7339
|
+
* hour that renders empty because nobody read it.
|
|
7340
|
+
*
|
|
7341
|
+
* **The live hour is never served from the ledger.** It is the one hour whose
|
|
7342
|
+
* durable row is knowably behind the disk: a segment finalized after the last
|
|
7343
|
+
* persist and before a crash is on the array and not in the row. Boot used to
|
|
7344
|
+
* mark every ledger hour `hydrated`, which made that tail invisible to
|
|
7345
|
+
* playback and the timeline FOREVER, not merely under-reported. Excluding the
|
|
7346
|
+
* live hour costs exactly one hour-directory read per process — the walk marks
|
|
7347
|
+
* it, so the second reader pays nothing — and it is what turns a lost tail
|
|
7348
|
+
* back into a recoverable one.
|
|
7349
|
+
*
|
|
7350
|
+
* A CLOSED hour already walked is skipped, which is what makes that exclusion
|
|
7351
|
+
* affordable: without it, every window touching the live hour would re-walk
|
|
7352
|
+
* all of its hours. The past is immutable, so re-reading a closed hour
|
|
7353
|
+
* directory can only ever return what the index already holds; eviction
|
|
7354
|
+
* removes rows through the index directly.
|
|
7355
|
+
*/
|
|
7356
|
+
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
|
|
7292
7357
|
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
7358
|
+
const liveHour = Math.floor(Date.now() / HOUR_MS$2) * HOUR_MS$2;
|
|
7359
|
+
if (ledger !== void 0) {
|
|
7360
|
+
const startedMs = Date.now();
|
|
7361
|
+
const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
|
|
7362
|
+
let segments = 0;
|
|
7363
|
+
let served = 0;
|
|
7364
|
+
for (const hour of hours) {
|
|
7365
|
+
if (hour.hourStartMs >= liveHour) continue;
|
|
7366
|
+
index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
7367
|
+
segments += hour.paths.length;
|
|
7368
|
+
served += 1;
|
|
7369
|
+
}
|
|
7370
|
+
if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
|
|
7371
|
+
tags: { deviceId },
|
|
7372
|
+
meta: {
|
|
7373
|
+
hours: served,
|
|
7374
|
+
segments,
|
|
7375
|
+
ms: Date.now() - startedMs
|
|
7376
|
+
}
|
|
7377
|
+
});
|
|
7378
|
+
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
7379
|
+
}
|
|
7293
7380
|
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
7294
7381
|
const deviceDir = path.join(root, String(deviceId));
|
|
7295
7382
|
let profiles;
|
|
@@ -7308,16 +7395,26 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
7308
7395
|
const first = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
|
|
7309
7396
|
const reuseBefore = index.hydrateReuseStats();
|
|
7310
7397
|
let hoursRead = 0;
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7398
|
+
let repaired = 0;
|
|
7399
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$2) {
|
|
7400
|
+
if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$2) === "hydrated") continue;
|
|
7401
|
+
for (const profile of profiles) {
|
|
7402
|
+
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
7403
|
+
let files;
|
|
7404
|
+
try {
|
|
7405
|
+
files = await promises.readdir(path.join(root, rel));
|
|
7406
|
+
} catch {
|
|
7407
|
+
continue;
|
|
7408
|
+
}
|
|
7409
|
+
hoursRead += 1;
|
|
7410
|
+
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
7411
|
+
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
7412
|
+
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
7413
|
+
if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$2 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
|
|
7414
|
+
repaired += 1;
|
|
7415
|
+
await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
|
|
7416
|
+
}
|
|
7318
7417
|
}
|
|
7319
|
-
hoursRead += 1;
|
|
7320
|
-
index.hydrateHour(deviceId, locationForHydratedProfile(aliases, profile).id, hour, files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`));
|
|
7321
7418
|
}
|
|
7322
7419
|
index.markHydrated(deviceId, fromMs, toMs);
|
|
7323
7420
|
if (hoursRead === 0) continue;
|
|
@@ -7329,6 +7426,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
7329
7426
|
meta: {
|
|
7330
7427
|
root,
|
|
7331
7428
|
hoursRead,
|
|
7429
|
+
repaired,
|
|
7332
7430
|
reused,
|
|
7333
7431
|
allocated
|
|
7334
7432
|
}
|
|
@@ -11610,6 +11708,9 @@ var DurableLedger = class DurableLedger {
|
|
|
11610
11708
|
//#endregion
|
|
11611
11709
|
//#region src/recorder/addon/segment-hour-ledger.ts
|
|
11612
11710
|
var HOUR_MS$1 = 36e5;
|
|
11711
|
+
var macrotask$1 = () => new Promise((resolve) => {
|
|
11712
|
+
setImmediate(resolve);
|
|
11713
|
+
});
|
|
11613
11714
|
/**
|
|
11614
11715
|
* @durable class=mirror owner=recorder
|
|
11615
11716
|
* write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
|
|
@@ -11757,6 +11858,7 @@ function hourAggregate(paths) {
|
|
|
11757
11858
|
function segmentHourRow(base) {
|
|
11758
11859
|
return {
|
|
11759
11860
|
...base,
|
|
11861
|
+
pathsResident: true,
|
|
11760
11862
|
...hourAggregate(base.paths)
|
|
11761
11863
|
};
|
|
11762
11864
|
}
|
|
@@ -11812,6 +11914,44 @@ var OLDEST_PROBE_COLUMNS = [
|
|
|
11812
11914
|
var REPAIR_PROBE_COLUMNS = ["segments"];
|
|
11813
11915
|
/** Rows repaired per boot. The live archive is ~19 k hour rows. */
|
|
11814
11916
|
var REPAIR_MAX_ROWS = 5e4;
|
|
11917
|
+
/**
|
|
11918
|
+
* The boot projection (D287): everything the recorder's always-resident tier
|
|
11919
|
+
* needs, and deliberately NOT `paths`.
|
|
11920
|
+
*
|
|
11921
|
+
* `paths` is ~55 B per segment and the archive is the only thing that grows:
|
|
11922
|
+
* ~16 MB of JSON today, a projected ~460 MB at 28 TB of today's bitrate mix
|
|
11923
|
+
* and ~5.9 GB at a low-bitrate mix — one tRPC response, parsed on the main
|
|
11924
|
+
* thread, before the index exists. The summary half is O(writers × days) and
|
|
11925
|
+
* stays ~10–15 MB at any archive size.
|
|
11926
|
+
*/
|
|
11927
|
+
var SEGMENT_HOUR_SUMMARY_COLUMNS = [
|
|
11928
|
+
"deviceId",
|
|
11929
|
+
"profile",
|
|
11930
|
+
"locationId",
|
|
11931
|
+
"hourStartMs",
|
|
11932
|
+
"bytes",
|
|
11933
|
+
"segments",
|
|
11934
|
+
"minStartMs",
|
|
11935
|
+
"maxStartMs"
|
|
11936
|
+
];
|
|
11937
|
+
/** Summary rows folded into the mirror between yields. */
|
|
11938
|
+
var LOAD_CHUNK = 5e3;
|
|
11939
|
+
/** Hour rows materialised per point-read page. One page covers a day of one
|
|
11940
|
+
* camera's three profiles with room to spare. */
|
|
11941
|
+
var HOUR_DETAIL_PAGE = 64;
|
|
11942
|
+
/**
|
|
11943
|
+
* The live tail materialised at boot: the current hour and the one before it.
|
|
11944
|
+
*
|
|
11945
|
+
* `recordSegment` must gate from RAM (D49), and the hour it appends to at boot
|
|
11946
|
+
* is exactly the hour the previous process was writing. Two hours × the active
|
|
11947
|
+
* writers is ~60 rows — one point-read page — and it is what makes the write
|
|
11948
|
+
* path's materialise a case that essentially never happens.
|
|
11949
|
+
*/
|
|
11950
|
+
var LIVE_TAIL_HOURS = 2;
|
|
11951
|
+
/** Rows the one-shot aggregate repair may materialise in a single boot. The
|
|
11952
|
+
* backfill is bounded so it cannot re-become the whole-archive read D287
|
|
11953
|
+
* removed; a partial pass finishes on the next boot. */
|
|
11954
|
+
var REPAIR_MAX_MATERIALIZE = 2e3;
|
|
11815
11955
|
/** The four scalars an accounting answer is made of, asked in one statement. */
|
|
11816
11956
|
var ACCOUNTING_FIELDS = [
|
|
11817
11957
|
{
|
|
@@ -11904,6 +12044,44 @@ function recordToRow(key, data) {
|
|
|
11904
12044
|
paths: raw
|
|
11905
12045
|
});
|
|
11906
12046
|
}
|
|
12047
|
+
/** The paths of a summary row: one shared empty array, never a per-row
|
|
12048
|
+
* allocation, and never mistakable for "this hour holds nothing". */
|
|
12049
|
+
var NO_PATHS = Object.freeze([]);
|
|
12050
|
+
function finiteOr0(raw) {
|
|
12051
|
+
const n = Number(raw);
|
|
12052
|
+
return Number.isFinite(n) ? n : 0;
|
|
12053
|
+
}
|
|
12054
|
+
/**
|
|
12055
|
+
* One row of the boot projection ({@link SEGMENT_HOUR_SUMMARY_COLUMNS}).
|
|
12056
|
+
*
|
|
12057
|
+
* The identity half is validated exactly as {@link recordToRow} validates it —
|
|
12058
|
+
* a row that cannot say which camera, profile, location and hour it is about
|
|
12059
|
+
* is skipped, because a summary keyed on garbage would refuse the write path
|
|
12060
|
+
* for an hour that is perfectly fine. The aggregate half is taken as the
|
|
12061
|
+
* database wrote it: a row that predates the aggregate columns reports zeros,
|
|
12062
|
+
* which UNDER-reports its disk (prunes less, never more) until
|
|
12063
|
+
* {@link SegmentHourLedger.repairAggregates} rewrites it.
|
|
12064
|
+
*/
|
|
12065
|
+
function summaryToRow(key, data) {
|
|
12066
|
+
const deviceId = Number(data["deviceId"]);
|
|
12067
|
+
const profile = data["profile"];
|
|
12068
|
+
const locationId = data["locationId"];
|
|
12069
|
+
const hour = Number(data["hourStartMs"]);
|
|
12070
|
+
if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour)) return null;
|
|
12071
|
+
return {
|
|
12072
|
+
key,
|
|
12073
|
+
deviceId,
|
|
12074
|
+
profile,
|
|
12075
|
+
locationId,
|
|
12076
|
+
hourStartMs: hour,
|
|
12077
|
+
paths: NO_PATHS,
|
|
12078
|
+
pathsResident: false,
|
|
12079
|
+
bytes: finiteOr0(data["bytes"]),
|
|
12080
|
+
segments: finiteOr0(data["segments"]),
|
|
12081
|
+
minStartMs: finiteOr0(data["minStartMs"]),
|
|
12082
|
+
maxStartMs: finiteOr0(data["maxStartMs"])
|
|
12083
|
+
};
|
|
12084
|
+
}
|
|
11907
12085
|
var SPEC = {
|
|
11908
12086
|
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
11909
12087
|
columns: RECORDING_SEGMENT_HOURS_COLUMNS,
|
|
@@ -11924,10 +12102,18 @@ var SegmentHourLedger = class {
|
|
|
11924
12102
|
* accounting number is not a gate. */
|
|
11925
12103
|
store;
|
|
11926
12104
|
isLocationUsable;
|
|
12105
|
+
now;
|
|
12106
|
+
loadChunk;
|
|
12107
|
+
yieldBetween;
|
|
12108
|
+
rowCap;
|
|
11927
12109
|
constructor(deps) {
|
|
11928
12110
|
this.logger = deps.logger;
|
|
11929
12111
|
this.store = deps.store;
|
|
11930
12112
|
this.isLocationUsable = deps.isLocationUsable ?? (() => true);
|
|
12113
|
+
this.now = deps.now ?? (() => Date.now());
|
|
12114
|
+
this.loadChunk = deps.loadPacing?.chunkSize ?? LOAD_CHUNK;
|
|
12115
|
+
this.yieldBetween = deps.loadPacing?.yieldBetween ?? macrotask$1;
|
|
12116
|
+
this.rowCap = deps.loadPacing?.rowCap ?? 25e4;
|
|
11931
12117
|
this.ledger = new DurableLedger({
|
|
11932
12118
|
spec: SPEC,
|
|
11933
12119
|
store: deps.store,
|
|
@@ -11940,26 +12126,270 @@ var SegmentHourLedger = class {
|
|
|
11940
12126
|
declare() {
|
|
11941
12127
|
return this.ledger.declare();
|
|
11942
12128
|
}
|
|
12129
|
+
/**
|
|
12130
|
+
* Boot load — **T1 only** (D287).
|
|
12131
|
+
*
|
|
12132
|
+
* One projected query over {@link SEGMENT_HOUR_SUMMARY_COLUMNS}, folded into
|
|
12133
|
+
* the mirror in chunks with a yield between them, then one point-read page
|
|
12134
|
+
* that materialises the live tail so the write path can gate from RAM.
|
|
12135
|
+
*
|
|
12136
|
+
* It replaces `DurableLedger.load`, which fetches whole rows and therefore
|
|
12137
|
+
* the whole archive's path strings, and it keeps that primitive's two rules:
|
|
12138
|
+
* a failed read RETURNS WHAT IS ALREADY MIRRORED rather than clearing it,
|
|
12139
|
+
* and a malformed row is skipped rather than repaired.
|
|
12140
|
+
*
|
|
12141
|
+
* It deliberately does NOT replay the archive into {@link RecordingIndex}.
|
|
12142
|
+
* `hydrated` now means "the rows are resident", so a ledger-known hour is
|
|
12143
|
+
* merely *hydratable from T3*: the first read of it pays one point read
|
|
12144
|
+
* ({@link detailForWindow}), and an hour whose tail the ledger is missing is
|
|
12145
|
+
* no longer permanently invisible to playback.
|
|
12146
|
+
*/
|
|
11943
12147
|
async load() {
|
|
11944
|
-
const
|
|
12148
|
+
const startedMs = this.now();
|
|
12149
|
+
let records;
|
|
12150
|
+
try {
|
|
12151
|
+
records = await this.store.query.query({
|
|
12152
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
12153
|
+
columns: SEGMENT_HOUR_SUMMARY_COLUMNS,
|
|
12154
|
+
filter: { limit: this.rowCap }
|
|
12155
|
+
});
|
|
12156
|
+
} catch (err) {
|
|
12157
|
+
this.logger.warn("recorder: segment-hour ledger load failed — keeping the hours already held", { meta: {
|
|
12158
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
12159
|
+
held: this.ledger.size,
|
|
12160
|
+
error: String(err)
|
|
12161
|
+
} });
|
|
12162
|
+
return this.ledger.snapshot();
|
|
12163
|
+
}
|
|
12164
|
+
const queriedMs = this.now();
|
|
12165
|
+
let skipped = 0;
|
|
12166
|
+
for (let i = 0; i < records.length; i += this.loadChunk) {
|
|
12167
|
+
const end = Math.min(i + this.loadChunk, records.length);
|
|
12168
|
+
for (let j = i; j < end; j += 1) {
|
|
12169
|
+
const record = records[j];
|
|
12170
|
+
const row = summaryToRow(record.id, record.data);
|
|
12171
|
+
if (row === null) {
|
|
12172
|
+
skipped += 1;
|
|
12173
|
+
continue;
|
|
12174
|
+
}
|
|
12175
|
+
this.ledger.stage(row);
|
|
12176
|
+
}
|
|
12177
|
+
if (end < records.length) await this.yieldBetween();
|
|
12178
|
+
}
|
|
12179
|
+
if (skipped > 0) this.logger.warn("recorder: segment-hour summaries skipped as malformed — they gate NOTHING", { meta: {
|
|
12180
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
12181
|
+
skipped
|
|
12182
|
+
} });
|
|
12183
|
+
if (records.length >= this.rowCap) this.logger.warn("recorder: segment-hour ledger load hit its row cap — hours past the cap are UNKNOWN to this process and will be walked from disk", { meta: {
|
|
12184
|
+
limit: this.rowCap,
|
|
12185
|
+
loaded: records.length
|
|
12186
|
+
} });
|
|
12187
|
+
const tail = await this.materializeLiveTail();
|
|
11945
12188
|
this.logger.info("recorder: segment-hour ledger loaded", { meta: {
|
|
11946
|
-
hours:
|
|
12189
|
+
hours: this.ledger.size,
|
|
12190
|
+
pathsLoaded: false,
|
|
12191
|
+
liveTailHours: tail,
|
|
12192
|
+
skipped,
|
|
12193
|
+
queryMs: queriedMs - startedMs,
|
|
12194
|
+
ms: this.now() - startedMs,
|
|
11947
12195
|
collection: RECORDING_SEGMENT_HOURS_COLLECTION
|
|
11948
12196
|
} });
|
|
11949
|
-
return
|
|
12197
|
+
return this.ledger.snapshot();
|
|
11950
12198
|
}
|
|
12199
|
+
/** Every hour the ledger knows — summaries included. A row whose
|
|
12200
|
+
* `pathsResident` is false carries real aggregates and empty `paths`. */
|
|
11951
12201
|
snapshot() {
|
|
11952
12202
|
return this.ledger.snapshot();
|
|
11953
12203
|
}
|
|
12204
|
+
/** Does the ledger hold a row for this hour at all? Pure RAM — the gate the
|
|
12205
|
+
* storage-walk repair consults before it upserts a rebuilt hour. */
|
|
12206
|
+
knowsHour(deviceId, profile, locationId, hour) {
|
|
12207
|
+
return this.ledger.has(segmentHourKey(deviceId, profile, locationId, hour));
|
|
12208
|
+
}
|
|
12209
|
+
/**
|
|
12210
|
+
* The hours of `[fromMs, toMs)` the ledger knows, with their paths — the
|
|
12211
|
+
* cold-hour detail read that replaces the boot replay (D287).
|
|
12212
|
+
*
|
|
12213
|
+
* One bounded point read against the settings store, never the media array:
|
|
12214
|
+
* a `readdir` of one hour directory was measured at ~650 ms under load on
|
|
12215
|
+
* the live share, and the array is the resource the scrub feeder is
|
|
12216
|
+
* competing for. Hours the ledger does NOT know are absent from the result,
|
|
12217
|
+
* and the caller walks those from disk exactly as it always did.
|
|
12218
|
+
*
|
|
12219
|
+
* **A read that fails returns what it already had** (D49). The caller sees
|
|
12220
|
+
* fewer hours, the index stays `unknown` for the rest, and the window falls
|
|
12221
|
+
* through to the walk — nothing is destroyed and nothing renders as an
|
|
12222
|
+
* empty hour that was merely unread.
|
|
12223
|
+
*/
|
|
12224
|
+
async detailForWindow(deviceId, fromMs, toMs) {
|
|
12225
|
+
const first = hourStartMs(fromMs);
|
|
12226
|
+
const out = [];
|
|
12227
|
+
const wanted = [];
|
|
12228
|
+
for (const row of this.ledger.snapshot()) {
|
|
12229
|
+
if (row.deviceId !== deviceId) continue;
|
|
12230
|
+
if (row.hourStartMs < first || row.hourStartMs >= toMs) continue;
|
|
12231
|
+
if (row.pathsResident) {
|
|
12232
|
+
out.push({
|
|
12233
|
+
locationId: row.locationId,
|
|
12234
|
+
hourStartMs: row.hourStartMs,
|
|
12235
|
+
paths: row.paths
|
|
12236
|
+
});
|
|
12237
|
+
continue;
|
|
12238
|
+
}
|
|
12239
|
+
wanted.push(row.key);
|
|
12240
|
+
}
|
|
12241
|
+
for (const row of await this.materialize(wanted, deviceId)) out.push({
|
|
12242
|
+
locationId: row.locationId,
|
|
12243
|
+
hourStartMs: row.hourStartMs,
|
|
12244
|
+
paths: row.paths
|
|
12245
|
+
});
|
|
12246
|
+
return out;
|
|
12247
|
+
}
|
|
11954
12248
|
/**
|
|
11955
|
-
*
|
|
12249
|
+
* Adopt an hour the storage walk rebuilt and the ledger never held.
|
|
11956
12250
|
*
|
|
11957
|
-
*
|
|
11958
|
-
*
|
|
11959
|
-
*
|
|
12251
|
+
* The reader repairs the durable index it just reconstructed, so the next
|
|
12252
|
+
* cold read of this hour is a point read instead of another `readdir`
|
|
12253
|
+
* (`docs/design/2026-08-29-scrypted-nvr-recording-model.md` §4.2). Two
|
|
12254
|
+
* constraints, both enforced by the caller and re-stated here because
|
|
12255
|
+
* getting either wrong turns a READ into durable damage: the hour must be
|
|
12256
|
+
* CLOSED (a live hour's directory races the writer, so a walk of it is a
|
|
12257
|
+
* lower bound, not the truth), and the ledger must hold no row for it (an
|
|
12258
|
+
* existing row is the authority; replacing it with a walk's view is the
|
|
12259
|
+
* D148 prune this ledger refuses to do on its own evidence).
|
|
12260
|
+
*/
|
|
12261
|
+
async adoptRebuiltHour(deviceId, profile, locationId, hour, paths) {
|
|
12262
|
+
const key = segmentHourKey(deviceId, profile, locationId, hour);
|
|
12263
|
+
if (paths.length === 0 || this.ledger.has(key)) return;
|
|
12264
|
+
const row = segmentHourRow({
|
|
12265
|
+
key,
|
|
12266
|
+
deviceId,
|
|
12267
|
+
profile,
|
|
12268
|
+
locationId,
|
|
12269
|
+
hourStartMs: hour,
|
|
12270
|
+
paths: uniquePaths(paths)
|
|
12271
|
+
});
|
|
12272
|
+
await this.ledger.put(row);
|
|
12273
|
+
this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
|
|
12274
|
+
tags: { deviceId },
|
|
12275
|
+
meta: {
|
|
12276
|
+
profile,
|
|
12277
|
+
locationId,
|
|
12278
|
+
hourStartMs: hour,
|
|
12279
|
+
segments: row.segments,
|
|
12280
|
+
bytes: row.bytes
|
|
12281
|
+
}
|
|
12282
|
+
});
|
|
12283
|
+
}
|
|
12284
|
+
/**
|
|
12285
|
+
* Fetch whole rows for `keys` and promote them in the mirror.
|
|
12286
|
+
*
|
|
12287
|
+
* Paged and yielding: an export window can name thousands of hours, and one
|
|
12288
|
+
* `whereIn` over all of them would rebuild the very whole-archive response
|
|
12289
|
+
* D287 removed. A page that FAILS ends the materialise and returns what
|
|
12290
|
+
* landed — partial detail is real detail, and the caller's fallback (a disk
|
|
12291
|
+
* walk, or refusing a write) is strictly safer than a guess.
|
|
12292
|
+
*
|
|
12293
|
+
* A row the store returns but this cannot parse is EVICTED from the mirror
|
|
12294
|
+
* rather than left as a summary that can never become resident: it re-seeds
|
|
12295
|
+
* cold on the next write, which is `DurableLedger`'s rule 4 and the only
|
|
12296
|
+
* escape from an hour permanently frozen against its own writer.
|
|
11960
12297
|
*/
|
|
11961
|
-
|
|
11962
|
-
|
|
12298
|
+
async materialize(keys, deviceId) {
|
|
12299
|
+
const out = [];
|
|
12300
|
+
if (keys.length === 0) return out;
|
|
12301
|
+
const tags = deviceId === void 0 ? {} : { tags: { deviceId } };
|
|
12302
|
+
for (let i = 0; i < keys.length; i += HOUR_DETAIL_PAGE) {
|
|
12303
|
+
const page = keys.slice(i, i + HOUR_DETAIL_PAGE);
|
|
12304
|
+
let records;
|
|
12305
|
+
try {
|
|
12306
|
+
records = await this.store.query.query({
|
|
12307
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
12308
|
+
filter: {
|
|
12309
|
+
whereIn: { key: page },
|
|
12310
|
+
limit: page.length
|
|
12311
|
+
}
|
|
12312
|
+
});
|
|
12313
|
+
} catch (err) {
|
|
12314
|
+
this.logger.warn("recorder: hour-detail point read failed — these hours stay summaries, and the caller walks the disk or declines the write", {
|
|
12315
|
+
...tags,
|
|
12316
|
+
meta: {
|
|
12317
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
12318
|
+
asked: page.length,
|
|
12319
|
+
materialized: out.length,
|
|
12320
|
+
error: String(err)
|
|
12321
|
+
}
|
|
12322
|
+
});
|
|
12323
|
+
return out;
|
|
12324
|
+
}
|
|
12325
|
+
for (const record of records) {
|
|
12326
|
+
const row = recordToRow(record.id, record.data);
|
|
12327
|
+
if (row === null) {
|
|
12328
|
+
this.ledger.evict(record.id);
|
|
12329
|
+
this.logger.warn("recorder: hour row is unparseable — dropping the summary so the hour re-seeds cold instead of freezing its writer", {
|
|
12330
|
+
...tags,
|
|
12331
|
+
meta: { key: record.id }
|
|
12332
|
+
});
|
|
12333
|
+
continue;
|
|
12334
|
+
}
|
|
12335
|
+
this.ledger.stage(row);
|
|
12336
|
+
out.push(row);
|
|
12337
|
+
}
|
|
12338
|
+
if (i + HOUR_DETAIL_PAGE < keys.length) await this.yieldBetween();
|
|
12339
|
+
}
|
|
12340
|
+
return out;
|
|
12341
|
+
}
|
|
12342
|
+
/**
|
|
12343
|
+
* Materialise every hour these devices hold, and report how many stayed
|
|
12344
|
+
* summaries.
|
|
12345
|
+
*
|
|
12346
|
+
* The reconcile is the one path that DELETES durable rows, so it may not run
|
|
12347
|
+
* against a mirror that only half knows what it holds. This is a whole-device
|
|
12348
|
+
* read and it is deliberately confined here: `reconcileFromIndex` follows an
|
|
12349
|
+
* operator-armed rescan of that same device — a recursive `readdir` over
|
|
12350
|
+
* >1M files — so a paged point read alongside it is noise, and it is the one
|
|
12351
|
+
* caller whose correctness depends on completeness.
|
|
12352
|
+
*/
|
|
12353
|
+
async materializeDevices(deviceIds) {
|
|
12354
|
+
const keys = [];
|
|
12355
|
+
for (const row of this.ledger.snapshot()) if (!row.pathsResident && deviceIds.has(row.deviceId)) keys.push(row.key);
|
|
12356
|
+
if (keys.length === 0) return;
|
|
12357
|
+
await this.materialize(keys);
|
|
12358
|
+
const stillSummary = /* @__PURE__ */ new Map();
|
|
12359
|
+
for (const row of this.ledger.snapshot()) {
|
|
12360
|
+
if (row.pathsResident || !deviceIds.has(row.deviceId)) continue;
|
|
12361
|
+
stillSummary.set(row.deviceId, (stillSummary.get(row.deviceId) ?? 0) + 1);
|
|
12362
|
+
}
|
|
12363
|
+
for (const [deviceId, unreadable] of stillSummary) this.logger.warn("recorder: hours without readable detail take NO part in this reconcile — they are neither pruned nor unioned", {
|
|
12364
|
+
tags: { deviceId },
|
|
12365
|
+
meta: {
|
|
12366
|
+
asked: keys.length,
|
|
12367
|
+
unreadable
|
|
12368
|
+
}
|
|
12369
|
+
});
|
|
12370
|
+
}
|
|
12371
|
+
/** The hour a write path may touch: current + previous. Materialised at boot
|
|
12372
|
+
* so {@link recordSegment}'s dedup gate answers from RAM (D49). */
|
|
12373
|
+
async materializeLiveTail() {
|
|
12374
|
+
const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$1;
|
|
12375
|
+
const keys = [];
|
|
12376
|
+
for (const row of this.ledger.snapshot()) if (row.hourStartMs >= oldest && !row.pathsResident) keys.push(row.key);
|
|
12377
|
+
return (await this.materialize(keys)).length;
|
|
12378
|
+
}
|
|
12379
|
+
/**
|
|
12380
|
+
* Promote one HELD row to paths-resident, or answer `null`.
|
|
12381
|
+
*
|
|
12382
|
+
* `null` means "this hour is known and its detail could not be had" — the
|
|
12383
|
+
* only honest answer to a failed point read, and every caller turns it into
|
|
12384
|
+
* "do nothing". It is deliberately distinct from `this.ledger.get(key) ===
|
|
12385
|
+
* undefined`, which means "no such hour" and is what licenses a fresh row.
|
|
12386
|
+
*/
|
|
12387
|
+
async resident(key, deviceId) {
|
|
12388
|
+
const held = this.ledger.get(key);
|
|
12389
|
+
if (held !== void 0 && held.pathsResident) return held;
|
|
12390
|
+
await this.materialize([key], deviceId);
|
|
12391
|
+
const after = this.ledger.get(key);
|
|
12392
|
+
return after !== void 0 && after.pathsResident ? after : null;
|
|
11963
12393
|
}
|
|
11964
12394
|
/**
|
|
11965
12395
|
* Bytes / count / oldest / newest across a SET of storage locations, as ONE
|
|
@@ -12060,6 +12490,7 @@ var SegmentHourLedger = class {
|
|
|
12060
12490
|
for (const record of full) {
|
|
12061
12491
|
const hour = recordToRow(record.id, record.data);
|
|
12062
12492
|
if (hour === null) continue;
|
|
12493
|
+
this.ledger.stage(hour);
|
|
12063
12494
|
for (const path of hour.paths) {
|
|
12064
12495
|
const parsed = parseSegmentPath(path);
|
|
12065
12496
|
if (parsed === null) continue;
|
|
@@ -12089,6 +12520,12 @@ var SegmentHourLedger = class {
|
|
|
12089
12520
|
* empties). So `segments = 0` IS the backfill predicate, and after one pass
|
|
12090
12521
|
* it selects nothing. A partial repair finishes on the next boot; until then
|
|
12091
12522
|
* the affected disks UNDER-report, which prunes less and never more.
|
|
12523
|
+
*
|
|
12524
|
+
* Since D287 the boot load carries no `paths`, and `paths` is the authority
|
|
12525
|
+
* this repair derives from — so the stale rows are MATERIALISED first, at
|
|
12526
|
+
* most {@link REPAIR_MAX_MATERIALIZE} per boot. That bound is the point: an
|
|
12527
|
+
* unbounded materialise here would be the whole-archive read this stage
|
|
12528
|
+
* removed, wearing a different name.
|
|
12092
12529
|
*/
|
|
12093
12530
|
async repairAggregates() {
|
|
12094
12531
|
let repaired = 0;
|
|
@@ -12101,9 +12538,15 @@ var SegmentHourLedger = class {
|
|
|
12101
12538
|
limit: REPAIR_MAX_ROWS
|
|
12102
12539
|
}
|
|
12103
12540
|
});
|
|
12541
|
+
const pending = stale.map((record) => record.id).filter((key) => this.ledger.get(key)?.pathsResident === false);
|
|
12542
|
+
if (pending.length > REPAIR_MAX_MATERIALIZE) this.logger.info("recorder: segment-hour aggregate backfill is bounded this boot — the rest is repaired on the next one", { meta: {
|
|
12543
|
+
pending: pending.length,
|
|
12544
|
+
perBoot: REPAIR_MAX_MATERIALIZE
|
|
12545
|
+
} });
|
|
12546
|
+
await this.materialize(pending.slice(0, REPAIR_MAX_MATERIALIZE));
|
|
12104
12547
|
for (const record of stale) {
|
|
12105
12548
|
const held = this.ledger.get(record.id);
|
|
12106
|
-
if (held === void 0 || held.segments === 0) continue;
|
|
12549
|
+
if (held === void 0 || !held.pathsResident || held.segments === 0) continue;
|
|
12107
12550
|
await this.ledger.put(held);
|
|
12108
12551
|
repaired += 1;
|
|
12109
12552
|
}
|
|
@@ -12131,8 +12574,7 @@ var SegmentHourLedger = class {
|
|
|
12131
12574
|
*/
|
|
12132
12575
|
async recordSegment(row) {
|
|
12133
12576
|
const { key, hourStartMs: hour } = hourOf(row);
|
|
12134
|
-
|
|
12135
|
-
if (held === void 0) {
|
|
12577
|
+
if (!this.ledger.has(key)) {
|
|
12136
12578
|
await this.ledger.put(segmentHourRow({
|
|
12137
12579
|
key,
|
|
12138
12580
|
deviceId: row.deviceId,
|
|
@@ -12143,6 +12585,17 @@ var SegmentHourLedger = class {
|
|
|
12143
12585
|
}));
|
|
12144
12586
|
return;
|
|
12145
12587
|
}
|
|
12588
|
+
const held = await this.resident(key, row.deviceId);
|
|
12589
|
+
if (held === null) {
|
|
12590
|
+
this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
|
|
12591
|
+
tags: { deviceId: row.deviceId },
|
|
12592
|
+
meta: {
|
|
12593
|
+
key,
|
|
12594
|
+
path: row.path
|
|
12595
|
+
}
|
|
12596
|
+
});
|
|
12597
|
+
return;
|
|
12598
|
+
}
|
|
12146
12599
|
if (held.paths.includes(row.path)) return;
|
|
12147
12600
|
await this.ledger.put(appendSegmentPath(held, row.path));
|
|
12148
12601
|
}
|
|
@@ -12154,16 +12607,29 @@ var SegmentHourLedger = class {
|
|
|
12154
12607
|
const doomed = /* @__PURE__ */ new Map();
|
|
12155
12608
|
for (const row of rows) {
|
|
12156
12609
|
const { key } = hourOf(row);
|
|
12157
|
-
|
|
12158
|
-
if (
|
|
12159
|
-
set
|
|
12160
|
-
|
|
12610
|
+
const entry = doomed.get(key);
|
|
12611
|
+
if (entry === void 0) {
|
|
12612
|
+
doomed.set(key, {
|
|
12613
|
+
deviceId: row.deviceId,
|
|
12614
|
+
paths: new Set([row.path])
|
|
12615
|
+
});
|
|
12616
|
+
continue;
|
|
12161
12617
|
}
|
|
12162
|
-
|
|
12618
|
+
entry.paths.add(row.path);
|
|
12163
12619
|
}
|
|
12164
|
-
for (const [key, paths] of doomed) {
|
|
12165
|
-
|
|
12166
|
-
|
|
12620
|
+
for (const [key, { deviceId, paths }] of doomed) {
|
|
12621
|
+
if (!this.ledger.has(key)) continue;
|
|
12622
|
+
const held = await this.resident(key, deviceId);
|
|
12623
|
+
if (held === null) {
|
|
12624
|
+
this.logger.warn("recorder: hour detail unavailable — these evicted paths stay in the durable hour row until a walk or the next eviction confirms them", {
|
|
12625
|
+
tags: { deviceId },
|
|
12626
|
+
meta: {
|
|
12627
|
+
key,
|
|
12628
|
+
paths: paths.size
|
|
12629
|
+
}
|
|
12630
|
+
});
|
|
12631
|
+
continue;
|
|
12632
|
+
}
|
|
12167
12633
|
const next = held.paths.filter((p) => !paths.has(p));
|
|
12168
12634
|
if (next.length === 0) {
|
|
12169
12635
|
await this.ledger.forget(key);
|
|
@@ -12192,10 +12658,17 @@ var SegmentHourLedger = class {
|
|
|
12192
12658
|
* And a walk this ledger does not TRUST prunes nothing at all — see
|
|
12193
12659
|
* {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
|
|
12194
12660
|
* are unioned in; what it did not find decides nothing (D49).
|
|
12661
|
+
*
|
|
12662
|
+
* Since D287 the walked devices' hours are MATERIALISED first: every
|
|
12663
|
+
* comparison below reads `row.paths`, and a summary's empty `paths` would
|
|
12664
|
+
* read as "the ledger holds nothing here" — which is the union collapsing to
|
|
12665
|
+
* the walk's own view, i.e. the prune the refusal machinery exists to
|
|
12666
|
+
* prevent. An hour whose detail could not be read is left additive-only.
|
|
12195
12667
|
*/
|
|
12196
12668
|
async reconcileFromIndex(index, deviceIds, nowMs) {
|
|
12197
12669
|
const walked = new Set(deviceIds);
|
|
12198
12670
|
const currentHour = hourStartMs(nowMs);
|
|
12671
|
+
await this.materializeDevices(walked);
|
|
12199
12672
|
const fromIndex = /* @__PURE__ */ new Map();
|
|
12200
12673
|
for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
|
|
12201
12674
|
const { key, hourStartMs: hour } = hourOf(seg);
|
|
@@ -12213,6 +12686,7 @@ var SegmentHourLedger = class {
|
|
|
12213
12686
|
const refused = this.refusedPrunes(walked, fromIndex);
|
|
12214
12687
|
for (const row of this.ledger.snapshot()) {
|
|
12215
12688
|
if (!walked.has(row.deviceId)) continue;
|
|
12689
|
+
if (!row.pathsResident) continue;
|
|
12216
12690
|
const disk = fromIndex.get(row.key);
|
|
12217
12691
|
const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
|
|
12218
12692
|
if (disk === void 0) {
|
|
@@ -12890,6 +13364,16 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
12890
13364
|
isRecordingNode = true;
|
|
12891
13365
|
index = new RecordingIndex();
|
|
12892
13366
|
/**
|
|
13367
|
+
* Stops the GC-attributing stall monitor armed in {@link onInitialize}.
|
|
13368
|
+
*
|
|
13369
|
+
* REUSED, not re-implemented, and not promoted: `startEventLoopStallMonitor`
|
|
13370
|
+
* already ships inside `@camstack/addon-pipeline` — the same bundle as this
|
|
13371
|
+
* addon — and moving it into `@camstack/system` would duplicate a mechanism
|
|
13372
|
+
* that already exists (forbidden) or relocate it across a package boundary,
|
|
13373
|
+
* which is a refactor with a slower ship chain, not a measurement.
|
|
13374
|
+
*/
|
|
13375
|
+
stopStallMonitor = null;
|
|
13376
|
+
/**
|
|
12893
13377
|
* Durable hour-directory copy of {@link index}. Null on an inert
|
|
12894
13378
|
* (non-recording) node. Boot reseeds the RAM index from this; the
|
|
12895
13379
|
* deferred archive walk reconciles it against disk.
|
|
@@ -13057,6 +13541,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
13057
13541
|
} });
|
|
13058
13542
|
}
|
|
13059
13543
|
async onInitialize() {
|
|
13544
|
+
this.stopStallMonitor = startEventLoopStallMonitor(this.ctx.logger);
|
|
13060
13545
|
const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
|
|
13061
13546
|
this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
|
|
13062
13547
|
const configuredNode = this.config.recordingNodeId;
|
|
@@ -13300,7 +13785,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
13300
13785
|
},
|
|
13301
13786
|
calendar: this.calendar,
|
|
13302
13787
|
mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
|
|
13303
|
-
hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
|
|
13788
|
+
hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0),
|
|
13304
13789
|
onConfigChanged: async (deviceId) => {
|
|
13305
13790
|
await this.refreshSensorTriggerBindings();
|
|
13306
13791
|
await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());
|
|
@@ -13399,7 +13884,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
13399
13884
|
state: exportsState,
|
|
13400
13885
|
engine: this.exportEngine,
|
|
13401
13886
|
index: { segmentsInWindow: async (deviceId, profile, fromMs, toMs) => {
|
|
13402
|
-
if (this.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger);
|
|
13887
|
+
if (this.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0);
|
|
13403
13888
|
return this.index.segments(deviceId, profile);
|
|
13404
13889
|
} },
|
|
13405
13890
|
now: () => Date.now(),
|
|
@@ -13496,6 +13981,8 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
13496
13981
|
};
|
|
13497
13982
|
}
|
|
13498
13983
|
async onShutdown() {
|
|
13984
|
+
this.stopStallMonitor?.();
|
|
13985
|
+
this.stopStallMonitor = null;
|
|
13499
13986
|
this.eventUnsub?.();
|
|
13500
13987
|
this.eventUnsub = null;
|
|
13501
13988
|
this.audioTriggerUnsub?.();
|
|
@@ -13626,7 +14113,6 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
13626
14113
|
}
|
|
13627
14114
|
try {
|
|
13628
14115
|
await this.segmentHours?.load();
|
|
13629
|
-
this.segmentHours?.hydrateIndex(this.index);
|
|
13630
14116
|
this.segmentHours?.repairAggregates();
|
|
13631
14117
|
} catch (err) {
|
|
13632
14118
|
this.ctx.logger.warn("recorder: segment-hour ledger load failed — hours stay unknown until a read walks them", { meta: { error: errMsg(err) } });
|
|
@@ -13762,6 +14248,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
13762
14248
|
}
|
|
13763
14249
|
if (this.exportSweepTimer === null) {
|
|
13764
14250
|
const sweep = () => {
|
|
14251
|
+
this.logIndexCensus();
|
|
13765
14252
|
sweepExpiredExports(Date.now(), this.exportJanitorDeps()).catch((err) => {
|
|
13766
14253
|
this.ctx.logger.warn("recorder: export expiry sweep failed", { meta: { error: errMsg(err) } });
|
|
13767
14254
|
});
|
|
@@ -13772,6 +14259,41 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
13772
14259
|
}
|
|
13773
14260
|
}
|
|
13774
14261
|
/**
|
|
14262
|
+
* What the RAM index is actually holding, on the 5-minute cadence the
|
|
14263
|
+
* `[mem]` heartbeat already samples on.
|
|
14264
|
+
*
|
|
14265
|
+
* `RecordingIndex` has exposed `sortedViewStats` and `hydrateReuseStats`
|
|
14266
|
+
* since the churn work and NOTHING has ever read them — so the two numbers
|
|
14267
|
+
* that would have told us where the recorder's heap goes have never appeared
|
|
14268
|
+
* in a log line. Production on 2026-08-29 reads ~490 MB in
|
|
14269
|
+
* `large_object_space`, stable across four samples five minutes apart
|
|
14270
|
+
* (retained, not churn) against 110 MB on hub-main; the sorted views are the
|
|
14271
|
+
* shape that lands there, and `rowSlots × 8 B` is the arithmetic that either
|
|
14272
|
+
* accounts for it or rules it out.
|
|
14273
|
+
*
|
|
14274
|
+
* `tags: { deviceId }` names the camera with the LARGEST view, because
|
|
14275
|
+
* "which camera" is the only form the follow-up question ever takes. Absent
|
|
14276
|
+
* when nothing is materialised — a device id of 0 is not a camera.
|
|
14277
|
+
*/
|
|
14278
|
+
logIndexCensus() {
|
|
14279
|
+
const census = this.index.sortedViewCensus();
|
|
14280
|
+
const views = this.index.sortedViewStats();
|
|
14281
|
+
const reuse = this.index.hydrateReuseStats();
|
|
14282
|
+
this.ctx.logger.info("recorder: index census", {
|
|
14283
|
+
...census.largestDeviceId > 0 ? { tags: { deviceId: census.largestDeviceId } } : {},
|
|
14284
|
+
meta: {
|
|
14285
|
+
sortedViews: census.views,
|
|
14286
|
+
rowSlots: census.rowSlots,
|
|
14287
|
+
largestViewRows: census.largestRows,
|
|
14288
|
+
fullRebuilds: views.fullRebuilds,
|
|
14289
|
+
incrementalInserts: views.incrementalInserts,
|
|
14290
|
+
rowsReused: reuse.rowsReused,
|
|
14291
|
+
rowsAllocated: reuse.rowsAllocated,
|
|
14292
|
+
ledgerHours: this.segmentHours?.snapshot().length ?? 0
|
|
14293
|
+
}
|
|
14294
|
+
});
|
|
14295
|
+
}
|
|
14296
|
+
/**
|
|
13775
14297
|
* One-time boot cleanup of the retired sprite-sheet scrub-thumbnail dirs
|
|
13776
14298
|
* (`<locationRoot>/.thumbs`). Scrub previews now use the WebCodecs keyframe
|
|
13777
14299
|
* packs + the on-demand still route, so the sheets are dead weight on the
|
|
@@ -14155,7 +14677,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
14155
14677
|
const started = now;
|
|
14156
14678
|
const liveHour = Math.floor(now / HOUR_MS) * HOUR_MS;
|
|
14157
14679
|
for (const deviceId of deviceIds) try {
|
|
14158
|
-
await hydrateWindowFromStorage(this.index, deviceId, liveHour, liveHour + HOUR_MS, this.resolvedLocations, this.ctx.logger);
|
|
14680
|
+
await hydrateWindowFromStorage(this.index, deviceId, liveHour, liveHour + HOUR_MS, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0);
|
|
14159
14681
|
} catch {}
|
|
14160
14682
|
this.ctx.logger.info("recorder: today warmed", { meta: {
|
|
14161
14683
|
devices: deviceIds.length,
|