@camstack/addon-pipeline 1.2.133 → 1.2.135
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-FV2O25id.js → addon-utils-CKf8kbwh.js} +1 -1
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +5 -5
- package/dist/detection-pipeline/index.mjs +3 -3
- package/dist/{dist-DsnZUYTs.js → dist-BzLiQ8Ue.js} +699 -30
- package/dist/{dist-7Uc6NdFm.mjs → dist-C1dXLLQq.mjs} +682 -31
- package/dist/{event-loop-stall-monitor-XzDMVU9d.mjs → event-loop-stall-monitor-CU-xxX5c.mjs} +1 -1
- package/dist/{event-loop-stall-monitor-D86J6d2c.js → event-loop-stall-monitor-DjV4Bow7.js} +1 -1
- package/dist/{lazy-sharp-UzEUL79V.js → lazy-sharp-DxWmhTqk.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/{process-memory-Co59RQIK.js → process-memory-CZK3D3kM.js} +1 -1
- package/dist/{process-memory-Ci90-zF_.mjs → process-memory-CaEwi5Cd.mjs} +1 -1
- package/dist/recorder/index.js +357 -327
- package/dist/recorder/index.mjs +356 -326
- package/dist/{segment-demux-js-DKcc0bxA.js → segment-demux-js-BbpqygHL.js} +1 -1
- package/dist/{segment-demux-js-0NZ9TXro.mjs → segment-demux-js-njRO85gd.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/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dqj2f3H7.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C14Qqrqn.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqplWP1i.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-DKMXG57X.mjs +26 -0
- 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-DnwyBL4I.mjs → hostInit-kjsveCjG.mjs} +3 -3
- package/dist/stream-broker/index.js +105 -7
- package/dist/stream-broker/index.mjs +105 -7
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DzRUHIcM.js → worker-protocol-DvTZBvh2.js} +1 -1
- package/dist/{worker-protocol-CMDUAqvX.mjs → worker-protocol-DxsVx0G7.mjs} +1 -1
- package/package.json +4 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CVBnNONy.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-77rGxv1J.mjs +0 -26
package/dist/recorder/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
const require_dist = require("../dist-
|
|
1
|
+
const require_dist = require("../dist-BzLiQ8Ue.js");
|
|
2
2
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
3
3
|
const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
|
|
4
|
-
const require_addon_utils = require("../addon-utils-
|
|
4
|
+
const require_addon_utils = require("../addon-utils-CKf8kbwh.js");
|
|
5
5
|
const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
7
|
let node_child_process = require("node:child_process");
|
|
@@ -237,81 +237,6 @@ var EventMap = class {
|
|
|
237
237
|
}
|
|
238
238
|
};
|
|
239
239
|
//#endregion
|
|
240
|
-
//#region src/recorder/redundant-segments.ts
|
|
241
|
-
/**
|
|
242
|
-
* Return the indices (into `segs`) of segments safe to delete: those fully
|
|
243
|
-
* contained within a kept segment `[coverStart, coverEnd]`. Sorting by start
|
|
244
|
-
* ascending, then by end DESCENDING, guarantees the containing (longer) segment
|
|
245
|
-
* is seen first and kept, and any shorter segment nested inside it is removed.
|
|
246
|
-
*/
|
|
247
|
-
function selectRedundantSegments(segs) {
|
|
248
|
-
if (segs.length < 2) return [];
|
|
249
|
-
const order = segs.map((s, i) => ({
|
|
250
|
-
start: s.startMs,
|
|
251
|
-
end: s.startMs + s.durMs,
|
|
252
|
-
i
|
|
253
|
-
})).toSorted((a, b) => a.start - b.start || b.end - a.end);
|
|
254
|
-
const remove = [];
|
|
255
|
-
let coverStart = Number.NEGATIVE_INFINITY;
|
|
256
|
-
let coverEnd = Number.NEGATIVE_INFINITY;
|
|
257
|
-
for (const seg of order) if (seg.start >= coverStart && seg.end <= coverEnd && seg.end > seg.start) remove.push(seg.i);
|
|
258
|
-
else {
|
|
259
|
-
coverStart = seg.start;
|
|
260
|
-
coverEnd = seg.end;
|
|
261
|
-
}
|
|
262
|
-
return remove;
|
|
263
|
-
}
|
|
264
|
-
//#endregion
|
|
265
|
-
//#region src/recorder/janitor.ts
|
|
266
|
-
/** Group an array by a string key. */
|
|
267
|
-
function groupBy(items, key) {
|
|
268
|
-
const out = /* @__PURE__ */ new Map();
|
|
269
|
-
for (const it of items) {
|
|
270
|
-
const k = key(it);
|
|
271
|
-
const arr = out.get(k);
|
|
272
|
-
if (arr) arr.push(it);
|
|
273
|
-
else out.set(k, [it]);
|
|
274
|
-
}
|
|
275
|
-
return out;
|
|
276
|
-
}
|
|
277
|
-
async function runRedundancyJanitor(deps) {
|
|
278
|
-
let files = 0;
|
|
279
|
-
let bytes = 0;
|
|
280
|
-
for (const deviceId of deps.deviceIds) {
|
|
281
|
-
const byProfile = groupBy(deps.segmentsFor(deviceId), (r) => r.profile);
|
|
282
|
-
for (const [profile, rows] of byProfile) {
|
|
283
|
-
const sorted = [...rows].toSorted((a, b) => a.startMs - b.startMs);
|
|
284
|
-
const redundant = selectRedundantSegments(sorted);
|
|
285
|
-
if (redundant.length === 0) continue;
|
|
286
|
-
const victims = redundant.map((i) => sorted[i]);
|
|
287
|
-
for (const [location, vrows] of groupBy(victims, (v) => v.locationId)) try {
|
|
288
|
-
const reclaimed = await deps.evict(location, vrows);
|
|
289
|
-
files += vrows.length;
|
|
290
|
-
bytes += reclaimed;
|
|
291
|
-
} catch (err) {
|
|
292
|
-
deps.logger.warn("janitor: evict failed (skipping)", {
|
|
293
|
-
tags: { deviceId },
|
|
294
|
-
meta: {
|
|
295
|
-
deviceId,
|
|
296
|
-
profile,
|
|
297
|
-
location,
|
|
298
|
-
count: vrows.length,
|
|
299
|
-
error: err instanceof Error ? err.message : String(err)
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
if (files > 0) deps.logger.info("janitor: removed redundant recording segments", { meta: {
|
|
306
|
-
files,
|
|
307
|
-
bytes
|
|
308
|
-
} });
|
|
309
|
-
return {
|
|
310
|
-
files,
|
|
311
|
-
bytes
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
//#endregion
|
|
315
240
|
//#region src/recorder/init-info.ts
|
|
316
241
|
/** Walk sibling boxes in [from, to), yielding [type, start, end]. */
|
|
317
242
|
function* boxes$1(buf, view, from, to) {
|
|
@@ -1291,69 +1216,6 @@ var RecordingIndex = class {
|
|
|
1291
1216
|
for (const m of this.byDevice.values()) for (const s of m.values()) if (s.locationId === locationId) out.push(s);
|
|
1292
1217
|
return out.toSorted((a, b) => a.startMs - b.startMs);
|
|
1293
1218
|
}
|
|
1294
|
-
/**
|
|
1295
|
-
* All segments across a SET of storage locations (one eviction DOMAIN — the
|
|
1296
|
-
* locations that alias one physical filesystem root), oldest-first across the
|
|
1297
|
-
* whole pool. This is the correct unit for byte-eviction: two location ids over
|
|
1298
|
-
* the same disk must be reclaimed as ONE oldest-first stream, never partitioned.
|
|
1299
|
-
*/
|
|
1300
|
-
segmentsOnLocations(locationIds) {
|
|
1301
|
-
const out = [];
|
|
1302
|
-
for (const m of this.byDevice.values()) for (const s of m.values()) if (locationIds.has(s.locationId)) out.push(s);
|
|
1303
|
-
return out.toSorted((a, b) => a.startMs - b.startMs);
|
|
1304
|
-
}
|
|
1305
|
-
/** Aggregate accounting across a SET of storage locations (one eviction domain). */
|
|
1306
|
-
accountingForLocations(locationIds) {
|
|
1307
|
-
let bytes = 0;
|
|
1308
|
-
let count = 0;
|
|
1309
|
-
let oldestMs = Infinity;
|
|
1310
|
-
let newestMs = -Infinity;
|
|
1311
|
-
for (const m of this.byDevice.values()) for (const s of m.values()) {
|
|
1312
|
-
if (!locationIds.has(s.locationId)) continue;
|
|
1313
|
-
bytes += s.bytes;
|
|
1314
|
-
count += 1;
|
|
1315
|
-
if (s.startMs < oldestMs) oldestMs = s.startMs;
|
|
1316
|
-
if (s.startMs > newestMs) newestMs = s.startMs;
|
|
1317
|
-
}
|
|
1318
|
-
if (count === 0) return {
|
|
1319
|
-
bytes: 0,
|
|
1320
|
-
count: 0,
|
|
1321
|
-
oldestMs: null,
|
|
1322
|
-
newestMs: null
|
|
1323
|
-
};
|
|
1324
|
-
return {
|
|
1325
|
-
bytes,
|
|
1326
|
-
count,
|
|
1327
|
-
oldestMs,
|
|
1328
|
-
newestMs
|
|
1329
|
-
};
|
|
1330
|
-
}
|
|
1331
|
-
/** Aggregate accounting for a storage location across every device. */
|
|
1332
|
-
accountingForLocation(locationId) {
|
|
1333
|
-
let bytes = 0;
|
|
1334
|
-
let count = 0;
|
|
1335
|
-
let oldestMs = Infinity;
|
|
1336
|
-
let newestMs = -Infinity;
|
|
1337
|
-
for (const m of this.byDevice.values()) for (const s of m.values()) {
|
|
1338
|
-
if (s.locationId !== locationId) continue;
|
|
1339
|
-
bytes += s.bytes;
|
|
1340
|
-
count += 1;
|
|
1341
|
-
if (s.startMs < oldestMs) oldestMs = s.startMs;
|
|
1342
|
-
if (s.startMs > newestMs) newestMs = s.startMs;
|
|
1343
|
-
}
|
|
1344
|
-
if (count === 0) return {
|
|
1345
|
-
bytes: 0,
|
|
1346
|
-
count: 0,
|
|
1347
|
-
oldestMs: null,
|
|
1348
|
-
newestMs: null
|
|
1349
|
-
};
|
|
1350
|
-
return {
|
|
1351
|
-
bytes,
|
|
1352
|
-
count,
|
|
1353
|
-
oldestMs,
|
|
1354
|
-
newestMs
|
|
1355
|
-
};
|
|
1356
|
-
}
|
|
1357
1219
|
/** Binary-search a start-sorted slice for the segment containing epochMs. */
|
|
1358
1220
|
segmentAtIn(segs, epochMs) {
|
|
1359
1221
|
if (segs.length === 0) return null;
|
|
@@ -2256,12 +2118,36 @@ var StorageEvictableProvider = class {
|
|
|
2256
2118
|
for (const id of domain) if (!this.deps.isReadOnly(id)) writable.add(id);
|
|
2257
2119
|
return writable;
|
|
2258
2120
|
}
|
|
2121
|
+
/**
|
|
2122
|
+
* How many bytes this domain holds — ONE aggregation, no rows returned.
|
|
2123
|
+
*
|
|
2124
|
+
* The storage-pressure sweep asks this per managed location every 60 s, and
|
|
2125
|
+
* two locations aliasing one root make it twice a minute. It used to be a
|
|
2126
|
+
* walk over every indexed segment on the node (7.1 M rows live), which is
|
|
2127
|
+
* also why the whole archive had to stay resident to be walked.
|
|
2128
|
+
*/
|
|
2259
2129
|
async getEvictableUsage(input) {
|
|
2260
|
-
|
|
2130
|
+
const archive = this.deps.archive();
|
|
2131
|
+
if (archive === null) {
|
|
2132
|
+
this.deps.logger.warn("recorder: evictable usage unavailable — no footage archive to measure, reporting 0 evictable bytes", { meta: { locationId: input.locationId } });
|
|
2133
|
+
return { bytes: 0 };
|
|
2134
|
+
}
|
|
2135
|
+
return { bytes: (await archive.accountingForLocations(this.domainOf(input.locationId))).bytes };
|
|
2261
2136
|
}
|
|
2262
2137
|
async evict(input) {
|
|
2263
|
-
const
|
|
2264
|
-
|
|
2138
|
+
const archive = this.deps.archive();
|
|
2139
|
+
if (archive === null) {
|
|
2140
|
+
this.deps.logger.warn("recorder: disk-pressure eviction skipped — no footage archive to pick the oldest from", { meta: {
|
|
2141
|
+
locationId: input.locationId,
|
|
2142
|
+
targetBytes: input.targetBytes
|
|
2143
|
+
} });
|
|
2144
|
+
return {
|
|
2145
|
+
reclaimedBytes: 0,
|
|
2146
|
+
exhausted: true
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
const oldest = await archive.oldestSegments(this.domainOf(input.locationId), input.targetBytes);
|
|
2150
|
+
const victims = selectEvictionsByBytes(oldest.rows, input.targetBytes);
|
|
2265
2151
|
const byLocation = /* @__PURE__ */ new Map();
|
|
2266
2152
|
for (const v of victims) {
|
|
2267
2153
|
const group = byLocation.get(v.locationId);
|
|
@@ -2271,7 +2157,7 @@ var StorageEvictableProvider = class {
|
|
|
2271
2157
|
let reclaimedBytes = 0;
|
|
2272
2158
|
for (const [locationId, rows] of byLocation) reclaimedBytes += await this.deps.store.evict(locationId, rows);
|
|
2273
2159
|
if (reclaimedBytes > 0 && this.deps.onEvicted) this.deps.onEvicted(summarizeEvictionsByDevice(victims));
|
|
2274
|
-
const exhausted = victims.length >=
|
|
2160
|
+
const exhausted = oldest.exhausted && victims.length >= oldest.rows.length;
|
|
2275
2161
|
return {
|
|
2276
2162
|
reclaimedBytes,
|
|
2277
2163
|
exhausted
|
|
@@ -3068,6 +2954,7 @@ var CENSUS_MAX_WINDOW_MS = 10 * 6e4;
|
|
|
3068
2954
|
var CENSUSED_PROCEDURES = new Set([
|
|
3069
2955
|
"query",
|
|
3070
2956
|
"count",
|
|
2957
|
+
"aggregate",
|
|
3071
2958
|
"get",
|
|
3072
2959
|
"histogram",
|
|
3073
2960
|
"getAll"
|
|
@@ -5558,7 +5445,7 @@ function buildRecordingProvider(deps) {
|
|
|
5558
5445
|
locationId: first.id,
|
|
5559
5446
|
locationIds: ids,
|
|
5560
5447
|
root: first.root,
|
|
5561
|
-
usedBytes: deps.
|
|
5448
|
+
usedBytes: (await deps.archiveAccounting?.(new Set(ids)))?.bytes ?? 0,
|
|
5562
5449
|
availableBytes: cap.availableBytes,
|
|
5563
5450
|
totalBytes: cap.totalBytes
|
|
5564
5451
|
};
|
|
@@ -6338,6 +6225,7 @@ Object.freeze({
|
|
|
6338
6225
|
"listProfiles",
|
|
6339
6226
|
"listRuntimeNodes"
|
|
6340
6227
|
],
|
|
6228
|
+
"log-channels": ["list"],
|
|
6341
6229
|
"log-destination": ["query"],
|
|
6342
6230
|
"login-method": ["getLoginMethods"],
|
|
6343
6231
|
"mqtt-broker": ["listBrokers"],
|
|
@@ -7088,61 +6976,6 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
|
|
|
7088
6976
|
}
|
|
7089
6977
|
/** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
|
|
7090
6978
|
var HYDRATE_CHUNK = 2e4;
|
|
7091
|
-
/**
|
|
7092
|
-
* Device walks in flight at once in {@link hydrateDevicesFromStorage}.
|
|
7093
|
-
*
|
|
7094
|
-
* The walk is `readdir` latency on a network/FUSE share, not CPU: measured on
|
|
7095
|
-
* the live hub, 24 cold hour directories cost 31 s one at a time and 16.7 s
|
|
7096
|
-
* eight at a time. Four is the conservative point on that curve — enough to
|
|
7097
|
-
* hide most of the latency, few enough that the boot walk never becomes the
|
|
7098
|
-
* reason a live SegmentWriter waits. libuv's default threadpool is 4, so a
|
|
7099
|
-
* higher number here mostly queues.
|
|
7100
|
-
*/
|
|
7101
|
-
var HYDRATE_DEVICE_CONCURRENCY = 4;
|
|
7102
|
-
/**
|
|
7103
|
-
* Walk the archive for a whole fleet: bounded-parallel, and each camera becomes
|
|
7104
|
-
* authoritative the moment ITS OWN walk lands (D167).
|
|
7105
|
-
*
|
|
7106
|
-
* Two things this fixes, both measured on the live hub on 2026-08-15:
|
|
7107
|
-
*
|
|
7108
|
-
* - The caller was `for (const id of ids) await hydrateDeviceFromStorage(...)`,
|
|
7109
|
-
* so seven cameras' 357 s of `readdir` WAS the 362 s wall clock. Nothing in
|
|
7110
|
-
* the walk contends for CPU — it is share latency — so overlapping is free.
|
|
7111
|
-
* - `markHydratedAll` ran in a SECOND loop after every device. A camera whose
|
|
7112
|
-
* walk finished at minute 1 still reported `hydrationOf → 'unknown'` until
|
|
7113
|
-
* minute 6, and every read of it in that window paid for an on-demand walk
|
|
7114
|
-
* over rows already in the index, competing with the walk still running for
|
|
7115
|
-
* the others.
|
|
7116
|
-
*
|
|
7117
|
-
* A device whose walk throws is NOT marked: `unknown` is the honest answer, and
|
|
7118
|
-
* it is what keeps the read path walking that window on demand.
|
|
7119
|
-
*/
|
|
7120
|
-
async function hydrateDevicesFromStorage(api, index, deviceIds, locations, logger, options = {}) {
|
|
7121
|
-
const concurrency = Math.max(1, options.concurrency ?? HYDRATE_DEVICE_CONCURRENCY);
|
|
7122
|
-
const queue = [...deviceIds];
|
|
7123
|
-
const walkOptions = {
|
|
7124
|
-
...options.yieldBetween ? { yieldBetween: options.yieldBetween } : {},
|
|
7125
|
-
...options.chunkSize !== void 0 ? { chunkSize: options.chunkSize } : {}
|
|
7126
|
-
};
|
|
7127
|
-
const lane = async () => {
|
|
7128
|
-
for (;;) {
|
|
7129
|
-
const deviceId = queue.shift();
|
|
7130
|
-
if (deviceId === void 0) return;
|
|
7131
|
-
try {
|
|
7132
|
-
options.onDeviceStarted?.(deviceId);
|
|
7133
|
-
await hydrateDeviceFromStorage(api, index, deviceId, locations, logger, walkOptions);
|
|
7134
|
-
index.markHydratedAll(deviceId);
|
|
7135
|
-
options.onDeviceHydrated?.(deviceId);
|
|
7136
|
-
} catch (err) {
|
|
7137
|
-
logger.warn("recorder: device hydrate failed — window stays unknown, reads will walk it", {
|
|
7138
|
-
tags: { deviceId },
|
|
7139
|
-
meta: { error: require_dist.errMsg(err) }
|
|
7140
|
-
});
|
|
7141
|
-
}
|
|
7142
|
-
}
|
|
7143
|
-
};
|
|
7144
|
-
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, lane));
|
|
7145
|
-
}
|
|
7146
6979
|
var macrotask$1 = () => new Promise((resolve) => {
|
|
7147
6980
|
setImmediate(resolve);
|
|
7148
6981
|
});
|
|
@@ -7713,7 +7546,7 @@ var PlacementService = class {
|
|
|
7713
7546
|
limits.push(capacity.availableBytes - floor);
|
|
7714
7547
|
}
|
|
7715
7548
|
if (location.maxUsedGb !== void 0) {
|
|
7716
|
-
const used = this.deps.usedOnLocation?.(location.id) ?? 0;
|
|
7549
|
+
const used = await this.deps.usedOnLocation?.(location.id) ?? 0;
|
|
7717
7550
|
limits.push(location.maxUsedGb * 1e9 - used);
|
|
7718
7551
|
}
|
|
7719
7552
|
out.push({
|
|
@@ -11092,6 +10925,14 @@ var HOUR_MS$1 = 36e5;
|
|
|
11092
10925
|
* retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
|
|
11093
10926
|
*/
|
|
11094
10927
|
var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
|
|
10928
|
+
/**
|
|
10929
|
+
* The four aggregate columns exist so "how much footage is on this disk" is a
|
|
10930
|
+
* `SUM`/`MIN`/`MAX` over ~19 k hour rows instead of a walk over ~7.1 M in-RAM
|
|
10931
|
+
* segment rows. They are DERIVED from `paths` — never a second authority — and
|
|
10932
|
+
* {@link hourAggregate} is the only place that derives them, so a row loaded
|
|
10933
|
+
* from a database that predates these columns is corrected in the mirror the
|
|
10934
|
+
* moment it is read and repaired on disk by {@link SegmentHourLedger.repairAggregates}.
|
|
10935
|
+
*/
|
|
11095
10936
|
var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
11096
10937
|
{
|
|
11097
10938
|
name: "key",
|
|
@@ -11123,15 +10964,85 @@ var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
|
11123
10964
|
name: "paths",
|
|
11124
10965
|
type: "TEXT",
|
|
11125
10966
|
notNull: true
|
|
10967
|
+
},
|
|
10968
|
+
{
|
|
10969
|
+
name: "bytes",
|
|
10970
|
+
type: "INTEGER",
|
|
10971
|
+
notNull: true,
|
|
10972
|
+
defaultValue: 0
|
|
10973
|
+
},
|
|
10974
|
+
{
|
|
10975
|
+
name: "segments",
|
|
10976
|
+
type: "INTEGER",
|
|
10977
|
+
notNull: true,
|
|
10978
|
+
defaultValue: 0
|
|
10979
|
+
},
|
|
10980
|
+
{
|
|
10981
|
+
name: "minStartMs",
|
|
10982
|
+
type: "INTEGER",
|
|
10983
|
+
notNull: true,
|
|
10984
|
+
defaultValue: 0
|
|
10985
|
+
},
|
|
10986
|
+
{
|
|
10987
|
+
name: "maxStartMs",
|
|
10988
|
+
type: "INTEGER",
|
|
10989
|
+
notNull: true,
|
|
10990
|
+
defaultValue: 0
|
|
11126
10991
|
}
|
|
11127
10992
|
];
|
|
11128
|
-
var RECORDING_SEGMENT_HOURS_INDEXES = [
|
|
11129
|
-
|
|
11130
|
-
|
|
11131
|
-
|
|
11132
|
-
|
|
11133
|
-
|
|
11134
|
-
|
|
10993
|
+
var RECORDING_SEGMENT_HOURS_INDEXES = [
|
|
10994
|
+
{
|
|
10995
|
+
name: "idx_recorder_segment_hours_device",
|
|
10996
|
+
columns: ["deviceId"]
|
|
10997
|
+
},
|
|
10998
|
+
{
|
|
10999
|
+
name: "idx_recorder_segment_hours_hour",
|
|
11000
|
+
columns: ["hourStartMs"]
|
|
11001
|
+
},
|
|
11002
|
+
(
|
|
11003
|
+
/** The eviction read: one oldest-first stream across a SET of location ids.
|
|
11004
|
+
* Location leads so `locationId IN (…)` is a range scan and `hourStartMs`
|
|
11005
|
+
* supplies the order without a sort. */
|
|
11006
|
+
{
|
|
11007
|
+
name: "idx_recorder_segment_hours_loc_hour",
|
|
11008
|
+
columns: ["locationId", "hourStartMs"]
|
|
11009
|
+
})
|
|
11010
|
+
];
|
|
11011
|
+
/**
|
|
11012
|
+
* Bytes / count / oldest / newest for one hour's paths.
|
|
11013
|
+
*
|
|
11014
|
+
* Every field of a segment path encodes its own facts (`parseSegmentPath`), so
|
|
11015
|
+
* this is a derivation, not a measurement: it can never disagree with the paths
|
|
11016
|
+
* it was computed from. A path the parser refuses contributes nothing — it is
|
|
11017
|
+
* not a segment, and counting it would inflate a disk report.
|
|
11018
|
+
*/
|
|
11019
|
+
function hourAggregate(paths) {
|
|
11020
|
+
let bytes = 0;
|
|
11021
|
+
let segments = 0;
|
|
11022
|
+
let minStartMs = 0;
|
|
11023
|
+
let maxStartMs = 0;
|
|
11024
|
+
for (const path of paths) {
|
|
11025
|
+
const p = parseSegmentPath(path);
|
|
11026
|
+
if (p === null) continue;
|
|
11027
|
+
bytes += p.bytes;
|
|
11028
|
+
if (segments === 0 || p.startMs < minStartMs) minStartMs = p.startMs;
|
|
11029
|
+
if (segments === 0 || p.startMs > maxStartMs) maxStartMs = p.startMs;
|
|
11030
|
+
segments += 1;
|
|
11031
|
+
}
|
|
11032
|
+
return {
|
|
11033
|
+
bytes,
|
|
11034
|
+
segments,
|
|
11035
|
+
minStartMs,
|
|
11036
|
+
maxStartMs
|
|
11037
|
+
};
|
|
11038
|
+
}
|
|
11039
|
+
/** Build the persisted row for an hour from its paths — the ONE constructor. */
|
|
11040
|
+
function segmentHourRow(base) {
|
|
11041
|
+
return {
|
|
11042
|
+
...base,
|
|
11043
|
+
...hourAggregate(base.paths)
|
|
11044
|
+
};
|
|
11045
|
+
}
|
|
11135
11046
|
/**
|
|
11136
11047
|
* The largest share of a device's held hours a single walk may delete.
|
|
11137
11048
|
*
|
|
@@ -11143,6 +11054,49 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [{
|
|
|
11143
11054
|
* `readdir` over an unmounted root deletes all of it.
|
|
11144
11055
|
*/
|
|
11145
11056
|
var LEDGER_PRUNE_MAX_SHARE = .5;
|
|
11057
|
+
/** Backstop on the oldest-first probe. A bounded read must stay bounded even if
|
|
11058
|
+
* a caller asks for a target no archive can cover. */
|
|
11059
|
+
var OLDEST_MAX_PAGES = 16;
|
|
11060
|
+
/** Phase-one columns: everything the accumulation needs and nothing fat. */
|
|
11061
|
+
var OLDEST_PROBE_COLUMNS = [
|
|
11062
|
+
"hourStartMs",
|
|
11063
|
+
"bytes",
|
|
11064
|
+
"locationId"
|
|
11065
|
+
];
|
|
11066
|
+
/** Aggregate-repair probe: the primary key always comes back, so this asks for
|
|
11067
|
+
* the predicate column alone. */
|
|
11068
|
+
var REPAIR_PROBE_COLUMNS = ["segments"];
|
|
11069
|
+
/** Rows repaired per boot. The live archive is ~19 k hour rows. */
|
|
11070
|
+
var REPAIR_MAX_ROWS = 5e4;
|
|
11071
|
+
/** The four scalars an accounting answer is made of, asked in one statement. */
|
|
11072
|
+
var ACCOUNTING_FIELDS = [
|
|
11073
|
+
{
|
|
11074
|
+
as: "bytes",
|
|
11075
|
+
field: "bytes",
|
|
11076
|
+
op: "sum"
|
|
11077
|
+
},
|
|
11078
|
+
{
|
|
11079
|
+
as: "segments",
|
|
11080
|
+
field: "segments",
|
|
11081
|
+
op: "sum"
|
|
11082
|
+
},
|
|
11083
|
+
{
|
|
11084
|
+
as: "oldestMs",
|
|
11085
|
+
field: "minStartMs",
|
|
11086
|
+
op: "min"
|
|
11087
|
+
},
|
|
11088
|
+
{
|
|
11089
|
+
as: "newestMs",
|
|
11090
|
+
field: "maxStartMs",
|
|
11091
|
+
op: "max"
|
|
11092
|
+
}
|
|
11093
|
+
];
|
|
11094
|
+
var EMPTY_ACCOUNTING = {
|
|
11095
|
+
bytes: 0,
|
|
11096
|
+
count: 0,
|
|
11097
|
+
oldestMs: null,
|
|
11098
|
+
newestMs: null
|
|
11099
|
+
};
|
|
11146
11100
|
function hourStartMs(startMs) {
|
|
11147
11101
|
return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
|
|
11148
11102
|
}
|
|
@@ -11178,7 +11132,11 @@ function rowToValue(row) {
|
|
|
11178
11132
|
profile: row.profile,
|
|
11179
11133
|
locationId: row.locationId,
|
|
11180
11134
|
hourStartMs: row.hourStartMs,
|
|
11181
|
-
paths: JSON.stringify(row.paths)
|
|
11135
|
+
paths: JSON.stringify(row.paths),
|
|
11136
|
+
bytes: row.bytes,
|
|
11137
|
+
segments: row.segments,
|
|
11138
|
+
minStartMs: row.minStartMs,
|
|
11139
|
+
maxStartMs: row.maxStartMs
|
|
11182
11140
|
};
|
|
11183
11141
|
}
|
|
11184
11142
|
function recordToRow(key, data) {
|
|
@@ -11193,14 +11151,14 @@ function recordToRow(key, data) {
|
|
|
11193
11151
|
return null;
|
|
11194
11152
|
}
|
|
11195
11153
|
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;
|
|
11196
|
-
return {
|
|
11154
|
+
return segmentHourRow({
|
|
11197
11155
|
key,
|
|
11198
11156
|
deviceId,
|
|
11199
11157
|
profile,
|
|
11200
11158
|
locationId,
|
|
11201
11159
|
hourStartMs: hour,
|
|
11202
11160
|
paths: raw
|
|
11203
|
-
};
|
|
11161
|
+
});
|
|
11204
11162
|
}
|
|
11205
11163
|
var SPEC = {
|
|
11206
11164
|
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
@@ -11216,9 +11174,15 @@ var SPEC = {
|
|
|
11216
11174
|
var SegmentHourLedger = class {
|
|
11217
11175
|
ledger;
|
|
11218
11176
|
logger;
|
|
11177
|
+
/** Held for the two ARCHIVE reads below, which are queries rather than mirror
|
|
11178
|
+
* lookups. Every DECISION path (`recordSegment`, `dropSegments`, the prune
|
|
11179
|
+
* refusals) still answers from the mirror alone — D49 is about gates, and an
|
|
11180
|
+
* accounting number is not a gate. */
|
|
11181
|
+
store;
|
|
11219
11182
|
isLocationUsable;
|
|
11220
11183
|
constructor(deps) {
|
|
11221
11184
|
this.logger = deps.logger;
|
|
11185
|
+
this.store = deps.store;
|
|
11222
11186
|
this.isLocationUsable = deps.isLocationUsable ?? (() => true);
|
|
11223
11187
|
this.ledger = new DurableLedger({
|
|
11224
11188
|
spec: SPEC,
|
|
@@ -11253,19 +11217,178 @@ var SegmentHourLedger = class {
|
|
|
11253
11217
|
hydrateIndex(index) {
|
|
11254
11218
|
for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
11255
11219
|
}
|
|
11220
|
+
/**
|
|
11221
|
+
* Bytes / count / oldest / newest across a SET of storage locations, as ONE
|
|
11222
|
+
* `SUM`/`COUNT`/`MIN`/`MAX` over the hour rows.
|
|
11223
|
+
*
|
|
11224
|
+
* The set is the eviction DOMAIN — the location ids that alias one physical
|
|
11225
|
+
* root. It goes into a single statement as `locationId IN (…)`, never a loop
|
|
11226
|
+
* per location: two ids over one disk are one pool, and asking twice is how
|
|
11227
|
+
* an answer about a disk gets partitioned.
|
|
11228
|
+
*/
|
|
11229
|
+
async accountingForLocations(locationIds) {
|
|
11230
|
+
const ids = [...locationIds];
|
|
11231
|
+
if (ids.length === 0) return EMPTY_ACCOUNTING;
|
|
11232
|
+
const out = await this.store.aggregate.query({
|
|
11233
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
11234
|
+
fields: ACCOUNTING_FIELDS,
|
|
11235
|
+
filter: { whereIn: { locationId: ids } }
|
|
11236
|
+
});
|
|
11237
|
+
const segments = out.values["segments"];
|
|
11238
|
+
if (out.count === 0 || segments === null || segments === void 0) return EMPTY_ACCOUNTING;
|
|
11239
|
+
return {
|
|
11240
|
+
bytes: out.values["bytes"] ?? 0,
|
|
11241
|
+
count: segments,
|
|
11242
|
+
oldestMs: out.values["oldestMs"] ?? null,
|
|
11243
|
+
newestMs: out.values["newestMs"] ?? null
|
|
11244
|
+
};
|
|
11245
|
+
}
|
|
11246
|
+
/**
|
|
11247
|
+
* The OLDEST segments across a set of storage locations — enough of them to
|
|
11248
|
+
* cover `targetBytes`, and no more.
|
|
11249
|
+
*
|
|
11250
|
+
* ## The constraint this read exists to keep
|
|
11251
|
+
*
|
|
11252
|
+
* Locations aliasing one physical root must be reclaimed as ONE oldest-first
|
|
11253
|
+
* stream. That is not a property of the CALLER here, it is a property of the
|
|
11254
|
+
* STATEMENT: every id of the domain rides in a single
|
|
11255
|
+
* `locationId IN (…) ORDER BY hourStartMs ASC`. A query per location plus a
|
|
11256
|
+
* merge would be the age-partition data-loss bug rebuilt in a second place.
|
|
11257
|
+
*
|
|
11258
|
+
* ## Why two phases
|
|
11259
|
+
*
|
|
11260
|
+
* Phase one reads only the small columns and walks oldest-first until the
|
|
11261
|
+
* accumulated bytes cover the target — normally one page. `paths` is by far
|
|
11262
|
+
* the fattest column in the row and phase one never touches it. Phase two
|
|
11263
|
+
* fetches paths for exactly the hours phase one chose.
|
|
11264
|
+
*
|
|
11265
|
+
* `exhausted` means the stream ran out before the target was met — the
|
|
11266
|
+
* caller's stop condition, and the one thing a bounded read must still be
|
|
11267
|
+
* able to say truthfully.
|
|
11268
|
+
*/
|
|
11269
|
+
async oldestSegments(locationIds, targetBytes) {
|
|
11270
|
+
const ids = [...locationIds];
|
|
11271
|
+
if (ids.length === 0 || targetBytes <= 0) return {
|
|
11272
|
+
rows: [],
|
|
11273
|
+
exhausted: true
|
|
11274
|
+
};
|
|
11275
|
+
const chosen = [];
|
|
11276
|
+
let covered = 0;
|
|
11277
|
+
let offset = 0;
|
|
11278
|
+
let exhausted = false;
|
|
11279
|
+
for (let page = 0; page < OLDEST_MAX_PAGES && covered < targetBytes; page++) {
|
|
11280
|
+
const records = await this.store.query.query({
|
|
11281
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
11282
|
+
columns: OLDEST_PROBE_COLUMNS,
|
|
11283
|
+
filter: {
|
|
11284
|
+
whereIn: { locationId: ids },
|
|
11285
|
+
orderBy: {
|
|
11286
|
+
field: "hourStartMs",
|
|
11287
|
+
direction: "asc"
|
|
11288
|
+
},
|
|
11289
|
+
limit: 32,
|
|
11290
|
+
offset
|
|
11291
|
+
}
|
|
11292
|
+
});
|
|
11293
|
+
for (const record of records) {
|
|
11294
|
+
chosen.push(record.id);
|
|
11295
|
+
const bytes = record.data["bytes"];
|
|
11296
|
+
covered += typeof bytes === "number" ? bytes : 0;
|
|
11297
|
+
}
|
|
11298
|
+
offset += records.length;
|
|
11299
|
+
if (records.length < 32) {
|
|
11300
|
+
exhausted = true;
|
|
11301
|
+
break;
|
|
11302
|
+
}
|
|
11303
|
+
}
|
|
11304
|
+
if (chosen.length === 0) return {
|
|
11305
|
+
rows: [],
|
|
11306
|
+
exhausted: true
|
|
11307
|
+
};
|
|
11308
|
+
const full = await this.store.query.query({
|
|
11309
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
11310
|
+
filter: {
|
|
11311
|
+
whereIn: { key: chosen },
|
|
11312
|
+
limit: chosen.length
|
|
11313
|
+
}
|
|
11314
|
+
});
|
|
11315
|
+
const rows = [];
|
|
11316
|
+
for (const record of full) {
|
|
11317
|
+
const hour = recordToRow(record.id, record.data);
|
|
11318
|
+
if (hour === null) continue;
|
|
11319
|
+
for (const path of hour.paths) {
|
|
11320
|
+
const parsed = parseSegmentPath(path);
|
|
11321
|
+
if (parsed === null) continue;
|
|
11322
|
+
rows.push({
|
|
11323
|
+
deviceId: parsed.deviceId,
|
|
11324
|
+
profile: parsed.profile,
|
|
11325
|
+
startMs: parsed.startMs,
|
|
11326
|
+
durMs: parsed.durMs,
|
|
11327
|
+
bytes: parsed.bytes,
|
|
11328
|
+
path,
|
|
11329
|
+
locationId: hour.locationId
|
|
11330
|
+
});
|
|
11331
|
+
}
|
|
11332
|
+
}
|
|
11333
|
+
return {
|
|
11334
|
+
rows: rows.toSorted((a, b) => a.startMs - b.startMs),
|
|
11335
|
+
exhausted
|
|
11336
|
+
};
|
|
11337
|
+
}
|
|
11338
|
+
/**
|
|
11339
|
+
* Rewrite, once, the rows whose aggregate columns are still the schema
|
|
11340
|
+
* default.
|
|
11341
|
+
*
|
|
11342
|
+
* `declareCollection` adds a column additively, so every row written before
|
|
11343
|
+
* the aggregate columns existed holds `segments = 0` — and an hour row with
|
|
11344
|
+
* no segments cannot exist any other way (`dropSegments` forgets an hour that
|
|
11345
|
+
* empties). So `segments = 0` IS the backfill predicate, and after one pass
|
|
11346
|
+
* it selects nothing. A partial repair finishes on the next boot; until then
|
|
11347
|
+
* the affected disks UNDER-report, which prunes less and never more.
|
|
11348
|
+
*/
|
|
11349
|
+
async repairAggregates() {
|
|
11350
|
+
let repaired = 0;
|
|
11351
|
+
try {
|
|
11352
|
+
const stale = await this.store.query.query({
|
|
11353
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
11354
|
+
columns: REPAIR_PROBE_COLUMNS,
|
|
11355
|
+
filter: {
|
|
11356
|
+
where: { segments: 0 },
|
|
11357
|
+
limit: REPAIR_MAX_ROWS
|
|
11358
|
+
}
|
|
11359
|
+
});
|
|
11360
|
+
for (const record of stale) {
|
|
11361
|
+
const held = this.ledger.get(record.id);
|
|
11362
|
+
if (held === void 0 || held.segments === 0) continue;
|
|
11363
|
+
await this.ledger.put(held);
|
|
11364
|
+
repaired += 1;
|
|
11365
|
+
}
|
|
11366
|
+
} catch (err) {
|
|
11367
|
+
this.logger.warn("recorder: segment-hour aggregate repair failed — disks under-report", { meta: {
|
|
11368
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
11369
|
+
error: String(err)
|
|
11370
|
+
} });
|
|
11371
|
+
return repaired;
|
|
11372
|
+
}
|
|
11373
|
+
if (repaired > 0) this.logger.info("recorder: segment-hour aggregate columns backfilled", { meta: {
|
|
11374
|
+
rows: repaired,
|
|
11375
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION
|
|
11376
|
+
} });
|
|
11377
|
+
return repaired;
|
|
11378
|
+
}
|
|
11256
11379
|
/** Write-behind: a just-finalized segment joins its hour row. */
|
|
11257
11380
|
async recordSegment(row) {
|
|
11258
11381
|
const { key, hourStartMs: hour } = hourOf(row);
|
|
11259
11382
|
const held = this.ledger.get(key);
|
|
11260
11383
|
if (held !== void 0 && held.paths.includes(row.path)) return;
|
|
11261
|
-
await this.ledger.put({
|
|
11384
|
+
await this.ledger.put(segmentHourRow({
|
|
11262
11385
|
key,
|
|
11263
11386
|
deviceId: row.deviceId,
|
|
11264
11387
|
profile: row.profile,
|
|
11265
11388
|
locationId: row.locationId,
|
|
11266
11389
|
hourStartMs: hour,
|
|
11267
11390
|
paths: held === void 0 ? [row.path] : [...held.paths, row.path]
|
|
11268
|
-
});
|
|
11391
|
+
}));
|
|
11269
11392
|
}
|
|
11270
11393
|
/**
|
|
11271
11394
|
* Eviction confirmed these paths are gone from disk. Drop them; forget the
|
|
@@ -11291,10 +11414,10 @@ var SegmentHourLedger = class {
|
|
|
11291
11414
|
continue;
|
|
11292
11415
|
}
|
|
11293
11416
|
if (next.length === held.paths.length) continue;
|
|
11294
|
-
await this.ledger.put({
|
|
11417
|
+
await this.ledger.put(segmentHourRow({
|
|
11295
11418
|
...held,
|
|
11296
11419
|
paths: next
|
|
11297
|
-
});
|
|
11420
|
+
}));
|
|
11298
11421
|
}
|
|
11299
11422
|
}
|
|
11300
11423
|
/**
|
|
@@ -11321,18 +11444,18 @@ var SegmentHourLedger = class {
|
|
|
11321
11444
|
for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
|
|
11322
11445
|
const { key, hourStartMs: hour } = hourOf(seg);
|
|
11323
11446
|
const existing = fromIndex.get(key);
|
|
11324
|
-
if (existing === void 0) fromIndex.set(key, {
|
|
11447
|
+
if (existing === void 0) fromIndex.set(key, segmentHourRow({
|
|
11325
11448
|
key,
|
|
11326
11449
|
deviceId: seg.deviceId,
|
|
11327
11450
|
profile: seg.profile,
|
|
11328
11451
|
locationId: seg.locationId,
|
|
11329
11452
|
hourStartMs: hour,
|
|
11330
11453
|
paths: [seg.path]
|
|
11331
|
-
});
|
|
11332
|
-
else fromIndex.set(key, {
|
|
11454
|
+
}));
|
|
11455
|
+
else fromIndex.set(key, segmentHourRow({
|
|
11333
11456
|
...existing,
|
|
11334
11457
|
paths: [...existing.paths, seg.path]
|
|
11335
|
-
});
|
|
11458
|
+
}));
|
|
11336
11459
|
}
|
|
11337
11460
|
const refused = this.refusedPrunes(walked, fromIndex);
|
|
11338
11461
|
for (const row of this.ledger.snapshot()) {
|
|
@@ -11346,10 +11469,10 @@ var SegmentHourLedger = class {
|
|
|
11346
11469
|
}
|
|
11347
11470
|
if (additiveOnly) {
|
|
11348
11471
|
const union = uniquePaths([...disk.paths, ...row.paths]);
|
|
11349
|
-
if (!samePaths(union, row.paths)) await this.ledger.put({
|
|
11472
|
+
if (!samePaths(union, row.paths)) await this.ledger.put(segmentHourRow({
|
|
11350
11473
|
...row,
|
|
11351
11474
|
paths: union
|
|
11352
|
-
});
|
|
11475
|
+
}));
|
|
11353
11476
|
continue;
|
|
11354
11477
|
}
|
|
11355
11478
|
if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
|
|
@@ -11970,22 +12093,14 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
|
11970
12093
|
* hourly cadence keeps the storage.list/evict cost negligible while honouring
|
|
11971
12094
|
* the recording-spec §6 periodic retention. */
|
|
11972
12095
|
var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
11973
|
-
/**
|
|
11974
|
-
*
|
|
11975
|
-
|
|
11976
|
-
|
|
11977
|
-
|
|
11978
|
-
|
|
11979
|
-
|
|
11980
|
-
|
|
11981
|
-
* (retention sweep, redundancy janitor, disk-pressure eviction) only ever
|
|
11982
|
-
* under-report while the index is partial — so the walk waits out the boot
|
|
11983
|
-
* storm (writers attaching, staging reconcile relocating, first viewer paint)
|
|
11984
|
-
* instead of competing with it. Five minutes because the storm is measured in
|
|
11985
|
-
* seconds-to-a-minute and the consumers re-run on their own cadences anyway
|
|
11986
|
-
* (retention hourly, eviction on pressure).
|
|
11987
|
-
*/
|
|
11988
|
-
var FULL_WALK_DEFER_MS = 5 * 6e4;
|
|
12096
|
+
/** What the per-volume usage row reports when the hour ledger is unavailable:
|
|
12097
|
+
* nothing measured, rather than a total counted off a partial RAM index. */
|
|
12098
|
+
var EMPTY_ARCHIVE_ACCOUNTING = {
|
|
12099
|
+
bytes: 0,
|
|
12100
|
+
count: 0,
|
|
12101
|
+
oldestMs: null,
|
|
12102
|
+
newestMs: null
|
|
12103
|
+
};
|
|
11989
12104
|
/** Grace after a completed download before a `deleteAfterDownload` file is removed. */
|
|
11990
12105
|
var EXPORT_DELETE_GRACE_MS = 6e4;
|
|
11991
12106
|
/** Only `.mp4` export ids matching this shape are servable (traversal guard). */
|
|
@@ -12124,11 +12239,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12124
12239
|
exportEngine = null;
|
|
12125
12240
|
/** Periodic export-expiry sweep timer. Cleared on shutdown. */
|
|
12126
12241
|
exportSweepTimer = null;
|
|
12127
|
-
/** Timer arming the deferred full-archive index walk. Null once fired. */
|
|
12128
|
-
fullWalkTimer = null;
|
|
12129
|
-
/** Single-flight guard for the full-archive walk (onHubReachable can re-enter
|
|
12130
|
-
* on a hub reconnect while a deferred walk is still running). */
|
|
12131
|
-
fullWalkInFlight = false;
|
|
12132
12242
|
/** The `recording` cap provider — retained so the periodic retention sweep can
|
|
12133
12243
|
* call its `pruneFootage`. Null until built in `onInitialize`. */
|
|
12134
12244
|
recordingProvider = null;
|
|
@@ -12295,7 +12405,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12295
12405
|
opsLog,
|
|
12296
12406
|
alert: (input) => this.emitPlacementAlert(input),
|
|
12297
12407
|
capacityFor: (location) => this.locationCapacity(location.root),
|
|
12298
|
-
usedOnLocation: (locationId) => this.
|
|
12408
|
+
usedOnLocation: async (locationId) => (await this.segmentHours?.accountingForLocations(new Set([locationId])))?.bytes ?? 0,
|
|
12299
12409
|
dailyBytesFor: (deviceId, profile) => this.dailyBytesFor(deviceId, profile)
|
|
12300
12410
|
});
|
|
12301
12411
|
this.controller = new RecordingController({
|
|
@@ -12407,6 +12517,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12407
12517
|
locations: () => this.resolvedLocations,
|
|
12408
12518
|
refreshLocations: () => this.refreshLocations(),
|
|
12409
12519
|
capacity: (root) => this.locationCapacity(root),
|
|
12520
|
+
archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
|
|
12410
12521
|
hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
|
|
12411
12522
|
reconcileHourLedger: async (deviceId) => {
|
|
12412
12523
|
await this.segmentHours?.reconcileFromIndex(this.index, [deviceId], Date.now());
|
|
@@ -12437,7 +12548,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12437
12548
|
const recordingProvider = buildRecordingProvider(recordingDeps);
|
|
12438
12549
|
this.recordingProvider = recordingProvider;
|
|
12439
12550
|
const evictableProvider = new StorageEvictableProvider({
|
|
12440
|
-
|
|
12551
|
+
archive: () => this.segmentHours,
|
|
12552
|
+
logger: this.ctx.logger,
|
|
12441
12553
|
store: this.segmentStore,
|
|
12442
12554
|
resolveDomain: (locationId) => this.locationDomain(locationId),
|
|
12443
12555
|
isReadOnly: (locationId) => this.resolvedLocations.find((l) => l.id === locationId)?.readOnly === true,
|
|
@@ -12621,10 +12733,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12621
12733
|
}
|
|
12622
12734
|
this.retentionSweeper?.stop();
|
|
12623
12735
|
this.retentionSweeper = null;
|
|
12624
|
-
if (this.fullWalkTimer !== null) {
|
|
12625
|
-
clearTimeout(this.fullWalkTimer);
|
|
12626
|
-
this.fullWalkTimer = null;
|
|
12627
|
-
}
|
|
12628
12736
|
this.exportEngine = null;
|
|
12629
12737
|
this.recordingProvider = null;
|
|
12630
12738
|
}
|
|
@@ -12736,6 +12844,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12736
12844
|
try {
|
|
12737
12845
|
await this.segmentHours?.load();
|
|
12738
12846
|
this.segmentHours?.hydrateIndex(this.index);
|
|
12847
|
+
this.segmentHours?.repairAggregates();
|
|
12739
12848
|
} catch (err) {
|
|
12740
12849
|
this.ctx.logger.warn("recorder: segment-hour ledger load failed — hours stay unknown until a read walks them", { meta: { error: require_dist.errMsg(err) } });
|
|
12741
12850
|
}
|
|
@@ -13224,85 +13333,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
13224
13333
|
} });
|
|
13225
13334
|
}
|
|
13226
13335
|
/**
|
|
13227
|
-
* Opt-in fleet walk — NOT armed at boot (2026-08-22: starved scrub/play).
|
|
13228
|
-
* Kept so an operator path can still request a full reconcile later.
|
|
13229
|
-
*
|
|
13230
|
-
* ⚠ That operator path DOES NOT EXIST YET. This is the only caller of
|
|
13231
|
-
* `runFullArchiveWalk`, so today the whole walk is unreachable — `tsc` was
|
|
13232
|
-
* flagging it as unused and it was one of the errors keeping this package
|
|
13233
|
-
* red. Made public rather than deleted, because deleting it would have taken
|
|
13234
|
-
* the 100-line walk with it; but a comment promising a path nobody built is
|
|
13235
|
-
* exactly the leftover that reads as verification. Wire it or drop it.
|
|
13236
|
-
*/
|
|
13237
|
-
scheduleDeferredFullWalk(deviceIds) {
|
|
13238
|
-
if (this.fullWalkTimer !== null || this.fullWalkInFlight) return;
|
|
13239
|
-
this.fullWalkTimer = setTimeout(() => {
|
|
13240
|
-
this.fullWalkTimer = null;
|
|
13241
|
-
this.runFullArchiveWalk(deviceIds);
|
|
13242
|
-
}, FULL_WALK_DEFER_MS);
|
|
13243
|
-
this.fullWalkTimer.unref?.();
|
|
13244
|
-
this.ctx.logger.info("recorder: full-archive index walk deferred", { meta: {
|
|
13245
|
-
deferMs: FULL_WALK_DEFER_MS,
|
|
13246
|
-
deviceCount: deviceIds.length
|
|
13247
|
-
} });
|
|
13248
|
-
}
|
|
13249
|
-
/**
|
|
13250
|
-
* The deferred boot walk itself: hydrate every configured device from the
|
|
13251
|
-
* recordings roots, then run the two consumers that need the COMPLETE index
|
|
13252
|
-
* (the eviction-domain span log and the redundancy janitor). Bounded-parallel
|
|
13253
|
-
* per device, and each camera is marked hydrated at ITS OWN completion — not
|
|
13254
|
-
* after the fleet (D167). Never overlaps itself.
|
|
13255
|
-
*/
|
|
13256
|
-
async runFullArchiveWalk(deviceIds) {
|
|
13257
|
-
if (this.fullWalkInFlight) return;
|
|
13258
|
-
this.fullWalkInFlight = true;
|
|
13259
|
-
const hydrateStartedMs = Date.now();
|
|
13260
|
-
try {
|
|
13261
|
-
await hydrateDevicesFromStorage(this.ctx.api, this.index, deviceIds, this.resolvedLocations, this.ctx.logger);
|
|
13262
|
-
this.ctx.logger.info("recorder: hydrated index from storage", { meta: {
|
|
13263
|
-
deviceCount: deviceIds.length,
|
|
13264
|
-
locationCount: this.resolvedLocations.length,
|
|
13265
|
-
ms: Date.now() - hydrateStartedMs
|
|
13266
|
-
} });
|
|
13267
|
-
try {
|
|
13268
|
-
await this.segmentHours?.reconcileFromIndex(this.index, deviceIds, Date.now());
|
|
13269
|
-
} catch (err) {
|
|
13270
|
-
this.ctx.logger.warn("recorder: segment-hour ledger reconcile failed — walk remains the authority", { meta: { error: require_dist.errMsg(err) } });
|
|
13271
|
-
}
|
|
13272
|
-
const loggedDomains = /* @__PURE__ */ new Set();
|
|
13273
|
-
for (const loc of this.resolvedLocations) {
|
|
13274
|
-
const domain = this.locationDomain(loc.id);
|
|
13275
|
-
const domainKey = [...domain].toSorted().join("+");
|
|
13276
|
-
if (loggedDomains.has(domainKey)) continue;
|
|
13277
|
-
loggedDomains.add(domainKey);
|
|
13278
|
-
const acct = this.index.accountingForLocations(domain);
|
|
13279
|
-
this.ctx.logger.info("recorder: eviction domain footage span at hydrate", { meta: {
|
|
13280
|
-
locationIds: [...domain].toSorted(),
|
|
13281
|
-
root: loc.root,
|
|
13282
|
-
count: acct.count,
|
|
13283
|
-
bytes: acct.bytes,
|
|
13284
|
-
oldestMs: acct.oldestMs,
|
|
13285
|
-
newestMs: acct.newestMs
|
|
13286
|
-
} });
|
|
13287
|
-
}
|
|
13288
|
-
const store = this.segmentStore;
|
|
13289
|
-
if (store) try {
|
|
13290
|
-
await runRedundancyJanitor({
|
|
13291
|
-
deviceIds,
|
|
13292
|
-
segmentsFor: (deviceId, profile) => this.index.segments(deviceId, profile),
|
|
13293
|
-
evict: (location, rows) => store.evict(location, rows),
|
|
13294
|
-
logger: this.ctx.logger
|
|
13295
|
-
});
|
|
13296
|
-
} catch (err) {
|
|
13297
|
-
this.ctx.logger.warn("recorder: redundancy janitor failed", { meta: { error: require_dist.errMsg(err) } });
|
|
13298
|
-
}
|
|
13299
|
-
} catch (err) {
|
|
13300
|
-
this.ctx.logger.warn("recorder: full-archive walk failed", { meta: { error: require_dist.errMsg(err) } });
|
|
13301
|
-
} finally {
|
|
13302
|
-
this.fullWalkInFlight = false;
|
|
13303
|
-
}
|
|
13304
|
-
}
|
|
13305
|
-
/**
|
|
13306
13336
|
* Declare the three row collections that replaced the `recordingConfigs`,
|
|
13307
13337
|
* `recordingExports` and `recordingOpsLog` blob keys. Idempotent — a
|
|
13308
13338
|
* re-declaration of the same shape is a no-op in the engine.
|