@camstack/addon-pipeline 1.2.304 → 1.2.306
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/{default-detection-model-vN686W1U.js → default-detection-model-CeYzI_ws.js} +1 -1
- package/dist/{default-detection-model-BroB5Jp1.mjs → default-detection-model-CmdkU7Ik.mjs} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-CLZmZfHT.mjs → dist-B0mdAGsn.mjs} +3437 -3059
- package/dist/{dist-CyWAhtbJ.js → dist-C2GsZSso.js} +3436 -3058
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-C5LXX8Qo.mjs → node-C4FxPOmq.mjs} +1 -1
- package/dist/{node-BDrkzYiH.js → node-DA9sHeo7.js} +1 -1
- package/dist/pipeline-runner/index.js +7 -6
- package/dist/pipeline-runner/index.mjs +7 -6
- package/dist/{process-memory-DMS-0oRT.js → process-memory-CT7jffcr.js} +1 -1
- package/dist/{process-memory-CCx6i9av.mjs → process-memory-DtcP6dwa.mjs} +1 -1
- package/dist/recorder/index.js +542 -138
- package/dist/recorder/index.mjs +542 -138
- package/dist/{segment-demux-js-CwQuMR0h.js → segment-demux-js-C6Z9t7EK.js} +1 -1
- package/dist/{segment-demux-js-BUxZFqkb.mjs → segment-demux-js-CDDu7U5-.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CF7C68rV.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BRWrphBU.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DKNdMa3E.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-CdKbuxqG.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-dac4-ofq.mjs → hostInit-CdU3jJMF.mjs} +2 -2
- 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/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-vnc0r4NV.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-CUFWg1jj.mjs +0 -26
package/dist/recorder/index.js
CHANGED
|
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
6
|
-
const require_dist = require("../dist-
|
|
7
|
-
const require_node = require("../node-
|
|
6
|
+
const require_dist = require("../dist-C2GsZSso.js");
|
|
7
|
+
const require_node = require("../node-DA9sHeo7.js");
|
|
8
8
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
9
9
|
const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
|
|
10
10
|
const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
|
|
@@ -840,15 +840,15 @@ function parseSegmentPath(relPath) {
|
|
|
840
840
|
//#region src/recorder/recording-index.ts
|
|
841
841
|
/** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
|
|
842
842
|
* already gives us — so "have I walked this?" costs one Set lookup. */
|
|
843
|
-
var HOUR_MS$
|
|
843
|
+
var HOUR_MS$11 = 36e5;
|
|
844
844
|
/** The hour buckets a half-open span touches. */
|
|
845
845
|
function* hourBuckets(fromMs, toMs) {
|
|
846
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
847
|
-
for (let h = first; h < toMs; h += HOUR_MS$
|
|
846
|
+
const first = Math.floor(fromMs / HOUR_MS$11) * HOUR_MS$11;
|
|
847
|
+
for (let h = first; h < toMs; h += HOUR_MS$11) yield h;
|
|
848
848
|
}
|
|
849
849
|
/** The hour bucket a segment belongs to — the same one its path encodes. */
|
|
850
850
|
function hourOf$1(startMs) {
|
|
851
|
-
return Math.floor(startMs / HOUR_MS$
|
|
851
|
+
return Math.floor(startMs / HOUR_MS$11) * HOUR_MS$11;
|
|
852
852
|
}
|
|
853
853
|
/** The production bound. See {@link RecordingIndexBudget} for the measurement. */
|
|
854
854
|
var DEFAULT_RECORDING_INDEX_BUDGET = {
|
|
@@ -1226,8 +1226,8 @@ var RecordingIndex = class {
|
|
|
1226
1226
|
const w = this.walked.get(deviceId);
|
|
1227
1227
|
if (w === void 0 || w.size === 0) return;
|
|
1228
1228
|
const tick = ++this.useTick;
|
|
1229
|
-
if (Math.ceil(Math.max(0, toMs - fromMs) / HOUR_MS$
|
|
1230
|
-
for (const h of w.keys()) if (h < toMs && h + HOUR_MS$
|
|
1229
|
+
if (Math.ceil(Math.max(0, toMs - fromMs) / HOUR_MS$11) > w.size) {
|
|
1230
|
+
for (const h of w.keys()) if (h < toMs && h + HOUR_MS$11 > fromMs) w.set(h, tick);
|
|
1231
1231
|
return;
|
|
1232
1232
|
}
|
|
1233
1233
|
for (const h of hourBuckets(fromMs, toMs)) if (w.has(h)) w.set(h, tick);
|
|
@@ -1264,7 +1264,7 @@ var RecordingIndex = class {
|
|
|
1264
1264
|
if (rows <= budget.residentRowBudget && hours <= budget.residentHourBudget) return;
|
|
1265
1265
|
const rowTarget = Math.floor(budget.residentRowBudget * budget.evictToFraction);
|
|
1266
1266
|
const hourTarget = Math.floor(budget.residentHourBudget * budget.evictToFraction);
|
|
1267
|
-
const pinnedFrom = hourOf$1(deps.now()) - Math.max(0, budget.pinnedTailHours - 1) * HOUR_MS$
|
|
1267
|
+
const pinnedFrom = hourOf$1(deps.now()) - Math.max(0, budget.pinnedTailHours - 1) * HOUR_MS$11;
|
|
1268
1268
|
const counts = this.hourHistogram();
|
|
1269
1269
|
const candidates = [];
|
|
1270
1270
|
for (const [deviceId, byHour] of counts) {
|
|
@@ -1386,7 +1386,7 @@ var RecordingIndex = class {
|
|
|
1386
1386
|
changed = true;
|
|
1387
1387
|
}
|
|
1388
1388
|
if (changed) this.invalidateSorted(deviceId);
|
|
1389
|
-
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$
|
|
1389
|
+
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$11);
|
|
1390
1390
|
this.enforceResidency();
|
|
1391
1391
|
}
|
|
1392
1392
|
/**
|
|
@@ -1411,7 +1411,7 @@ var RecordingIndex = class {
|
|
|
1411
1411
|
const dropped = [];
|
|
1412
1412
|
for (const [path, row] of m) {
|
|
1413
1413
|
if (row.locationId !== locationId) continue;
|
|
1414
|
-
if (Math.floor(row.startMs / HOUR_MS$
|
|
1414
|
+
if (Math.floor(row.startMs / HOUR_MS$11) * HOUR_MS$11 !== hourStartMs) continue;
|
|
1415
1415
|
if (keep.has(path)) continue;
|
|
1416
1416
|
dropped.push(path);
|
|
1417
1417
|
}
|
|
@@ -2074,11 +2074,11 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
|
2074
2074
|
const p2 = (n) => String(n).padStart(2, "0");
|
|
2075
2075
|
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
2076
2076
|
}
|
|
2077
|
-
var HOUR_MS$
|
|
2077
|
+
var HOUR_MS$10 = 36e5;
|
|
2078
2078
|
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
|
|
2079
2079
|
const verify = options?.verifyClosedHours === true;
|
|
2080
2080
|
if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
2081
|
-
const liveHour = Math.floor(Date.now() / HOUR_MS$
|
|
2081
|
+
const liveHour = Math.floor(Date.now() / HOUR_MS$10) * HOUR_MS$10;
|
|
2082
2082
|
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
2083
2083
|
const deviceDir = node_path.default.join(root, String(deviceId));
|
|
2084
2084
|
let profiles;
|
|
@@ -2094,15 +2094,15 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
2094
2094
|
});
|
|
2095
2095
|
continue;
|
|
2096
2096
|
}
|
|
2097
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
2097
|
+
const first = Math.floor(fromMs / HOUR_MS$10) * HOUR_MS$10;
|
|
2098
2098
|
const reuseBefore = index.hydrateReuseStats();
|
|
2099
2099
|
let hoursRead = 0;
|
|
2100
2100
|
let repaired = 0;
|
|
2101
2101
|
let forgottenSegments = 0;
|
|
2102
2102
|
let forgottenHours = 0;
|
|
2103
2103
|
let sealed = 0;
|
|
2104
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
2105
|
-
if (!verify && hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$
|
|
2104
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$10) {
|
|
2105
|
+
if (!verify && hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$10) === "hydrated") continue;
|
|
2106
2106
|
for (const profile of profiles) {
|
|
2107
2107
|
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
2108
2108
|
let files;
|
|
@@ -2115,7 +2115,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
2115
2115
|
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
2116
2116
|
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
2117
2117
|
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
2118
|
-
if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$
|
|
2118
|
+
if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$10 > Date.now()) continue;
|
|
2119
2119
|
const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
|
|
2120
2120
|
if (outcome === null) continue;
|
|
2121
2121
|
if (outcome.created) repaired += 1;
|
|
@@ -2197,7 +2197,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
2197
2197
|
*/
|
|
2198
2198
|
var DEFAULT_THROTTLE_MBPS = 40;
|
|
2199
2199
|
/** The durable index's bucket, and therefore this mover's batch. */
|
|
2200
|
-
var HOUR_MS$
|
|
2200
|
+
var HOUR_MS$9 = 36e5;
|
|
2201
2201
|
/** Progress callback cadence (files) — cheap enough to fire often. */
|
|
2202
2202
|
var PROGRESS_EVERY_FILES = 25;
|
|
2203
2203
|
/** Free space a destination must keep when it declares no `config.minFreePercent`
|
|
@@ -2886,7 +2886,7 @@ function profilesOfClass(footageClass) {
|
|
|
2886
2886
|
function groupByHour(rows) {
|
|
2887
2887
|
const out = /* @__PURE__ */ new Map();
|
|
2888
2888
|
for (const row of rows) {
|
|
2889
|
-
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$
|
|
2889
|
+
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$9)}`;
|
|
2890
2890
|
const found = out.get(key);
|
|
2891
2891
|
if (found) found.push(row);
|
|
2892
2892
|
else out.set(key, [row]);
|
|
@@ -4110,6 +4110,231 @@ async function profileHasFootage(root, deviceId, profile) {
|
|
|
4110
4110
|
return (await listDir(node_path.default.join(root, String(deviceId), profile))).length > 0;
|
|
4111
4111
|
}
|
|
4112
4112
|
//#endregion
|
|
4113
|
+
//#region src/recorder/addon/calendar-hour-cache.ts
|
|
4114
|
+
var HOUR_MS$8 = 36e5;
|
|
4115
|
+
var HOURS_PER_DAY = 24;
|
|
4116
|
+
/**
|
|
4117
|
+
* The production bound: the RAM index's own (`DEFAULT_RECORDING_INDEX_BUDGET`,
|
|
4118
|
+
* 250 000 rows / 20 000 walked hours, measured at 698 B/row). The calendar
|
|
4119
|
+
* holds a 3-scalar `HourSegment` per segment where the index holds a full row,
|
|
4120
|
+
* so the same counts are a SMALLER heap here — see
|
|
4121
|
+
* {@link CALENDAR_SEGMENT_APPROX_BYTES}.
|
|
4122
|
+
*/
|
|
4123
|
+
var DEFAULT_CALENDAR_HOUR_BUDGET = {
|
|
4124
|
+
hourBudget: DEFAULT_RECORDING_INDEX_BUDGET.residentHourBudget,
|
|
4125
|
+
segmentBudget: DEFAULT_RECORDING_INDEX_BUDGET.residentRowBudget,
|
|
4126
|
+
evictToFraction: DEFAULT_RECORDING_INDEX_BUDGET.evictToFraction
|
|
4127
|
+
};
|
|
4128
|
+
/** Every empty hour shares this one array: a gap costs a slot, not an object. */
|
|
4129
|
+
var EMPTY_HOUR = Object.freeze([]);
|
|
4130
|
+
/** Hours since the epoch — a Smi, where `hourMs` would be a HeapNumber. */
|
|
4131
|
+
function hourIndexOf(epochMs) {
|
|
4132
|
+
return Math.floor(epochMs / HOUR_MS$8);
|
|
4133
|
+
}
|
|
4134
|
+
var CalendarHourCache = class {
|
|
4135
|
+
budget;
|
|
4136
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
4137
|
+
/** Least recently used first — `Set` iteration order is insertion order. */
|
|
4138
|
+
lru = /* @__PURE__ */ new Set();
|
|
4139
|
+
segmentCount = 0;
|
|
4140
|
+
emptyCount = 0;
|
|
4141
|
+
stampedCount = 0;
|
|
4142
|
+
evictedCount = 0;
|
|
4143
|
+
constructor(budget = DEFAULT_CALENDAR_HOUR_BUDGET) {
|
|
4144
|
+
this.budget = budget;
|
|
4145
|
+
}
|
|
4146
|
+
/** The cached segments of one hour, or `undefined` when not cached. */
|
|
4147
|
+
get(deviceId, profile, hourMs) {
|
|
4148
|
+
const entry = this.entryOf(deviceId, profile, hourMs);
|
|
4149
|
+
if (entry === void 0) return void 0;
|
|
4150
|
+
this.lru.delete(entry);
|
|
4151
|
+
this.lru.add(entry);
|
|
4152
|
+
return entry.segments;
|
|
4153
|
+
}
|
|
4154
|
+
/** When this hour was read AS THE CURRENT HOUR, or `undefined`. */
|
|
4155
|
+
readAtOf(deviceId, profile, hourMs) {
|
|
4156
|
+
return this.entryOf(deviceId, profile, hourMs)?.readAtMs ?? void 0;
|
|
4157
|
+
}
|
|
4158
|
+
/**
|
|
4159
|
+
* Store one COMPLETE scan. `currentReadAtMs` is set only when the hour was
|
|
4160
|
+
* the current one at store time; a later store of the closed hour passes
|
|
4161
|
+
* `null` and so drops the stamp with the entry it replaces.
|
|
4162
|
+
*/
|
|
4163
|
+
set(deviceId, profile, hourMs, segments, currentReadAtMs) {
|
|
4164
|
+
const hourIndex = hourIndexOf(hourMs);
|
|
4165
|
+
this.remove(deviceId, profile, hourIndex);
|
|
4166
|
+
const entry = {
|
|
4167
|
+
deviceId,
|
|
4168
|
+
profile,
|
|
4169
|
+
hourIndex,
|
|
4170
|
+
segments: segments.length === 0 ? EMPTY_HOUR : segments,
|
|
4171
|
+
readAtMs: currentReadAtMs
|
|
4172
|
+
};
|
|
4173
|
+
this.profileMap(deviceId, profile).set(hourIndex, entry);
|
|
4174
|
+
this.lru.add(entry);
|
|
4175
|
+
if (entry.segments.length === 0) this.emptyCount += 1;
|
|
4176
|
+
else this.segmentCount += entry.segments.length;
|
|
4177
|
+
if (entry.readAtMs !== null) this.stampedCount += 1;
|
|
4178
|
+
this.enforceBudget();
|
|
4179
|
+
}
|
|
4180
|
+
/**
|
|
4181
|
+
* Drop one camera's UTC day, every profile — what eviction changed. The read
|
|
4182
|
+
* stamps go with their hours; there is no second map to sweep.
|
|
4183
|
+
*/
|
|
4184
|
+
invalidateDay(deviceId, utcDayMs) {
|
|
4185
|
+
const firstHour = hourIndexOf(utcDayMs);
|
|
4186
|
+
const profiles = this.byDevice.get(deviceId);
|
|
4187
|
+
if (profiles === void 0) return;
|
|
4188
|
+
for (const profile of [...profiles.keys()]) for (let h = 0; h < HOURS_PER_DAY; h++) this.remove(deviceId, profile, firstHour + h);
|
|
4189
|
+
}
|
|
4190
|
+
/** Full reset — a relocation re-pointed rows across locations. */
|
|
4191
|
+
clear() {
|
|
4192
|
+
this.byDevice.clear();
|
|
4193
|
+
this.lru.clear();
|
|
4194
|
+
this.segmentCount = 0;
|
|
4195
|
+
this.emptyCount = 0;
|
|
4196
|
+
this.stampedCount = 0;
|
|
4197
|
+
}
|
|
4198
|
+
census() {
|
|
4199
|
+
const hours = this.lru.size;
|
|
4200
|
+
return {
|
|
4201
|
+
hourKeys: hours - this.emptyCount,
|
|
4202
|
+
emptyHours: this.emptyCount,
|
|
4203
|
+
hourSegments: this.segmentCount,
|
|
4204
|
+
segsReadAtKeys: this.stampedCount,
|
|
4205
|
+
evictedHours: this.evictedCount,
|
|
4206
|
+
approxBytes: hours * 128 + this.segmentCount * 72 + this.stampedCount * 16
|
|
4207
|
+
};
|
|
4208
|
+
}
|
|
4209
|
+
entryOf(deviceId, profile, hourMs) {
|
|
4210
|
+
return this.byDevice.get(deviceId)?.get(profile)?.get(hourIndexOf(hourMs));
|
|
4211
|
+
}
|
|
4212
|
+
profileMap(deviceId, profile) {
|
|
4213
|
+
let profiles = this.byDevice.get(deviceId);
|
|
4214
|
+
if (profiles === void 0) {
|
|
4215
|
+
profiles = /* @__PURE__ */ new Map();
|
|
4216
|
+
this.byDevice.set(deviceId, profiles);
|
|
4217
|
+
}
|
|
4218
|
+
let hours = profiles.get(profile);
|
|
4219
|
+
if (hours === void 0) {
|
|
4220
|
+
hours = /* @__PURE__ */ new Map();
|
|
4221
|
+
profiles.set(profile, hours);
|
|
4222
|
+
}
|
|
4223
|
+
return hours;
|
|
4224
|
+
}
|
|
4225
|
+
/** Remove one hour and its stamp; prune empty parents. */
|
|
4226
|
+
remove(deviceId, profile, hourIndex) {
|
|
4227
|
+
const profiles = this.byDevice.get(deviceId);
|
|
4228
|
+
const hours = profiles?.get(profile);
|
|
4229
|
+
const entry = hours?.get(hourIndex);
|
|
4230
|
+
if (profiles === void 0 || hours === void 0 || entry === void 0) return;
|
|
4231
|
+
hours.delete(hourIndex);
|
|
4232
|
+
if (hours.size === 0) profiles.delete(profile);
|
|
4233
|
+
if (profiles.size === 0) this.byDevice.delete(deviceId);
|
|
4234
|
+
this.lru.delete(entry);
|
|
4235
|
+
if (entry.segments.length === 0) this.emptyCount -= 1;
|
|
4236
|
+
else this.segmentCount -= entry.segments.length;
|
|
4237
|
+
if (entry.readAtMs !== null) this.stampedCount -= 1;
|
|
4238
|
+
}
|
|
4239
|
+
enforceBudget() {
|
|
4240
|
+
const { hourBudget, segmentBudget, evictToFraction } = this.budget;
|
|
4241
|
+
if (this.lru.size <= hourBudget && this.segmentCount <= segmentBudget) return;
|
|
4242
|
+
const hourTarget = Math.floor(hourBudget * evictToFraction);
|
|
4243
|
+
const segmentTarget = Math.floor(segmentBudget * evictToFraction);
|
|
4244
|
+
for (const entry of this.lru) {
|
|
4245
|
+
if (this.lru.size <= hourTarget && this.segmentCount <= segmentTarget) return;
|
|
4246
|
+
this.remove(entry.deviceId, entry.profile, entry.hourIndex);
|
|
4247
|
+
this.evictedCount += 1;
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
};
|
|
4251
|
+
//#endregion
|
|
4252
|
+
//#region src/recorder/addon/calendar-window.ts
|
|
4253
|
+
var HOUR_MS$7 = 36e5;
|
|
4254
|
+
/**
|
|
4255
|
+
* The widest walk one call may make AFTER the clamp: a leap year of hours.
|
|
4256
|
+
*
|
|
4257
|
+
* Evidence, not taste. The widest in-repo caller is the viewer's month
|
|
4258
|
+
* calendar (~33 days); the rest ask a day or a clip window (liveness 24 h,
|
|
4259
|
+
* videoclips' since/until, a timelapse window, a timeline day). The one that
|
|
4260
|
+
* asked for more wanted a single number the ledger already holds (D674). The
|
|
4261
|
+
* archives it would still admit are past what this system keeps: the 28 TB
|
|
4262
|
+
* projection is ~47 k hour rows, ~2,000 hours (~82 days) per camera/profile
|
|
4263
|
+
* pair at ~24 pairs, and the live hub held 3.3 days. (The ledger's load CAP,
|
|
4264
|
+
* `SEGMENT_HOUR_SUMMARY_LIMIT` = 250,000 rows, is ~10 k hours per pair — a
|
|
4265
|
+
* bound on memory, not a claim about retention; the clamp only ever narrows a
|
|
4266
|
+
* walk further.) A walk this wide costs ~130 B of garbage per hour warm
|
|
4267
|
+
* (measured: ~65 MB for 497,408 hours), so ~1.1 MB at the cap.
|
|
4268
|
+
*/
|
|
4269
|
+
var CALENDAR_MAX_WALK_HOURS = 366 * 24;
|
|
4270
|
+
var UNKNOWN_ARCHIVE_FLOOR = { known: false };
|
|
4271
|
+
/** A walk the calendar will not make. Surfaces as `read: 'unreadable'`. */
|
|
4272
|
+
var CalendarWindowRefusedError = class extends Error {
|
|
4273
|
+
deviceId;
|
|
4274
|
+
hours;
|
|
4275
|
+
maxHours;
|
|
4276
|
+
floorKnown;
|
|
4277
|
+
name = "CalendarWindowRefusedError";
|
|
4278
|
+
constructor(deviceId, hours, maxHours, floorKnown) {
|
|
4279
|
+
super(`calendar: refused a ${String(hours)}-hour walk for device ${String(deviceId)} (cap ${String(maxHours)} h, archive floor ${floorKnown ? "known" : "UNKNOWN"}) — ask the archive (recordingArchive.getStorageUsage) for whole-archive facts`);
|
|
4280
|
+
this.deviceId = deviceId;
|
|
4281
|
+
this.hours = hours;
|
|
4282
|
+
this.maxHours = maxHours;
|
|
4283
|
+
this.floorKnown = floorKnown;
|
|
4284
|
+
}
|
|
4285
|
+
};
|
|
4286
|
+
/**
|
|
4287
|
+
* Clamp `[fromMs, toMs)` to the hours that can hold footage and refuse it when
|
|
4288
|
+
* it is still wider than `maxHours`. `null` = nothing left to walk.
|
|
4289
|
+
*
|
|
4290
|
+
* The writer's current hour and the one it just left are never clamped away:
|
|
4291
|
+
* the ledger is write-behind, so a camera's first segments may be on disk (and
|
|
4292
|
+
* in the RAM index) a moment before the ledger holds their hour.
|
|
4293
|
+
*/
|
|
4294
|
+
function calendarWalk(deviceId, fromMs, toMs, nowMs, floor, maxHours = CALENDAR_MAX_WALK_HOURS) {
|
|
4295
|
+
const currentHour = Math.floor(nowMs / HOUR_MS$7) * HOUR_MS$7;
|
|
4296
|
+
const liveFloor = currentHour - HOUR_MS$7;
|
|
4297
|
+
const floorHour = floor.known ? Math.min(floor.oldestHourMs, liveFloor) : Number.NEGATIVE_INFINITY;
|
|
4298
|
+
const firstHourMs = Math.max(Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7, floorHour);
|
|
4299
|
+
const endMs = Math.min(toMs, currentHour + HOUR_MS$7);
|
|
4300
|
+
if (endMs <= firstHourMs || toMs <= fromMs) return null;
|
|
4301
|
+
const hours = Math.ceil((endMs - firstHourMs) / HOUR_MS$7);
|
|
4302
|
+
if (hours > maxHours) throw new CalendarWindowRefusedError(deviceId, hours, maxHours, floor.known);
|
|
4303
|
+
return {
|
|
4304
|
+
firstHourMs,
|
|
4305
|
+
endMs,
|
|
4306
|
+
hours
|
|
4307
|
+
};
|
|
4308
|
+
}
|
|
4309
|
+
/** Per-camera archive floors, one ledger fold per {@link ARCHIVE_FLOOR_TTL_MS}. */
|
|
4310
|
+
var ArchiveFloorReader = class {
|
|
4311
|
+
source;
|
|
4312
|
+
now;
|
|
4313
|
+
folded = null;
|
|
4314
|
+
constructor(source, now = () => Date.now()) {
|
|
4315
|
+
this.source = source;
|
|
4316
|
+
this.now = now;
|
|
4317
|
+
}
|
|
4318
|
+
floorOf(deviceId) {
|
|
4319
|
+
const ledger = this.source();
|
|
4320
|
+
if (ledger === null) return UNKNOWN_ARCHIVE_FLOOR;
|
|
4321
|
+
if (!ledger.isComplete()) {
|
|
4322
|
+
ledger.retryLoadIfUnanswered();
|
|
4323
|
+
return UNKNOWN_ARCHIVE_FLOOR;
|
|
4324
|
+
}
|
|
4325
|
+
const nowMs = this.now();
|
|
4326
|
+
if (this.folded === null || nowMs - this.folded.atMs >= 6e4) this.folded = {
|
|
4327
|
+
atMs: nowMs,
|
|
4328
|
+
floors: ledger.oldestHourByDevice()
|
|
4329
|
+
};
|
|
4330
|
+
const oldestHourMs = this.folded.floors.get(deviceId);
|
|
4331
|
+
return oldestHourMs === void 0 ? UNKNOWN_ARCHIVE_FLOOR : {
|
|
4332
|
+
known: true,
|
|
4333
|
+
oldestHourMs
|
|
4334
|
+
};
|
|
4335
|
+
}
|
|
4336
|
+
};
|
|
4337
|
+
//#endregion
|
|
4113
4338
|
//#region src/recorder/addon/calendar-index.ts
|
|
4114
4339
|
var DAY_MS$3 = 864e5;
|
|
4115
4340
|
var HOUR_MS$6 = 36e5;
|
|
@@ -4172,7 +4397,9 @@ function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
|
|
|
4172
4397
|
*
|
|
4173
4398
|
* Segments land every 8–12 s, so three seconds of staleness at the live edge
|
|
4174
4399
|
* cannot hide a whole one — and the live view owns "now" regardless. Past hours
|
|
4175
|
-
* stay cached
|
|
4400
|
+
* stay cached until eviction or the rung's bound (D674) drops them; only this
|
|
4401
|
+
* one expires — and it is read once more after it closes, so its last minutes
|
|
4402
|
+
* are never served from a stamp taken while it was still being written.
|
|
4176
4403
|
*/
|
|
4177
4404
|
var CURRENT_HOUR_TTL_MS = 3e3;
|
|
4178
4405
|
/**
|
|
@@ -4214,17 +4441,20 @@ async function mapBounded(items, limit, fn) {
|
|
|
4214
4441
|
await Promise.all(workers);
|
|
4215
4442
|
return out;
|
|
4216
4443
|
}
|
|
4444
|
+
/** Estimated bytes per cached UTC day on the day rung (map slot + a short
|
|
4445
|
+
* array of hour numbers). */
|
|
4446
|
+
var DAY_RUNG_APPROX_BYTES = 96;
|
|
4217
4447
|
var CalendarIndex = class {
|
|
4218
4448
|
deps;
|
|
4219
4449
|
/** `deviceId` → (UTC day ms → hours). Absent = never scanned. */
|
|
4220
4450
|
byDevice = /* @__PURE__ */ new Map();
|
|
4221
4451
|
/** Scans in flight, so a burst of queries costs one walk. */
|
|
4222
4452
|
inFlight = /* @__PURE__ */ new Map();
|
|
4223
|
-
/**
|
|
4224
|
-
|
|
4453
|
+
/** The exact rung: device → profile → hour → segments, bounded (D674). */
|
|
4454
|
+
hours;
|
|
4455
|
+
/** Hour scans in flight, keyed like the rung — so a burst costs one read. */
|
|
4225
4456
|
segsInFlight = /* @__PURE__ */ new Map();
|
|
4226
|
-
|
|
4227
|
-
segsReadAt = /* @__PURE__ */ new Map();
|
|
4457
|
+
segsInFlightCount = 0;
|
|
4228
4458
|
/** The profile availability reads from, resolved once per device. */
|
|
4229
4459
|
profileFor = /* @__PURE__ */ new Map();
|
|
4230
4460
|
profilesFor = /* @__PURE__ */ new Map();
|
|
@@ -4239,25 +4469,36 @@ var CalendarIndex = class {
|
|
|
4239
4469
|
profileInFlight = /* @__PURE__ */ new Map();
|
|
4240
4470
|
constructor(deps) {
|
|
4241
4471
|
this.deps = deps;
|
|
4472
|
+
this.hours = new CalendarHourCache(deps.hourBudget ?? DEFAULT_CALENDAR_HOUR_BUDGET);
|
|
4242
4473
|
}
|
|
4243
4474
|
/**
|
|
4244
|
-
* Entry counts for the recorder census.
|
|
4245
|
-
*
|
|
4246
|
-
* is the number that says whether this cache is where the heap went.
|
|
4475
|
+
* Entry counts and an estimated byte total for the recorder census. The
|
|
4476
|
+
* exact rung is bounded (D674); `evictedHours` says whether the bound bites.
|
|
4247
4477
|
*/
|
|
4248
4478
|
memoryCensus() {
|
|
4249
4479
|
let days = 0;
|
|
4250
4480
|
for (const perDay of this.byDevice.values()) days += perDay.size;
|
|
4251
|
-
|
|
4252
|
-
for (const segments of this.hourSegs.values()) hourSegments += segments.length;
|
|
4481
|
+
const hours = this.hours.census();
|
|
4253
4482
|
return {
|
|
4254
4483
|
devices: this.byDevice.size,
|
|
4255
4484
|
days,
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
inFlight: this.inFlight.size + this.
|
|
4485
|
+
...hours,
|
|
4486
|
+
approxBytes: hours.approxBytes + days * DAY_RUNG_APPROX_BYTES,
|
|
4487
|
+
inFlight: this.inFlight.size + this.segsInFlightCount
|
|
4259
4488
|
};
|
|
4260
4489
|
}
|
|
4490
|
+
nowMs() {
|
|
4491
|
+
return this.deps.now?.() ?? Date.now();
|
|
4492
|
+
}
|
|
4493
|
+
/**
|
|
4494
|
+
* The hours a walk over `[fromMs, toMs)` may visit — clamped to the archive
|
|
4495
|
+
* floor and the live edge, REFUSED above the cap (see `calendar-window.ts`).
|
|
4496
|
+
* Throws {@link CalendarWindowRefusedError}; the provider turns that into
|
|
4497
|
+
* `read: 'unreadable'` and logs it.
|
|
4498
|
+
*/
|
|
4499
|
+
walkOf(deviceId, fromMs, toMs) {
|
|
4500
|
+
return calendarWalk(deviceId, fromMs, toMs, this.nowMs(), this.deps.archiveFloor?.(deviceId) ?? UNKNOWN_ARCHIVE_FLOOR, this.deps.maxWalkHours ?? 8784);
|
|
4501
|
+
}
|
|
4261
4502
|
mapFor(deviceId) {
|
|
4262
4503
|
let m = this.byDevice.get(deviceId);
|
|
4263
4504
|
if (!m) {
|
|
@@ -4318,8 +4559,10 @@ var CalendarIndex = class {
|
|
|
4318
4559
|
* UTC day outside the range (23:30Z on the 28th is the 29th in CEST).
|
|
4319
4560
|
*/
|
|
4320
4561
|
async localDays(deviceId, fromMs, toMs, tzOffsetMinutes) {
|
|
4321
|
-
const
|
|
4322
|
-
|
|
4562
|
+
const walk = this.walkOf(deviceId, utcDayOf(fromMs) - DAY_MS$3, utcDayOf(toMs) + 2 * DAY_MS$3);
|
|
4563
|
+
if (walk === null) return [];
|
|
4564
|
+
const first = utcDayOf(walk.firstHourMs);
|
|
4565
|
+
const last = utcDayOf(walk.endMs - 1);
|
|
4323
4566
|
const wanted = [];
|
|
4324
4567
|
for (let day = first; day <= last; day += DAY_MS$3) wanted.push(day);
|
|
4325
4568
|
const perDay = await mapBounded(wanted, READ_CONCURRENCY, async (day) => ({
|
|
@@ -4344,16 +4587,12 @@ var CalendarIndex = class {
|
|
|
4344
4587
|
* rebuild lazily on the next question. */
|
|
4345
4588
|
invalidateAll() {
|
|
4346
4589
|
this.byDevice.clear();
|
|
4347
|
-
this.
|
|
4590
|
+
this.hours.clear();
|
|
4348
4591
|
}
|
|
4349
4592
|
invalidate(deviceId, atMs) {
|
|
4350
4593
|
const day = utcDayOf(atMs);
|
|
4351
4594
|
this.byDevice.get(deviceId)?.delete(day);
|
|
4352
|
-
|
|
4353
|
-
for (const key of this.hourSegs.keys()) {
|
|
4354
|
-
if (!key.startsWith(prefix)) continue;
|
|
4355
|
-
if (utcDayOf(Number(key.slice(key.lastIndexOf(":") + 1))) === day) this.hourSegs.delete(key);
|
|
4356
|
-
}
|
|
4595
|
+
this.hours.invalidateDay(deviceId, day);
|
|
4357
4596
|
}
|
|
4358
4597
|
/**
|
|
4359
4598
|
* Scan a span in the background so the first screen is already answered.
|
|
@@ -4411,28 +4650,28 @@ var CalendarIndex = class {
|
|
|
4411
4650
|
* retries instead of caching a gap.
|
|
4412
4651
|
*/
|
|
4413
4652
|
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
4414
|
-
if (!background) this.lastForegroundAt = this.
|
|
4653
|
+
if (!background) this.lastForegroundAt = this.nowMs();
|
|
4415
4654
|
const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
|
|
4416
4655
|
const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
|
|
4417
4656
|
if (fromIndex !== null && fromIndex !== void 0) return {
|
|
4418
4657
|
segments: fromIndex,
|
|
4419
4658
|
complete: true
|
|
4420
4659
|
};
|
|
4421
|
-
const
|
|
4422
|
-
const now = this.deps.now?.() ?? Date.now();
|
|
4660
|
+
const now = this.nowMs();
|
|
4423
4661
|
const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
|
|
4424
|
-
const cached = this.
|
|
4662
|
+
const cached = this.hours.get(deviceId, profile, hour);
|
|
4425
4663
|
if (cached !== void 0) {
|
|
4426
|
-
|
|
4664
|
+
const readAt = this.hours.readAtOf(deviceId, profile, hour);
|
|
4665
|
+
if (!isCurrentHour && readAt === void 0) return {
|
|
4427
4666
|
segments: cached,
|
|
4428
4667
|
complete: true
|
|
4429
4668
|
};
|
|
4430
|
-
if (now - (
|
|
4669
|
+
if (isCurrentHour && now - (readAt ?? 0) < CURRENT_HOUR_TTL_MS) return {
|
|
4431
4670
|
segments: cached,
|
|
4432
4671
|
complete: true
|
|
4433
4672
|
};
|
|
4434
4673
|
}
|
|
4435
|
-
const running = this.
|
|
4674
|
+
const running = this.inFlightScan(deviceId, profile, hour);
|
|
4436
4675
|
if (running) return running;
|
|
4437
4676
|
const scan = (async () => {
|
|
4438
4677
|
const read = this.deps.scanSegments ?? segmentsOfHour;
|
|
@@ -4456,21 +4695,46 @@ var CalendarIndex = class {
|
|
|
4456
4695
|
complete
|
|
4457
4696
|
};
|
|
4458
4697
|
})();
|
|
4459
|
-
this.
|
|
4698
|
+
this.trackScan(deviceId, profile, hour, scan);
|
|
4460
4699
|
try {
|
|
4461
4700
|
const { segments, complete } = await scan;
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
}
|
|
4701
|
+
const storedAt = this.nowMs();
|
|
4702
|
+
const liveHour = Math.floor(storedAt / HOUR_MS$6) * HOUR_MS$6;
|
|
4703
|
+
if (complete && hour <= liveHour) this.hours.set(deviceId, profile, hour, segments, hour === liveHour ? storedAt : null);
|
|
4466
4704
|
return {
|
|
4467
4705
|
segments,
|
|
4468
4706
|
complete
|
|
4469
4707
|
};
|
|
4470
4708
|
} finally {
|
|
4471
|
-
|
|
4709
|
+
this.untrackScan(deviceId, profile, hour, scan);
|
|
4472
4710
|
}
|
|
4473
4711
|
}
|
|
4712
|
+
inFlightScan(deviceId, profile, hour) {
|
|
4713
|
+
return this.segsInFlight.get(deviceId)?.get(profile)?.get(hour) ?? null;
|
|
4714
|
+
}
|
|
4715
|
+
trackScan(deviceId, profile, hour, scan) {
|
|
4716
|
+
let profiles = this.segsInFlight.get(deviceId);
|
|
4717
|
+
if (profiles === void 0) {
|
|
4718
|
+
profiles = /* @__PURE__ */ new Map();
|
|
4719
|
+
this.segsInFlight.set(deviceId, profiles);
|
|
4720
|
+
}
|
|
4721
|
+
let hours = profiles.get(profile);
|
|
4722
|
+
if (hours === void 0) {
|
|
4723
|
+
hours = /* @__PURE__ */ new Map();
|
|
4724
|
+
profiles.set(profile, hours);
|
|
4725
|
+
}
|
|
4726
|
+
hours.set(hour, scan);
|
|
4727
|
+
this.segsInFlightCount += 1;
|
|
4728
|
+
}
|
|
4729
|
+
untrackScan(deviceId, profile, hour, scan) {
|
|
4730
|
+
const profiles = this.segsInFlight.get(deviceId);
|
|
4731
|
+
const hours = profiles?.get(profile);
|
|
4732
|
+
if (profiles === void 0 || hours === void 0 || hours.get(hour) !== scan) return;
|
|
4733
|
+
hours.delete(hour);
|
|
4734
|
+
this.segsInFlightCount -= 1;
|
|
4735
|
+
if (hours.size === 0) profiles.delete(profile);
|
|
4736
|
+
if (profiles.size === 0) this.segsInFlight.delete(deviceId);
|
|
4737
|
+
}
|
|
4474
4738
|
/**
|
|
4475
4739
|
* Exact availability ranges for a UTC day — what the progressive timeline
|
|
4476
4740
|
* draws.
|
|
@@ -4498,9 +4762,10 @@ var CalendarIndex = class {
|
|
|
4498
4762
|
*/
|
|
4499
4763
|
async rangesIn(deviceId, profile, fromMs, toMs) {
|
|
4500
4764
|
if (toMs <= fromMs) return [];
|
|
4501
|
-
const
|
|
4765
|
+
const walk = this.walkOf(deviceId, fromMs, toMs);
|
|
4766
|
+
if (walk === null) return [];
|
|
4502
4767
|
const hours = [];
|
|
4503
|
-
for (let hour =
|
|
4768
|
+
for (let hour = walk.firstHourMs; hour < walk.endMs; hour += HOUR_MS$6) hours.push(hour);
|
|
4504
4769
|
const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
|
|
4505
4770
|
const ranges = [];
|
|
4506
4771
|
for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
|
|
@@ -4606,6 +4871,115 @@ var CalendarIndex = class {
|
|
|
4606
4871
|
}
|
|
4607
4872
|
};
|
|
4608
4873
|
//#endregion
|
|
4874
|
+
//#region src/recorder/addon/device-storage-usage.ts
|
|
4875
|
+
/**
|
|
4876
|
+
* Gap between the per-camera sum and the per-volume total that is worth
|
|
4877
|
+
* saying out loud, as a share of the volume total.
|
|
4878
|
+
*
|
|
4879
|
+
* The two are aggregations of the SAME hour rows over the same location set —
|
|
4880
|
+
* one folded per camera, one summed in SQL — so in a healthy recorder they
|
|
4881
|
+
* agree to the byte. A few tenths of a percent is a write landing between the
|
|
4882
|
+
* two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
|
|
4883
|
+
* rebuilt somewhere new.
|
|
4884
|
+
*/
|
|
4885
|
+
var USAGE_MISMATCH_SHARE = .02;
|
|
4886
|
+
/** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
|
|
4887
|
+
* warn on a single in-flight segment. */
|
|
4888
|
+
var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
|
|
4889
|
+
/**
|
|
4890
|
+
* One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
|
|
4891
|
+
* first (deviceId breaks a tie, so two consecutive calls compare).
|
|
4892
|
+
*
|
|
4893
|
+
* A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
|
|
4894
|
+
* 'unavailable'`, and a line saying so. Falling back to the index would answer
|
|
4895
|
+
* the same question with the wrong quantity and look exactly like a correct
|
|
4896
|
+
* answer, which is how this shipped in the first place.
|
|
4897
|
+
*/
|
|
4898
|
+
function buildDeviceUsage(input) {
|
|
4899
|
+
if (input.archive === null) {
|
|
4900
|
+
input.logger.warn("recorder: the hour ledger is absent or not loaded on this node — the per-camera storage rows are UNANSWERED, not empty", { meta: { locations: [...input.locationIds].toSorted().join(",") } });
|
|
4901
|
+
return {
|
|
4902
|
+
devices: [],
|
|
4903
|
+
totalUsedBytes: 0,
|
|
4904
|
+
read: "unavailable"
|
|
4905
|
+
};
|
|
4906
|
+
}
|
|
4907
|
+
const devices = [];
|
|
4908
|
+
let totalUsedBytes = 0;
|
|
4909
|
+
for (const [deviceId, archived] of input.archive) {
|
|
4910
|
+
const resident = input.resident(deviceId);
|
|
4911
|
+
totalUsedBytes += archived.bytes;
|
|
4912
|
+
devices.push({
|
|
4913
|
+
deviceId,
|
|
4914
|
+
usedBytes: archived.bytes,
|
|
4915
|
+
oldestMs: archived.oldestMs,
|
|
4916
|
+
segments: archived.segments,
|
|
4917
|
+
residentBytes: resident.bytes,
|
|
4918
|
+
residentOldestMs: resident.oldestMs
|
|
4919
|
+
});
|
|
4920
|
+
}
|
|
4921
|
+
return {
|
|
4922
|
+
devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
|
|
4923
|
+
totalUsedBytes,
|
|
4924
|
+
read: "archive"
|
|
4925
|
+
};
|
|
4926
|
+
}
|
|
4927
|
+
/**
|
|
4928
|
+
* Say it when the per-camera sum and the per-volume total disagree.
|
|
4929
|
+
*
|
|
4930
|
+
* The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
|
|
4931
|
+
* the same rows counted two ways, so a gap means one of the two reads saw a
|
|
4932
|
+
* fraction of the archive — and the whole point of D418 is that a page which
|
|
4933
|
+
* shows both without comparing them lets a 97% gap read as a fact about the
|
|
4934
|
+
* disks.
|
|
4935
|
+
*/
|
|
4936
|
+
function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
|
|
4937
|
+
if (read !== "archive") return;
|
|
4938
|
+
if (volumeBytes === 0) return;
|
|
4939
|
+
const gap = Math.abs(volumeBytes - deviceBytes);
|
|
4940
|
+
if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
|
|
4941
|
+
if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
|
|
4942
|
+
logger.warn("recorder: the per-camera storage sum disagrees with the per-volume total — one of the two reads saw a fraction of the archive", { meta: {
|
|
4943
|
+
deviceBytes,
|
|
4944
|
+
volumeBytes,
|
|
4945
|
+
gap
|
|
4946
|
+
} });
|
|
4947
|
+
}
|
|
4948
|
+
/**
|
|
4949
|
+
* The per-camera archive rows, or `null` — a REFUSAL — when they would not be
|
|
4950
|
+
* the archive (D674, review C1).
|
|
4951
|
+
*
|
|
4952
|
+
* The recorder registers its providers before `onHubReachable` loads the
|
|
4953
|
+
* ledger, and a failed load used to last the runner's life. In both windows the
|
|
4954
|
+
* mirror holds only the rows written since boot, and `usageByDevice` folded
|
|
4955
|
+
* them into a confident `devicesRead: 'archive'` answer — which the retention
|
|
4956
|
+
* floor reads, so every follow-recordings camera was swept on a floor that was
|
|
4957
|
+
* too young. Now:
|
|
4958
|
+
*
|
|
4959
|
+
* - no ledger, or a load that has not answered (including mid-load) ⇒ `null`,
|
|
4960
|
+
* and an unanswered load is retried (bounded, never awaited);
|
|
4961
|
+
* - no resolved location yet ⇒ `null`: an empty id set folds to "no camera
|
|
4962
|
+
* has footage", which is not what an unresolved location means.
|
|
4963
|
+
*/
|
|
4964
|
+
function archiveDeviceUsageOf(ledger, locationIds) {
|
|
4965
|
+
if (ledger === null) return null;
|
|
4966
|
+
if (!ledger.hasLoaded()) {
|
|
4967
|
+
ledger.retryLoadIfUnanswered();
|
|
4968
|
+
return null;
|
|
4969
|
+
}
|
|
4970
|
+
if (locationIds.size === 0) return null;
|
|
4971
|
+
return ledger.usageByDevice(locationIds);
|
|
4972
|
+
}
|
|
4973
|
+
/**
|
|
4974
|
+
* Is an `archive` answer WHOLE — may a camera ABSENT from it be read as "no
|
|
4975
|
+
* footage"? Only when the ledger loaded without hitting its row cap and at
|
|
4976
|
+
* least one location resolved. The storage-usage response carries this as
|
|
4977
|
+
* `complete` (D674, review M2).
|
|
4978
|
+
*/
|
|
4979
|
+
function archiveUsageComplete(ledger, locationIds) {
|
|
4980
|
+
return ledger !== null && ledger.isComplete() && locationIds.size > 0;
|
|
4981
|
+
}
|
|
4982
|
+
//#endregion
|
|
4609
4983
|
//#region src/recorder/addon/config-store.ts
|
|
4610
4984
|
/**
|
|
4611
4985
|
* Per-device recording-config persistence for the recorder addon.
|
|
@@ -5652,9 +6026,7 @@ Object.freeze({
|
|
|
5652
6026
|
"getMotionEvents": { "limit": 1e3 },
|
|
5653
6027
|
"getObjectEvents": { "limit": 1e3 },
|
|
5654
6028
|
"getSensorEvents": { "limit": 1e3 },
|
|
5655
|
-
"listRecentSummaries": { "limit": 100 },
|
|
5656
6029
|
"listRecentTracks": { "limit": 200 },
|
|
5657
|
-
"listSummaries": { "limit": 50 },
|
|
5658
6030
|
"reclaimDebugMedia": { "mode": "report" },
|
|
5659
6031
|
"searchObjectEvents": { "limit": 50 }
|
|
5660
6032
|
},
|
|
@@ -7891,81 +8263,6 @@ function buildRecordingDeviceSchema() {
|
|
|
7891
8263
|
}]
|
|
7892
8264
|
}] };
|
|
7893
8265
|
}
|
|
7894
|
-
//#endregion
|
|
7895
|
-
//#region src/recorder/addon/device-storage-usage.ts
|
|
7896
|
-
/**
|
|
7897
|
-
* Gap between the per-camera sum and the per-volume total that is worth
|
|
7898
|
-
* saying out loud, as a share of the volume total.
|
|
7899
|
-
*
|
|
7900
|
-
* The two are aggregations of the SAME hour rows over the same location set —
|
|
7901
|
-
* one folded per camera, one summed in SQL — so in a healthy recorder they
|
|
7902
|
-
* agree to the byte. A few tenths of a percent is a write landing between the
|
|
7903
|
-
* two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
|
|
7904
|
-
* rebuilt somewhere new.
|
|
7905
|
-
*/
|
|
7906
|
-
var USAGE_MISMATCH_SHARE = .02;
|
|
7907
|
-
/** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
|
|
7908
|
-
* warn on a single in-flight segment. */
|
|
7909
|
-
var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
|
|
7910
|
-
/**
|
|
7911
|
-
* One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
|
|
7912
|
-
* first (deviceId breaks a tie, so two consecutive calls compare).
|
|
7913
|
-
*
|
|
7914
|
-
* A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
|
|
7915
|
-
* 'unavailable'`, and a line saying so. Falling back to the index would answer
|
|
7916
|
-
* the same question with the wrong quantity and look exactly like a correct
|
|
7917
|
-
* answer, which is how this shipped in the first place.
|
|
7918
|
-
*/
|
|
7919
|
-
function buildDeviceUsage(input) {
|
|
7920
|
-
if (input.archive === null) {
|
|
7921
|
-
input.logger.warn("recorder: no hour ledger on this node — the per-camera storage rows are UNANSWERED, not empty", { meta: { locations: [...input.locationIds].toSorted().join(",") } });
|
|
7922
|
-
return {
|
|
7923
|
-
devices: [],
|
|
7924
|
-
totalUsedBytes: 0,
|
|
7925
|
-
read: "unavailable"
|
|
7926
|
-
};
|
|
7927
|
-
}
|
|
7928
|
-
const devices = [];
|
|
7929
|
-
let totalUsedBytes = 0;
|
|
7930
|
-
for (const [deviceId, archived] of input.archive) {
|
|
7931
|
-
const resident = input.resident(deviceId);
|
|
7932
|
-
totalUsedBytes += archived.bytes;
|
|
7933
|
-
devices.push({
|
|
7934
|
-
deviceId,
|
|
7935
|
-
usedBytes: archived.bytes,
|
|
7936
|
-
oldestMs: archived.oldestMs,
|
|
7937
|
-
segments: archived.segments,
|
|
7938
|
-
residentBytes: resident.bytes,
|
|
7939
|
-
residentOldestMs: resident.oldestMs
|
|
7940
|
-
});
|
|
7941
|
-
}
|
|
7942
|
-
return {
|
|
7943
|
-
devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
|
|
7944
|
-
totalUsedBytes,
|
|
7945
|
-
read: "archive"
|
|
7946
|
-
};
|
|
7947
|
-
}
|
|
7948
|
-
/**
|
|
7949
|
-
* Say it when the per-camera sum and the per-volume total disagree.
|
|
7950
|
-
*
|
|
7951
|
-
* The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
|
|
7952
|
-
* the same rows counted two ways, so a gap means one of the two reads saw a
|
|
7953
|
-
* fraction of the archive — and the whole point of D418 is that a page which
|
|
7954
|
-
* shows both without comparing them lets a 97% gap read as a fact about the
|
|
7955
|
-
* disks.
|
|
7956
|
-
*/
|
|
7957
|
-
function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
|
|
7958
|
-
if (read !== "archive") return;
|
|
7959
|
-
if (volumeBytes === 0) return;
|
|
7960
|
-
const gap = Math.abs(volumeBytes - deviceBytes);
|
|
7961
|
-
if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
|
|
7962
|
-
if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
|
|
7963
|
-
logger.warn("recorder: the per-camera storage sum disagrees with the per-volume total — one of the two reads saw a fraction of the archive", { meta: {
|
|
7964
|
-
deviceBytes,
|
|
7965
|
-
volumeBytes,
|
|
7966
|
-
gap
|
|
7967
|
-
} });
|
|
7968
|
-
}
|
|
7969
8266
|
/** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
|
|
7970
8267
|
* in `placement-store.ts`; kept local so this module stays dependency-free. */
|
|
7971
8268
|
function sourceKey(source) {
|
|
@@ -8598,6 +8895,7 @@ function buildRecordingProvider(deps) {
|
|
|
8598
8895
|
totalUsedBytes,
|
|
8599
8896
|
devices: [...devices],
|
|
8600
8897
|
devicesRead: deviceUsage.read,
|
|
8898
|
+
complete: deviceUsage.read === "archive" && (deps.archiveUsageComplete?.(locationIds) ?? false),
|
|
8601
8899
|
locations: locationUsages
|
|
8602
8900
|
};
|
|
8603
8901
|
},
|
|
@@ -9822,6 +10120,10 @@ var CENSUS_FIELDS = [
|
|
|
9822
10120
|
];
|
|
9823
10121
|
/** Summary rows folded into the mirror between yields. */
|
|
9824
10122
|
var LOAD_CHUNK = 5e3;
|
|
10123
|
+
/** Minimum spacing between ledger load attempts after a failure (D674). */
|
|
10124
|
+
var LEDGER_LOAD_RETRY_MS = 6e4;
|
|
10125
|
+
/** Spacing of the retries after the fast ones — slower, never stopped. */
|
|
10126
|
+
var LEDGER_LOAD_SLOW_RETRY_MS = 10 * 6e4;
|
|
9825
10127
|
/** The four scalars an accounting answer is made of, asked in one statement. */
|
|
9826
10128
|
var ACCOUNTING_FIELDS = [
|
|
9827
10129
|
{
|
|
@@ -10017,6 +10319,22 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
10017
10319
|
* the recorder happens to be placing that hour on.
|
|
10018
10320
|
*/
|
|
10019
10321
|
refusedWriterHours = /* @__PURE__ */ new Set();
|
|
10322
|
+
/**
|
|
10323
|
+
* Has a {@link load} ANSWERED — its query returned AND every row it returned
|
|
10324
|
+
* is staged? Before that the mirror is "not read yet" (or, mid-load, a
|
|
10325
|
+
* fraction), never "the archive is empty". `load` swallows its failure, so a
|
|
10326
|
+
* reader that must tell the two apart (the calendar's window floor, the
|
|
10327
|
+
* storage-usage rows a retention floor is read from — D674) asks here.
|
|
10328
|
+
*/
|
|
10329
|
+
loaded = false;
|
|
10330
|
+
/** The last successful load returned {@link rowCap} rows: hours past the cap
|
|
10331
|
+
* are UNKNOWN, so the mirror is an answer but not a WHOLE one. */
|
|
10332
|
+
loadHitRowCap = false;
|
|
10333
|
+
/** Load attempts made, boot included — the retry is bounded (D674). */
|
|
10334
|
+
loadAttempts = 0;
|
|
10335
|
+
lastLoadAttemptMs = null;
|
|
10336
|
+
loadInFlight = null;
|
|
10337
|
+
loadRetriesExhaustedReported = false;
|
|
10020
10338
|
/** Entry counts of the in-process maps, for the recorder census. */
|
|
10021
10339
|
memoryCensus() {
|
|
10022
10340
|
let accountedPaths = 0;
|
|
@@ -10065,17 +10383,70 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
10065
10383
|
* seed the index (D523).
|
|
10066
10384
|
*/
|
|
10067
10385
|
async load() {
|
|
10386
|
+
this.loadInFlight ??= this.loadOnce().finally(() => {
|
|
10387
|
+
this.loadInFlight = null;
|
|
10388
|
+
});
|
|
10389
|
+
return this.loadInFlight;
|
|
10390
|
+
}
|
|
10391
|
+
/**
|
|
10392
|
+
* Retry a load that has not answered — rate-limited, logged, never abandoned
|
|
10393
|
+
* (D674).
|
|
10394
|
+
*
|
|
10395
|
+
* A failed boot load used to last the runner's life: every reader then saw
|
|
10396
|
+
* only the rows written since boot, and `getStorageUsage` labelled them the
|
|
10397
|
+
* archive. Readers call this and never wait on it (it is fire-and-forget at
|
|
10398
|
+
* every call site); at most one attempt per {@link LEDGER_LOAD_RETRY_MS} for
|
|
10399
|
+
* the first {@link LEDGER_LOAD_MAX_ATTEMPTS}, then one per
|
|
10400
|
+
* {@link LEDGER_LOAD_SLOW_RETRY_MS}; the slowdown is said once.
|
|
10401
|
+
*/
|
|
10402
|
+
retryLoadIfUnanswered() {
|
|
10403
|
+
if (this.loaded || this.loadInFlight !== null) return;
|
|
10404
|
+
const slow = this.loadAttempts >= 10;
|
|
10405
|
+
if (slow && !this.loadRetriesExhaustedReported) {
|
|
10406
|
+
this.loadRetriesExhaustedReported = true;
|
|
10407
|
+
this.logger.warn("recorder: segment-hour ledger still not loaded — retrying at the slow interval; archive-wide answers stay REFUSED until it loads", { meta: {
|
|
10408
|
+
attempts: this.loadAttempts,
|
|
10409
|
+
slowRetryMs: LEDGER_LOAD_SLOW_RETRY_MS
|
|
10410
|
+
} });
|
|
10411
|
+
}
|
|
10412
|
+
const spacing = slow ? LEDGER_LOAD_SLOW_RETRY_MS : LEDGER_LOAD_RETRY_MS;
|
|
10413
|
+
const now = this.now();
|
|
10414
|
+
if (this.lastLoadAttemptMs !== null && now - this.lastLoadAttemptMs < spacing) return;
|
|
10415
|
+
if (!slow) this.logger.info("recorder: retrying the segment-hour ledger load", { meta: {
|
|
10416
|
+
attempt: this.loadAttempts + 1,
|
|
10417
|
+
fastAttempts: 10
|
|
10418
|
+
} });
|
|
10419
|
+
this.load();
|
|
10420
|
+
}
|
|
10421
|
+
/**
|
|
10422
|
+
* Is the mirror a WHOLE answer about the archive: loaded, and not cut at the
|
|
10423
|
+
* row cap? The storage-usage rows and the calendar floor are only facts about
|
|
10424
|
+
* the archive when this is true.
|
|
10425
|
+
*/
|
|
10426
|
+
isComplete() {
|
|
10427
|
+
return this.loaded && !this.loadHitRowCap;
|
|
10428
|
+
}
|
|
10429
|
+
async loadOnce() {
|
|
10068
10430
|
const startedMs = this.now();
|
|
10431
|
+
this.loadAttempts += 1;
|
|
10432
|
+
this.lastLoadAttemptMs = startedMs;
|
|
10069
10433
|
let records;
|
|
10070
10434
|
try {
|
|
10071
10435
|
records = await this.store.query.query({
|
|
10072
10436
|
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
10073
|
-
filter: {
|
|
10437
|
+
filter: {
|
|
10438
|
+
limit: this.rowCap,
|
|
10439
|
+
orderBy: {
|
|
10440
|
+
field: "hourStartMs",
|
|
10441
|
+
direction: "desc"
|
|
10442
|
+
}
|
|
10443
|
+
}
|
|
10074
10444
|
});
|
|
10075
10445
|
} catch (err) {
|
|
10076
10446
|
this.logger.warn("recorder: segment-hour ledger load failed — keeping the hours already held", { meta: {
|
|
10077
10447
|
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
10078
10448
|
held: this.ledger.size,
|
|
10449
|
+
attempt: this.loadAttempts,
|
|
10079
10450
|
error: String(err)
|
|
10080
10451
|
} });
|
|
10081
10452
|
return this.ledger.snapshot();
|
|
@@ -10091,10 +10462,12 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
10091
10462
|
skipped += 1;
|
|
10092
10463
|
continue;
|
|
10093
10464
|
}
|
|
10094
|
-
this.ledger.stage(row);
|
|
10465
|
+
if (!this.ledger.has(row.key)) this.ledger.stage(row);
|
|
10095
10466
|
}
|
|
10096
10467
|
if (end < records.length) await this.yieldBetween();
|
|
10097
10468
|
}
|
|
10469
|
+
this.loaded = true;
|
|
10470
|
+
this.loadHitRowCap = records.length >= this.rowCap;
|
|
10098
10471
|
if (skipped > 0) this.logger.warn("recorder: segment-hour rows skipped as malformed — they gate NOTHING", { meta: {
|
|
10099
10472
|
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
10100
10473
|
skipped
|
|
@@ -10116,6 +10489,31 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
10116
10489
|
snapshot() {
|
|
10117
10490
|
return this.ledger.snapshot();
|
|
10118
10491
|
}
|
|
10492
|
+
/** Whether a {@link load} has answered and staged every row it returned —
|
|
10493
|
+
* see {@link loaded}. {@link isComplete} is the stronger question. */
|
|
10494
|
+
hasLoaded() {
|
|
10495
|
+
return this.loaded;
|
|
10496
|
+
}
|
|
10497
|
+
/**
|
|
10498
|
+
* The OLDEST hour the archive holds per camera, across every profile and
|
|
10499
|
+
* every location — the floor below which no hour can hold footage (D674).
|
|
10500
|
+
*
|
|
10501
|
+
* Every row counts, including one whose segments were all dropped and that
|
|
10502
|
+
* the prune has not yet forgotten: an older floor only walks a few more
|
|
10503
|
+
* hours, while a floor that is too YOUNG hides footage. One fold, no I/O —
|
|
10504
|
+
* the same shape as {@link devicesWithFootage}. Meaningful only after
|
|
10505
|
+
* {@link isComplete}; even then a camera ABSENT from the map is unknown to
|
|
10506
|
+
* the calendar (an hour not adopted yet is on disk and absent here), never a
|
|
10507
|
+
* known "no footage" (D674).
|
|
10508
|
+
*/
|
|
10509
|
+
oldestHourByDevice() {
|
|
10510
|
+
const out = /* @__PURE__ */ new Map();
|
|
10511
|
+
for (const row of this.ledger.snapshot()) {
|
|
10512
|
+
const held = out.get(row.deviceId);
|
|
10513
|
+
if (held === void 0 || row.hourStartMs < held) out.set(row.deviceId, row.hourStartMs);
|
|
10514
|
+
}
|
|
10515
|
+
return out;
|
|
10516
|
+
}
|
|
10119
10517
|
/**
|
|
10120
10518
|
* Which profiles each of these cameras has footage for IN THE ARCHIVE.
|
|
10121
10519
|
*
|
|
@@ -20501,12 +20899,17 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
20501
20899
|
/** LAZY: a field initialiser runs before `initialize()` assigns `ctx`, and
|
|
20502
20900
|
* reading `this.ctx` there throws — the same trap that bit the playback tier. */
|
|
20503
20901
|
get calendar() {
|
|
20504
|
-
this.calendarInstance ??=
|
|
20902
|
+
this.calendarInstance ??= this.buildCalendar();
|
|
20903
|
+
return this.calendarInstance;
|
|
20904
|
+
}
|
|
20905
|
+
buildCalendar() {
|
|
20906
|
+
const floors = new ArchiveFloorReader(() => this.segmentHours);
|
|
20907
|
+
return new CalendarIndex({
|
|
20505
20908
|
roots: () => this.resolvedLocations.map((l) => l.root),
|
|
20506
20909
|
logger: this.ctx.logger,
|
|
20507
|
-
indexHour: (deviceId, profile, hourStartMs) => hourSegmentsFromIndex(this.index, deviceId, profile, hourStartMs)
|
|
20910
|
+
indexHour: (deviceId, profile, hourStartMs) => hourSegmentsFromIndex(this.index, deviceId, profile, hourStartMs),
|
|
20911
|
+
archiveFloor: (deviceId) => floors.floorOf(deviceId)
|
|
20508
20912
|
});
|
|
20509
|
-
return this.calendarInstance;
|
|
20510
20913
|
}
|
|
20511
20914
|
segmentStore = null;
|
|
20512
20915
|
/** Sync-sample tables captured at finalize, served by the directory so a
|
|
@@ -20971,7 +21374,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
20971
21374
|
refreshLocations: () => this.refreshLocations(),
|
|
20972
21375
|
capacity: (root) => this.locationCapacity(root),
|
|
20973
21376
|
archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
|
|
20974
|
-
archiveDeviceUsage: (locationIds) => this.segmentHours
|
|
21377
|
+
archiveDeviceUsage: (locationIds) => archiveDeviceUsageOf(this.segmentHours, locationIds),
|
|
21378
|
+
archiveUsageComplete: (locationIds) => archiveUsageComplete(this.segmentHours, locationIds),
|
|
20975
21379
|
archive: () => this.segmentHours,
|
|
20976
21380
|
ledgerWalk: this.ledgerWalkReconciler(),
|
|
20977
21381
|
hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
|