@camstack/addon-pipeline 1.2.303 → 1.2.305
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-BroB5Jp1.mjs → default-detection-model-BduwEjht.mjs} +1 -1
- package/dist/{default-detection-model-vN686W1U.js → default-detection-model-BsXfdMCG.js} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-CLZmZfHT.mjs → dist-BdYgTuWJ.mjs} +4011 -3654
- package/dist/{dist-CyWAhtbJ.js → dist-D4lbkRUh.js} +4010 -3653
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-BDrkzYiH.js → node-BMmRQfH_.js} +1 -1
- package/dist/{node-C5LXX8Qo.mjs → node-DTG5A-3e.mjs} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-DMS-0oRT.js → process-memory-DJrgIn1c.js} +1 -1
- package/dist/{process-memory-CCx6i9av.mjs → process-memory-RnWS9Et5.mjs} +1 -1
- package/dist/recorder/index.js +542 -136
- package/dist/recorder/index.mjs +542 -136
- package/dist/{segment-demux-js-CwQuMR0h.js → segment-demux-js-BI9zEgk6.js} +1 -1
- package/dist/{segment-demux-js-BUxZFqkb.mjs → segment-demux-js-CsJMEslt.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-Vcuvgik_.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-X_j7TiDp.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-CR9IIPsz.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-C3s1ZFbg.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.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-
|
|
1
|
+
import { B as VISIT_MERGE_GAP_MS, Bt as resolveLocationMode, Ct as modeMayWrite, Dn as array, En as _enum, F as RECORDING_SOURCE_CAMSTACK_ADDON, Fn as record, Ft as recordingExportCapability, Gt as storageEvictableCapability, Ht as resolveRecordingProfiles, I as RecordingConfigSchema, In as string, It as recordingSignalCapability, J as clipExportFailure, Jt as stripRetiredBandPreBufferSec, K as batteryCapability, Kt as storageOccupancyCapability, L as RecordingSignalStatusSchema, Mn as number, N as RECORDING_EXPORT_MAX_READ_BYTES, Nn as object, Nt as recordingArchiveCapability, On as boolean, P as RECORDING_SOURCE_CAMSTACK, Pn as preprocess, Pt as recordingCapability, Rn as EventCategory, Rt as resolveCreateExportSubject, S as ExportRecordSchema, St as migrateLegacyDeviceSignalConfig, Tn as sleep$1, Xt as videoclipsCapability, Y as clipExportFailureOf, _n as hydrateSchema, a as BatteryStatusSchema, an as DeviceFeature, b as EVENT_PAD_MS, bt as mayReadLocation, ct as evictionPolicyForMode, et as deriveBatteryPresence, ft as gbToBytes, g as DEFAULT_EVENTS_BAND_BUFFER_SEC, gt as loadContributionCapability, hn as defineCustomActions, ht as isFirstLevelMacroClass, k as OpsLogEntrySchema, ln as BaseAddon, lt as evictionPolicyOfLocation, mn as customAction, nn as evaluateSensorEdge, nt as deriveRecordingMode, o as CLIP_BROKER_PACED_RATES, on as DeviceType, sn as errMsg$1, ut as exportSubjectOf, wn as selectAssignedProfileSlots, xn as nodePin, xt as mayWriteToLocation, y as EVENT_OWNER_TYPES, z as VIDEOCLIPS_MAX_OFFER_BYTES } from "../dist-BdYgTuWJ.mjs";
|
|
2
|
+
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DTG5A-3e.mjs";
|
|
3
3
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
4
4
|
import { d as PREBUFFER_MAX_MS, l as withRecordingIntent, r as RECORDING_PRE_ROLL_REQUEST_MS } from "../restream-intent-DyGmLAU9.mjs";
|
|
5
5
|
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
|
|
@@ -833,15 +833,15 @@ function parseSegmentPath(relPath) {
|
|
|
833
833
|
//#region src/recorder/recording-index.ts
|
|
834
834
|
/** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
|
|
835
835
|
* already gives us — so "have I walked this?" costs one Set lookup. */
|
|
836
|
-
var HOUR_MS$
|
|
836
|
+
var HOUR_MS$11 = 36e5;
|
|
837
837
|
/** The hour buckets a half-open span touches. */
|
|
838
838
|
function* hourBuckets(fromMs, toMs) {
|
|
839
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
840
|
-
for (let h = first; h < toMs; h += HOUR_MS$
|
|
839
|
+
const first = Math.floor(fromMs / HOUR_MS$11) * HOUR_MS$11;
|
|
840
|
+
for (let h = first; h < toMs; h += HOUR_MS$11) yield h;
|
|
841
841
|
}
|
|
842
842
|
/** The hour bucket a segment belongs to — the same one its path encodes. */
|
|
843
843
|
function hourOf$1(startMs) {
|
|
844
|
-
return Math.floor(startMs / HOUR_MS$
|
|
844
|
+
return Math.floor(startMs / HOUR_MS$11) * HOUR_MS$11;
|
|
845
845
|
}
|
|
846
846
|
/** The production bound. See {@link RecordingIndexBudget} for the measurement. */
|
|
847
847
|
var DEFAULT_RECORDING_INDEX_BUDGET = {
|
|
@@ -1219,8 +1219,8 @@ var RecordingIndex = class {
|
|
|
1219
1219
|
const w = this.walked.get(deviceId);
|
|
1220
1220
|
if (w === void 0 || w.size === 0) return;
|
|
1221
1221
|
const tick = ++this.useTick;
|
|
1222
|
-
if (Math.ceil(Math.max(0, toMs - fromMs) / HOUR_MS$
|
|
1223
|
-
for (const h of w.keys()) if (h < toMs && h + HOUR_MS$
|
|
1222
|
+
if (Math.ceil(Math.max(0, toMs - fromMs) / HOUR_MS$11) > w.size) {
|
|
1223
|
+
for (const h of w.keys()) if (h < toMs && h + HOUR_MS$11 > fromMs) w.set(h, tick);
|
|
1224
1224
|
return;
|
|
1225
1225
|
}
|
|
1226
1226
|
for (const h of hourBuckets(fromMs, toMs)) if (w.has(h)) w.set(h, tick);
|
|
@@ -1257,7 +1257,7 @@ var RecordingIndex = class {
|
|
|
1257
1257
|
if (rows <= budget.residentRowBudget && hours <= budget.residentHourBudget) return;
|
|
1258
1258
|
const rowTarget = Math.floor(budget.residentRowBudget * budget.evictToFraction);
|
|
1259
1259
|
const hourTarget = Math.floor(budget.residentHourBudget * budget.evictToFraction);
|
|
1260
|
-
const pinnedFrom = hourOf$1(deps.now()) - Math.max(0, budget.pinnedTailHours - 1) * HOUR_MS$
|
|
1260
|
+
const pinnedFrom = hourOf$1(deps.now()) - Math.max(0, budget.pinnedTailHours - 1) * HOUR_MS$11;
|
|
1261
1261
|
const counts = this.hourHistogram();
|
|
1262
1262
|
const candidates = [];
|
|
1263
1263
|
for (const [deviceId, byHour] of counts) {
|
|
@@ -1379,7 +1379,7 @@ var RecordingIndex = class {
|
|
|
1379
1379
|
changed = true;
|
|
1380
1380
|
}
|
|
1381
1381
|
if (changed) this.invalidateSorted(deviceId);
|
|
1382
|
-
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$
|
|
1382
|
+
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$11);
|
|
1383
1383
|
this.enforceResidency();
|
|
1384
1384
|
}
|
|
1385
1385
|
/**
|
|
@@ -1404,7 +1404,7 @@ var RecordingIndex = class {
|
|
|
1404
1404
|
const dropped = [];
|
|
1405
1405
|
for (const [path, row] of m) {
|
|
1406
1406
|
if (row.locationId !== locationId) continue;
|
|
1407
|
-
if (Math.floor(row.startMs / HOUR_MS$
|
|
1407
|
+
if (Math.floor(row.startMs / HOUR_MS$11) * HOUR_MS$11 !== hourStartMs) continue;
|
|
1408
1408
|
if (keep.has(path)) continue;
|
|
1409
1409
|
dropped.push(path);
|
|
1410
1410
|
}
|
|
@@ -2067,11 +2067,11 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
|
2067
2067
|
const p2 = (n) => String(n).padStart(2, "0");
|
|
2068
2068
|
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
2069
2069
|
}
|
|
2070
|
-
var HOUR_MS$
|
|
2070
|
+
var HOUR_MS$10 = 36e5;
|
|
2071
2071
|
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
|
|
2072
2072
|
const verify = options?.verifyClosedHours === true;
|
|
2073
2073
|
if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
2074
|
-
const liveHour = Math.floor(Date.now() / HOUR_MS$
|
|
2074
|
+
const liveHour = Math.floor(Date.now() / HOUR_MS$10) * HOUR_MS$10;
|
|
2075
2075
|
for (const [root, aliases] of aliasesByRoot(locations)) {
|
|
2076
2076
|
const deviceDir = path.join(root, String(deviceId));
|
|
2077
2077
|
let profiles;
|
|
@@ -2087,15 +2087,15 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
2087
2087
|
});
|
|
2088
2088
|
continue;
|
|
2089
2089
|
}
|
|
2090
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
2090
|
+
const first = Math.floor(fromMs / HOUR_MS$10) * HOUR_MS$10;
|
|
2091
2091
|
const reuseBefore = index.hydrateReuseStats();
|
|
2092
2092
|
let hoursRead = 0;
|
|
2093
2093
|
let repaired = 0;
|
|
2094
2094
|
let forgottenSegments = 0;
|
|
2095
2095
|
let forgottenHours = 0;
|
|
2096
2096
|
let sealed = 0;
|
|
2097
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
2098
|
-
if (!verify && hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$
|
|
2097
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$10) {
|
|
2098
|
+
if (!verify && hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$10) === "hydrated") continue;
|
|
2099
2099
|
for (const profile of profiles) {
|
|
2100
2100
|
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
2101
2101
|
let files;
|
|
@@ -2108,7 +2108,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
2108
2108
|
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
2109
2109
|
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
2110
2110
|
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
2111
|
-
if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$
|
|
2111
|
+
if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$10 > Date.now()) continue;
|
|
2112
2112
|
const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
|
|
2113
2113
|
if (outcome === null) continue;
|
|
2114
2114
|
if (outcome.created) repaired += 1;
|
|
@@ -2190,7 +2190,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
2190
2190
|
*/
|
|
2191
2191
|
var DEFAULT_THROTTLE_MBPS = 40;
|
|
2192
2192
|
/** The durable index's bucket, and therefore this mover's batch. */
|
|
2193
|
-
var HOUR_MS$
|
|
2193
|
+
var HOUR_MS$9 = 36e5;
|
|
2194
2194
|
/** Progress callback cadence (files) — cheap enough to fire often. */
|
|
2195
2195
|
var PROGRESS_EVERY_FILES = 25;
|
|
2196
2196
|
/** Free space a destination must keep when it declares no `config.minFreePercent`
|
|
@@ -2879,7 +2879,7 @@ function profilesOfClass(footageClass) {
|
|
|
2879
2879
|
function groupByHour(rows) {
|
|
2880
2880
|
const out = /* @__PURE__ */ new Map();
|
|
2881
2881
|
for (const row of rows) {
|
|
2882
|
-
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$
|
|
2882
|
+
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$9)}`;
|
|
2883
2883
|
const found = out.get(key);
|
|
2884
2884
|
if (found) found.push(row);
|
|
2885
2885
|
else out.set(key, [row]);
|
|
@@ -4103,6 +4103,231 @@ async function profileHasFootage(root, deviceId, profile) {
|
|
|
4103
4103
|
return (await listDir(path.join(root, String(deviceId), profile))).length > 0;
|
|
4104
4104
|
}
|
|
4105
4105
|
//#endregion
|
|
4106
|
+
//#region src/recorder/addon/calendar-hour-cache.ts
|
|
4107
|
+
var HOUR_MS$8 = 36e5;
|
|
4108
|
+
var HOURS_PER_DAY = 24;
|
|
4109
|
+
/**
|
|
4110
|
+
* The production bound: the RAM index's own (`DEFAULT_RECORDING_INDEX_BUDGET`,
|
|
4111
|
+
* 250 000 rows / 20 000 walked hours, measured at 698 B/row). The calendar
|
|
4112
|
+
* holds a 3-scalar `HourSegment` per segment where the index holds a full row,
|
|
4113
|
+
* so the same counts are a SMALLER heap here — see
|
|
4114
|
+
* {@link CALENDAR_SEGMENT_APPROX_BYTES}.
|
|
4115
|
+
*/
|
|
4116
|
+
var DEFAULT_CALENDAR_HOUR_BUDGET = {
|
|
4117
|
+
hourBudget: DEFAULT_RECORDING_INDEX_BUDGET.residentHourBudget,
|
|
4118
|
+
segmentBudget: DEFAULT_RECORDING_INDEX_BUDGET.residentRowBudget,
|
|
4119
|
+
evictToFraction: DEFAULT_RECORDING_INDEX_BUDGET.evictToFraction
|
|
4120
|
+
};
|
|
4121
|
+
/** Every empty hour shares this one array: a gap costs a slot, not an object. */
|
|
4122
|
+
var EMPTY_HOUR = Object.freeze([]);
|
|
4123
|
+
/** Hours since the epoch — a Smi, where `hourMs` would be a HeapNumber. */
|
|
4124
|
+
function hourIndexOf(epochMs) {
|
|
4125
|
+
return Math.floor(epochMs / HOUR_MS$8);
|
|
4126
|
+
}
|
|
4127
|
+
var CalendarHourCache = class {
|
|
4128
|
+
budget;
|
|
4129
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
4130
|
+
/** Least recently used first — `Set` iteration order is insertion order. */
|
|
4131
|
+
lru = /* @__PURE__ */ new Set();
|
|
4132
|
+
segmentCount = 0;
|
|
4133
|
+
emptyCount = 0;
|
|
4134
|
+
stampedCount = 0;
|
|
4135
|
+
evictedCount = 0;
|
|
4136
|
+
constructor(budget = DEFAULT_CALENDAR_HOUR_BUDGET) {
|
|
4137
|
+
this.budget = budget;
|
|
4138
|
+
}
|
|
4139
|
+
/** The cached segments of one hour, or `undefined` when not cached. */
|
|
4140
|
+
get(deviceId, profile, hourMs) {
|
|
4141
|
+
const entry = this.entryOf(deviceId, profile, hourMs);
|
|
4142
|
+
if (entry === void 0) return void 0;
|
|
4143
|
+
this.lru.delete(entry);
|
|
4144
|
+
this.lru.add(entry);
|
|
4145
|
+
return entry.segments;
|
|
4146
|
+
}
|
|
4147
|
+
/** When this hour was read AS THE CURRENT HOUR, or `undefined`. */
|
|
4148
|
+
readAtOf(deviceId, profile, hourMs) {
|
|
4149
|
+
return this.entryOf(deviceId, profile, hourMs)?.readAtMs ?? void 0;
|
|
4150
|
+
}
|
|
4151
|
+
/**
|
|
4152
|
+
* Store one COMPLETE scan. `currentReadAtMs` is set only when the hour was
|
|
4153
|
+
* the current one at store time; a later store of the closed hour passes
|
|
4154
|
+
* `null` and so drops the stamp with the entry it replaces.
|
|
4155
|
+
*/
|
|
4156
|
+
set(deviceId, profile, hourMs, segments, currentReadAtMs) {
|
|
4157
|
+
const hourIndex = hourIndexOf(hourMs);
|
|
4158
|
+
this.remove(deviceId, profile, hourIndex);
|
|
4159
|
+
const entry = {
|
|
4160
|
+
deviceId,
|
|
4161
|
+
profile,
|
|
4162
|
+
hourIndex,
|
|
4163
|
+
segments: segments.length === 0 ? EMPTY_HOUR : segments,
|
|
4164
|
+
readAtMs: currentReadAtMs
|
|
4165
|
+
};
|
|
4166
|
+
this.profileMap(deviceId, profile).set(hourIndex, entry);
|
|
4167
|
+
this.lru.add(entry);
|
|
4168
|
+
if (entry.segments.length === 0) this.emptyCount += 1;
|
|
4169
|
+
else this.segmentCount += entry.segments.length;
|
|
4170
|
+
if (entry.readAtMs !== null) this.stampedCount += 1;
|
|
4171
|
+
this.enforceBudget();
|
|
4172
|
+
}
|
|
4173
|
+
/**
|
|
4174
|
+
* Drop one camera's UTC day, every profile — what eviction changed. The read
|
|
4175
|
+
* stamps go with their hours; there is no second map to sweep.
|
|
4176
|
+
*/
|
|
4177
|
+
invalidateDay(deviceId, utcDayMs) {
|
|
4178
|
+
const firstHour = hourIndexOf(utcDayMs);
|
|
4179
|
+
const profiles = this.byDevice.get(deviceId);
|
|
4180
|
+
if (profiles === void 0) return;
|
|
4181
|
+
for (const profile of [...profiles.keys()]) for (let h = 0; h < HOURS_PER_DAY; h++) this.remove(deviceId, profile, firstHour + h);
|
|
4182
|
+
}
|
|
4183
|
+
/** Full reset — a relocation re-pointed rows across locations. */
|
|
4184
|
+
clear() {
|
|
4185
|
+
this.byDevice.clear();
|
|
4186
|
+
this.lru.clear();
|
|
4187
|
+
this.segmentCount = 0;
|
|
4188
|
+
this.emptyCount = 0;
|
|
4189
|
+
this.stampedCount = 0;
|
|
4190
|
+
}
|
|
4191
|
+
census() {
|
|
4192
|
+
const hours = this.lru.size;
|
|
4193
|
+
return {
|
|
4194
|
+
hourKeys: hours - this.emptyCount,
|
|
4195
|
+
emptyHours: this.emptyCount,
|
|
4196
|
+
hourSegments: this.segmentCount,
|
|
4197
|
+
segsReadAtKeys: this.stampedCount,
|
|
4198
|
+
evictedHours: this.evictedCount,
|
|
4199
|
+
approxBytes: hours * 128 + this.segmentCount * 72 + this.stampedCount * 16
|
|
4200
|
+
};
|
|
4201
|
+
}
|
|
4202
|
+
entryOf(deviceId, profile, hourMs) {
|
|
4203
|
+
return this.byDevice.get(deviceId)?.get(profile)?.get(hourIndexOf(hourMs));
|
|
4204
|
+
}
|
|
4205
|
+
profileMap(deviceId, profile) {
|
|
4206
|
+
let profiles = this.byDevice.get(deviceId);
|
|
4207
|
+
if (profiles === void 0) {
|
|
4208
|
+
profiles = /* @__PURE__ */ new Map();
|
|
4209
|
+
this.byDevice.set(deviceId, profiles);
|
|
4210
|
+
}
|
|
4211
|
+
let hours = profiles.get(profile);
|
|
4212
|
+
if (hours === void 0) {
|
|
4213
|
+
hours = /* @__PURE__ */ new Map();
|
|
4214
|
+
profiles.set(profile, hours);
|
|
4215
|
+
}
|
|
4216
|
+
return hours;
|
|
4217
|
+
}
|
|
4218
|
+
/** Remove one hour and its stamp; prune empty parents. */
|
|
4219
|
+
remove(deviceId, profile, hourIndex) {
|
|
4220
|
+
const profiles = this.byDevice.get(deviceId);
|
|
4221
|
+
const hours = profiles?.get(profile);
|
|
4222
|
+
const entry = hours?.get(hourIndex);
|
|
4223
|
+
if (profiles === void 0 || hours === void 0 || entry === void 0) return;
|
|
4224
|
+
hours.delete(hourIndex);
|
|
4225
|
+
if (hours.size === 0) profiles.delete(profile);
|
|
4226
|
+
if (profiles.size === 0) this.byDevice.delete(deviceId);
|
|
4227
|
+
this.lru.delete(entry);
|
|
4228
|
+
if (entry.segments.length === 0) this.emptyCount -= 1;
|
|
4229
|
+
else this.segmentCount -= entry.segments.length;
|
|
4230
|
+
if (entry.readAtMs !== null) this.stampedCount -= 1;
|
|
4231
|
+
}
|
|
4232
|
+
enforceBudget() {
|
|
4233
|
+
const { hourBudget, segmentBudget, evictToFraction } = this.budget;
|
|
4234
|
+
if (this.lru.size <= hourBudget && this.segmentCount <= segmentBudget) return;
|
|
4235
|
+
const hourTarget = Math.floor(hourBudget * evictToFraction);
|
|
4236
|
+
const segmentTarget = Math.floor(segmentBudget * evictToFraction);
|
|
4237
|
+
for (const entry of this.lru) {
|
|
4238
|
+
if (this.lru.size <= hourTarget && this.segmentCount <= segmentTarget) return;
|
|
4239
|
+
this.remove(entry.deviceId, entry.profile, entry.hourIndex);
|
|
4240
|
+
this.evictedCount += 1;
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
};
|
|
4244
|
+
//#endregion
|
|
4245
|
+
//#region src/recorder/addon/calendar-window.ts
|
|
4246
|
+
var HOUR_MS$7 = 36e5;
|
|
4247
|
+
/**
|
|
4248
|
+
* The widest walk one call may make AFTER the clamp: a leap year of hours.
|
|
4249
|
+
*
|
|
4250
|
+
* Evidence, not taste. The widest in-repo caller is the viewer's month
|
|
4251
|
+
* calendar (~33 days); the rest ask a day or a clip window (liveness 24 h,
|
|
4252
|
+
* videoclips' since/until, a timelapse window, a timeline day). The one that
|
|
4253
|
+
* asked for more wanted a single number the ledger already holds (D674). The
|
|
4254
|
+
* archives it would still admit are past what this system keeps: the 28 TB
|
|
4255
|
+
* projection is ~47 k hour rows, ~2,000 hours (~82 days) per camera/profile
|
|
4256
|
+
* pair at ~24 pairs, and the live hub held 3.3 days. (The ledger's load CAP,
|
|
4257
|
+
* `SEGMENT_HOUR_SUMMARY_LIMIT` = 250,000 rows, is ~10 k hours per pair — a
|
|
4258
|
+
* bound on memory, not a claim about retention; the clamp only ever narrows a
|
|
4259
|
+
* walk further.) A walk this wide costs ~130 B of garbage per hour warm
|
|
4260
|
+
* (measured: ~65 MB for 497,408 hours), so ~1.1 MB at the cap.
|
|
4261
|
+
*/
|
|
4262
|
+
var CALENDAR_MAX_WALK_HOURS = 366 * 24;
|
|
4263
|
+
var UNKNOWN_ARCHIVE_FLOOR = { known: false };
|
|
4264
|
+
/** A walk the calendar will not make. Surfaces as `read: 'unreadable'`. */
|
|
4265
|
+
var CalendarWindowRefusedError = class extends Error {
|
|
4266
|
+
deviceId;
|
|
4267
|
+
hours;
|
|
4268
|
+
maxHours;
|
|
4269
|
+
floorKnown;
|
|
4270
|
+
name = "CalendarWindowRefusedError";
|
|
4271
|
+
constructor(deviceId, hours, maxHours, floorKnown) {
|
|
4272
|
+
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`);
|
|
4273
|
+
this.deviceId = deviceId;
|
|
4274
|
+
this.hours = hours;
|
|
4275
|
+
this.maxHours = maxHours;
|
|
4276
|
+
this.floorKnown = floorKnown;
|
|
4277
|
+
}
|
|
4278
|
+
};
|
|
4279
|
+
/**
|
|
4280
|
+
* Clamp `[fromMs, toMs)` to the hours that can hold footage and refuse it when
|
|
4281
|
+
* it is still wider than `maxHours`. `null` = nothing left to walk.
|
|
4282
|
+
*
|
|
4283
|
+
* The writer's current hour and the one it just left are never clamped away:
|
|
4284
|
+
* the ledger is write-behind, so a camera's first segments may be on disk (and
|
|
4285
|
+
* in the RAM index) a moment before the ledger holds their hour.
|
|
4286
|
+
*/
|
|
4287
|
+
function calendarWalk(deviceId, fromMs, toMs, nowMs, floor, maxHours = CALENDAR_MAX_WALK_HOURS) {
|
|
4288
|
+
const currentHour = Math.floor(nowMs / HOUR_MS$7) * HOUR_MS$7;
|
|
4289
|
+
const liveFloor = currentHour - HOUR_MS$7;
|
|
4290
|
+
const floorHour = floor.known ? Math.min(floor.oldestHourMs, liveFloor) : Number.NEGATIVE_INFINITY;
|
|
4291
|
+
const firstHourMs = Math.max(Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7, floorHour);
|
|
4292
|
+
const endMs = Math.min(toMs, currentHour + HOUR_MS$7);
|
|
4293
|
+
if (endMs <= firstHourMs || toMs <= fromMs) return null;
|
|
4294
|
+
const hours = Math.ceil((endMs - firstHourMs) / HOUR_MS$7);
|
|
4295
|
+
if (hours > maxHours) throw new CalendarWindowRefusedError(deviceId, hours, maxHours, floor.known);
|
|
4296
|
+
return {
|
|
4297
|
+
firstHourMs,
|
|
4298
|
+
endMs,
|
|
4299
|
+
hours
|
|
4300
|
+
};
|
|
4301
|
+
}
|
|
4302
|
+
/** Per-camera archive floors, one ledger fold per {@link ARCHIVE_FLOOR_TTL_MS}. */
|
|
4303
|
+
var ArchiveFloorReader = class {
|
|
4304
|
+
source;
|
|
4305
|
+
now;
|
|
4306
|
+
folded = null;
|
|
4307
|
+
constructor(source, now = () => Date.now()) {
|
|
4308
|
+
this.source = source;
|
|
4309
|
+
this.now = now;
|
|
4310
|
+
}
|
|
4311
|
+
floorOf(deviceId) {
|
|
4312
|
+
const ledger = this.source();
|
|
4313
|
+
if (ledger === null) return UNKNOWN_ARCHIVE_FLOOR;
|
|
4314
|
+
if (!ledger.isComplete()) {
|
|
4315
|
+
ledger.retryLoadIfUnanswered();
|
|
4316
|
+
return UNKNOWN_ARCHIVE_FLOOR;
|
|
4317
|
+
}
|
|
4318
|
+
const nowMs = this.now();
|
|
4319
|
+
if (this.folded === null || nowMs - this.folded.atMs >= 6e4) this.folded = {
|
|
4320
|
+
atMs: nowMs,
|
|
4321
|
+
floors: ledger.oldestHourByDevice()
|
|
4322
|
+
};
|
|
4323
|
+
const oldestHourMs = this.folded.floors.get(deviceId);
|
|
4324
|
+
return oldestHourMs === void 0 ? UNKNOWN_ARCHIVE_FLOOR : {
|
|
4325
|
+
known: true,
|
|
4326
|
+
oldestHourMs
|
|
4327
|
+
};
|
|
4328
|
+
}
|
|
4329
|
+
};
|
|
4330
|
+
//#endregion
|
|
4106
4331
|
//#region src/recorder/addon/calendar-index.ts
|
|
4107
4332
|
var DAY_MS$3 = 864e5;
|
|
4108
4333
|
var HOUR_MS$6 = 36e5;
|
|
@@ -4165,7 +4390,9 @@ function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
|
|
|
4165
4390
|
*
|
|
4166
4391
|
* Segments land every 8–12 s, so three seconds of staleness at the live edge
|
|
4167
4392
|
* cannot hide a whole one — and the live view owns "now" regardless. Past hours
|
|
4168
|
-
* stay cached
|
|
4393
|
+
* stay cached until eviction or the rung's bound (D674) drops them; only this
|
|
4394
|
+
* one expires — and it is read once more after it closes, so its last minutes
|
|
4395
|
+
* are never served from a stamp taken while it was still being written.
|
|
4169
4396
|
*/
|
|
4170
4397
|
var CURRENT_HOUR_TTL_MS = 3e3;
|
|
4171
4398
|
/**
|
|
@@ -4207,17 +4434,20 @@ async function mapBounded(items, limit, fn) {
|
|
|
4207
4434
|
await Promise.all(workers);
|
|
4208
4435
|
return out;
|
|
4209
4436
|
}
|
|
4437
|
+
/** Estimated bytes per cached UTC day on the day rung (map slot + a short
|
|
4438
|
+
* array of hour numbers). */
|
|
4439
|
+
var DAY_RUNG_APPROX_BYTES = 96;
|
|
4210
4440
|
var CalendarIndex = class {
|
|
4211
4441
|
deps;
|
|
4212
4442
|
/** `deviceId` → (UTC day ms → hours). Absent = never scanned. */
|
|
4213
4443
|
byDevice = /* @__PURE__ */ new Map();
|
|
4214
4444
|
/** Scans in flight, so a burst of queries costs one walk. */
|
|
4215
4445
|
inFlight = /* @__PURE__ */ new Map();
|
|
4216
|
-
/**
|
|
4217
|
-
|
|
4446
|
+
/** The exact rung: device → profile → hour → segments, bounded (D674). */
|
|
4447
|
+
hours;
|
|
4448
|
+
/** Hour scans in flight, keyed like the rung — so a burst costs one read. */
|
|
4218
4449
|
segsInFlight = /* @__PURE__ */ new Map();
|
|
4219
|
-
|
|
4220
|
-
segsReadAt = /* @__PURE__ */ new Map();
|
|
4450
|
+
segsInFlightCount = 0;
|
|
4221
4451
|
/** The profile availability reads from, resolved once per device. */
|
|
4222
4452
|
profileFor = /* @__PURE__ */ new Map();
|
|
4223
4453
|
profilesFor = /* @__PURE__ */ new Map();
|
|
@@ -4232,25 +4462,36 @@ var CalendarIndex = class {
|
|
|
4232
4462
|
profileInFlight = /* @__PURE__ */ new Map();
|
|
4233
4463
|
constructor(deps) {
|
|
4234
4464
|
this.deps = deps;
|
|
4465
|
+
this.hours = new CalendarHourCache(deps.hourBudget ?? DEFAULT_CALENDAR_HOUR_BUDGET);
|
|
4235
4466
|
}
|
|
4236
4467
|
/**
|
|
4237
|
-
* Entry counts for the recorder census.
|
|
4238
|
-
*
|
|
4239
|
-
* is the number that says whether this cache is where the heap went.
|
|
4468
|
+
* Entry counts and an estimated byte total for the recorder census. The
|
|
4469
|
+
* exact rung is bounded (D674); `evictedHours` says whether the bound bites.
|
|
4240
4470
|
*/
|
|
4241
4471
|
memoryCensus() {
|
|
4242
4472
|
let days = 0;
|
|
4243
4473
|
for (const perDay of this.byDevice.values()) days += perDay.size;
|
|
4244
|
-
|
|
4245
|
-
for (const segments of this.hourSegs.values()) hourSegments += segments.length;
|
|
4474
|
+
const hours = this.hours.census();
|
|
4246
4475
|
return {
|
|
4247
4476
|
devices: this.byDevice.size,
|
|
4248
4477
|
days,
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
inFlight: this.inFlight.size + this.
|
|
4478
|
+
...hours,
|
|
4479
|
+
approxBytes: hours.approxBytes + days * DAY_RUNG_APPROX_BYTES,
|
|
4480
|
+
inFlight: this.inFlight.size + this.segsInFlightCount
|
|
4252
4481
|
};
|
|
4253
4482
|
}
|
|
4483
|
+
nowMs() {
|
|
4484
|
+
return this.deps.now?.() ?? Date.now();
|
|
4485
|
+
}
|
|
4486
|
+
/**
|
|
4487
|
+
* The hours a walk over `[fromMs, toMs)` may visit — clamped to the archive
|
|
4488
|
+
* floor and the live edge, REFUSED above the cap (see `calendar-window.ts`).
|
|
4489
|
+
* Throws {@link CalendarWindowRefusedError}; the provider turns that into
|
|
4490
|
+
* `read: 'unreadable'` and logs it.
|
|
4491
|
+
*/
|
|
4492
|
+
walkOf(deviceId, fromMs, toMs) {
|
|
4493
|
+
return calendarWalk(deviceId, fromMs, toMs, this.nowMs(), this.deps.archiveFloor?.(deviceId) ?? UNKNOWN_ARCHIVE_FLOOR, this.deps.maxWalkHours ?? 8784);
|
|
4494
|
+
}
|
|
4254
4495
|
mapFor(deviceId) {
|
|
4255
4496
|
let m = this.byDevice.get(deviceId);
|
|
4256
4497
|
if (!m) {
|
|
@@ -4311,8 +4552,10 @@ var CalendarIndex = class {
|
|
|
4311
4552
|
* UTC day outside the range (23:30Z on the 28th is the 29th in CEST).
|
|
4312
4553
|
*/
|
|
4313
4554
|
async localDays(deviceId, fromMs, toMs, tzOffsetMinutes) {
|
|
4314
|
-
const
|
|
4315
|
-
|
|
4555
|
+
const walk = this.walkOf(deviceId, utcDayOf(fromMs) - DAY_MS$3, utcDayOf(toMs) + 2 * DAY_MS$3);
|
|
4556
|
+
if (walk === null) return [];
|
|
4557
|
+
const first = utcDayOf(walk.firstHourMs);
|
|
4558
|
+
const last = utcDayOf(walk.endMs - 1);
|
|
4316
4559
|
const wanted = [];
|
|
4317
4560
|
for (let day = first; day <= last; day += DAY_MS$3) wanted.push(day);
|
|
4318
4561
|
const perDay = await mapBounded(wanted, READ_CONCURRENCY, async (day) => ({
|
|
@@ -4337,16 +4580,12 @@ var CalendarIndex = class {
|
|
|
4337
4580
|
* rebuild lazily on the next question. */
|
|
4338
4581
|
invalidateAll() {
|
|
4339
4582
|
this.byDevice.clear();
|
|
4340
|
-
this.
|
|
4583
|
+
this.hours.clear();
|
|
4341
4584
|
}
|
|
4342
4585
|
invalidate(deviceId, atMs) {
|
|
4343
4586
|
const day = utcDayOf(atMs);
|
|
4344
4587
|
this.byDevice.get(deviceId)?.delete(day);
|
|
4345
|
-
|
|
4346
|
-
for (const key of this.hourSegs.keys()) {
|
|
4347
|
-
if (!key.startsWith(prefix)) continue;
|
|
4348
|
-
if (utcDayOf(Number(key.slice(key.lastIndexOf(":") + 1))) === day) this.hourSegs.delete(key);
|
|
4349
|
-
}
|
|
4588
|
+
this.hours.invalidateDay(deviceId, day);
|
|
4350
4589
|
}
|
|
4351
4590
|
/**
|
|
4352
4591
|
* Scan a span in the background so the first screen is already answered.
|
|
@@ -4404,28 +4643,28 @@ var CalendarIndex = class {
|
|
|
4404
4643
|
* retries instead of caching a gap.
|
|
4405
4644
|
*/
|
|
4406
4645
|
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
4407
|
-
if (!background) this.lastForegroundAt = this.
|
|
4646
|
+
if (!background) this.lastForegroundAt = this.nowMs();
|
|
4408
4647
|
const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
|
|
4409
4648
|
const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
|
|
4410
4649
|
if (fromIndex !== null && fromIndex !== void 0) return {
|
|
4411
4650
|
segments: fromIndex,
|
|
4412
4651
|
complete: true
|
|
4413
4652
|
};
|
|
4414
|
-
const
|
|
4415
|
-
const now = this.deps.now?.() ?? Date.now();
|
|
4653
|
+
const now = this.nowMs();
|
|
4416
4654
|
const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
|
|
4417
|
-
const cached = this.
|
|
4655
|
+
const cached = this.hours.get(deviceId, profile, hour);
|
|
4418
4656
|
if (cached !== void 0) {
|
|
4419
|
-
|
|
4657
|
+
const readAt = this.hours.readAtOf(deviceId, profile, hour);
|
|
4658
|
+
if (!isCurrentHour && readAt === void 0) return {
|
|
4420
4659
|
segments: cached,
|
|
4421
4660
|
complete: true
|
|
4422
4661
|
};
|
|
4423
|
-
if (now - (
|
|
4662
|
+
if (isCurrentHour && now - (readAt ?? 0) < CURRENT_HOUR_TTL_MS) return {
|
|
4424
4663
|
segments: cached,
|
|
4425
4664
|
complete: true
|
|
4426
4665
|
};
|
|
4427
4666
|
}
|
|
4428
|
-
const running = this.
|
|
4667
|
+
const running = this.inFlightScan(deviceId, profile, hour);
|
|
4429
4668
|
if (running) return running;
|
|
4430
4669
|
const scan = (async () => {
|
|
4431
4670
|
const read = this.deps.scanSegments ?? segmentsOfHour;
|
|
@@ -4449,21 +4688,46 @@ var CalendarIndex = class {
|
|
|
4449
4688
|
complete
|
|
4450
4689
|
};
|
|
4451
4690
|
})();
|
|
4452
|
-
this.
|
|
4691
|
+
this.trackScan(deviceId, profile, hour, scan);
|
|
4453
4692
|
try {
|
|
4454
4693
|
const { segments, complete } = await scan;
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
}
|
|
4694
|
+
const storedAt = this.nowMs();
|
|
4695
|
+
const liveHour = Math.floor(storedAt / HOUR_MS$6) * HOUR_MS$6;
|
|
4696
|
+
if (complete && hour <= liveHour) this.hours.set(deviceId, profile, hour, segments, hour === liveHour ? storedAt : null);
|
|
4459
4697
|
return {
|
|
4460
4698
|
segments,
|
|
4461
4699
|
complete
|
|
4462
4700
|
};
|
|
4463
4701
|
} finally {
|
|
4464
|
-
|
|
4702
|
+
this.untrackScan(deviceId, profile, hour, scan);
|
|
4465
4703
|
}
|
|
4466
4704
|
}
|
|
4705
|
+
inFlightScan(deviceId, profile, hour) {
|
|
4706
|
+
return this.segsInFlight.get(deviceId)?.get(profile)?.get(hour) ?? null;
|
|
4707
|
+
}
|
|
4708
|
+
trackScan(deviceId, profile, hour, scan) {
|
|
4709
|
+
let profiles = this.segsInFlight.get(deviceId);
|
|
4710
|
+
if (profiles === void 0) {
|
|
4711
|
+
profiles = /* @__PURE__ */ new Map();
|
|
4712
|
+
this.segsInFlight.set(deviceId, profiles);
|
|
4713
|
+
}
|
|
4714
|
+
let hours = profiles.get(profile);
|
|
4715
|
+
if (hours === void 0) {
|
|
4716
|
+
hours = /* @__PURE__ */ new Map();
|
|
4717
|
+
profiles.set(profile, hours);
|
|
4718
|
+
}
|
|
4719
|
+
hours.set(hour, scan);
|
|
4720
|
+
this.segsInFlightCount += 1;
|
|
4721
|
+
}
|
|
4722
|
+
untrackScan(deviceId, profile, hour, scan) {
|
|
4723
|
+
const profiles = this.segsInFlight.get(deviceId);
|
|
4724
|
+
const hours = profiles?.get(profile);
|
|
4725
|
+
if (profiles === void 0 || hours === void 0 || hours.get(hour) !== scan) return;
|
|
4726
|
+
hours.delete(hour);
|
|
4727
|
+
this.segsInFlightCount -= 1;
|
|
4728
|
+
if (hours.size === 0) profiles.delete(profile);
|
|
4729
|
+
if (profiles.size === 0) this.segsInFlight.delete(deviceId);
|
|
4730
|
+
}
|
|
4467
4731
|
/**
|
|
4468
4732
|
* Exact availability ranges for a UTC day — what the progressive timeline
|
|
4469
4733
|
* draws.
|
|
@@ -4491,9 +4755,10 @@ var CalendarIndex = class {
|
|
|
4491
4755
|
*/
|
|
4492
4756
|
async rangesIn(deviceId, profile, fromMs, toMs) {
|
|
4493
4757
|
if (toMs <= fromMs) return [];
|
|
4494
|
-
const
|
|
4758
|
+
const walk = this.walkOf(deviceId, fromMs, toMs);
|
|
4759
|
+
if (walk === null) return [];
|
|
4495
4760
|
const hours = [];
|
|
4496
|
-
for (let hour =
|
|
4761
|
+
for (let hour = walk.firstHourMs; hour < walk.endMs; hour += HOUR_MS$6) hours.push(hour);
|
|
4497
4762
|
const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
|
|
4498
4763
|
const ranges = [];
|
|
4499
4764
|
for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
|
|
@@ -4599,6 +4864,115 @@ var CalendarIndex = class {
|
|
|
4599
4864
|
}
|
|
4600
4865
|
};
|
|
4601
4866
|
//#endregion
|
|
4867
|
+
//#region src/recorder/addon/device-storage-usage.ts
|
|
4868
|
+
/**
|
|
4869
|
+
* Gap between the per-camera sum and the per-volume total that is worth
|
|
4870
|
+
* saying out loud, as a share of the volume total.
|
|
4871
|
+
*
|
|
4872
|
+
* The two are aggregations of the SAME hour rows over the same location set —
|
|
4873
|
+
* one folded per camera, one summed in SQL — so in a healthy recorder they
|
|
4874
|
+
* agree to the byte. A few tenths of a percent is a write landing between the
|
|
4875
|
+
* two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
|
|
4876
|
+
* rebuilt somewhere new.
|
|
4877
|
+
*/
|
|
4878
|
+
var USAGE_MISMATCH_SHARE = .02;
|
|
4879
|
+
/** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
|
|
4880
|
+
* warn on a single in-flight segment. */
|
|
4881
|
+
var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
|
|
4882
|
+
/**
|
|
4883
|
+
* One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
|
|
4884
|
+
* first (deviceId breaks a tie, so two consecutive calls compare).
|
|
4885
|
+
*
|
|
4886
|
+
* A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
|
|
4887
|
+
* 'unavailable'`, and a line saying so. Falling back to the index would answer
|
|
4888
|
+
* the same question with the wrong quantity and look exactly like a correct
|
|
4889
|
+
* answer, which is how this shipped in the first place.
|
|
4890
|
+
*/
|
|
4891
|
+
function buildDeviceUsage(input) {
|
|
4892
|
+
if (input.archive === null) {
|
|
4893
|
+
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(",") } });
|
|
4894
|
+
return {
|
|
4895
|
+
devices: [],
|
|
4896
|
+
totalUsedBytes: 0,
|
|
4897
|
+
read: "unavailable"
|
|
4898
|
+
};
|
|
4899
|
+
}
|
|
4900
|
+
const devices = [];
|
|
4901
|
+
let totalUsedBytes = 0;
|
|
4902
|
+
for (const [deviceId, archived] of input.archive) {
|
|
4903
|
+
const resident = input.resident(deviceId);
|
|
4904
|
+
totalUsedBytes += archived.bytes;
|
|
4905
|
+
devices.push({
|
|
4906
|
+
deviceId,
|
|
4907
|
+
usedBytes: archived.bytes,
|
|
4908
|
+
oldestMs: archived.oldestMs,
|
|
4909
|
+
segments: archived.segments,
|
|
4910
|
+
residentBytes: resident.bytes,
|
|
4911
|
+
residentOldestMs: resident.oldestMs
|
|
4912
|
+
});
|
|
4913
|
+
}
|
|
4914
|
+
return {
|
|
4915
|
+
devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
|
|
4916
|
+
totalUsedBytes,
|
|
4917
|
+
read: "archive"
|
|
4918
|
+
};
|
|
4919
|
+
}
|
|
4920
|
+
/**
|
|
4921
|
+
* Say it when the per-camera sum and the per-volume total disagree.
|
|
4922
|
+
*
|
|
4923
|
+
* The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
|
|
4924
|
+
* the same rows counted two ways, so a gap means one of the two reads saw a
|
|
4925
|
+
* fraction of the archive — and the whole point of D418 is that a page which
|
|
4926
|
+
* shows both without comparing them lets a 97% gap read as a fact about the
|
|
4927
|
+
* disks.
|
|
4928
|
+
*/
|
|
4929
|
+
function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
|
|
4930
|
+
if (read !== "archive") return;
|
|
4931
|
+
if (volumeBytes === 0) return;
|
|
4932
|
+
const gap = Math.abs(volumeBytes - deviceBytes);
|
|
4933
|
+
if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
|
|
4934
|
+
if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
|
|
4935
|
+
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: {
|
|
4936
|
+
deviceBytes,
|
|
4937
|
+
volumeBytes,
|
|
4938
|
+
gap
|
|
4939
|
+
} });
|
|
4940
|
+
}
|
|
4941
|
+
/**
|
|
4942
|
+
* The per-camera archive rows, or `null` — a REFUSAL — when they would not be
|
|
4943
|
+
* the archive (D674, review C1).
|
|
4944
|
+
*
|
|
4945
|
+
* The recorder registers its providers before `onHubReachable` loads the
|
|
4946
|
+
* ledger, and a failed load used to last the runner's life. In both windows the
|
|
4947
|
+
* mirror holds only the rows written since boot, and `usageByDevice` folded
|
|
4948
|
+
* them into a confident `devicesRead: 'archive'` answer — which the retention
|
|
4949
|
+
* floor reads, so every follow-recordings camera was swept on a floor that was
|
|
4950
|
+
* too young. Now:
|
|
4951
|
+
*
|
|
4952
|
+
* - no ledger, or a load that has not answered (including mid-load) ⇒ `null`,
|
|
4953
|
+
* and an unanswered load is retried (bounded, never awaited);
|
|
4954
|
+
* - no resolved location yet ⇒ `null`: an empty id set folds to "no camera
|
|
4955
|
+
* has footage", which is not what an unresolved location means.
|
|
4956
|
+
*/
|
|
4957
|
+
function archiveDeviceUsageOf(ledger, locationIds) {
|
|
4958
|
+
if (ledger === null) return null;
|
|
4959
|
+
if (!ledger.hasLoaded()) {
|
|
4960
|
+
ledger.retryLoadIfUnanswered();
|
|
4961
|
+
return null;
|
|
4962
|
+
}
|
|
4963
|
+
if (locationIds.size === 0) return null;
|
|
4964
|
+
return ledger.usageByDevice(locationIds);
|
|
4965
|
+
}
|
|
4966
|
+
/**
|
|
4967
|
+
* Is an `archive` answer WHOLE — may a camera ABSENT from it be read as "no
|
|
4968
|
+
* footage"? Only when the ledger loaded without hitting its row cap and at
|
|
4969
|
+
* least one location resolved. The storage-usage response carries this as
|
|
4970
|
+
* `complete` (D674, review M2).
|
|
4971
|
+
*/
|
|
4972
|
+
function archiveUsageComplete(ledger, locationIds) {
|
|
4973
|
+
return ledger !== null && ledger.isComplete() && locationIds.size > 0;
|
|
4974
|
+
}
|
|
4975
|
+
//#endregion
|
|
4602
4976
|
//#region src/recorder/addon/config-store.ts
|
|
4603
4977
|
/**
|
|
4604
4978
|
* Per-device recording-config persistence for the recorder addon.
|
|
@@ -7883,81 +8257,6 @@ function buildRecordingDeviceSchema() {
|
|
|
7883
8257
|
}]
|
|
7884
8258
|
}] };
|
|
7885
8259
|
}
|
|
7886
|
-
//#endregion
|
|
7887
|
-
//#region src/recorder/addon/device-storage-usage.ts
|
|
7888
|
-
/**
|
|
7889
|
-
* Gap between the per-camera sum and the per-volume total that is worth
|
|
7890
|
-
* saying out loud, as a share of the volume total.
|
|
7891
|
-
*
|
|
7892
|
-
* The two are aggregations of the SAME hour rows over the same location set —
|
|
7893
|
-
* one folded per camera, one summed in SQL — so in a healthy recorder they
|
|
7894
|
-
* agree to the byte. A few tenths of a percent is a write landing between the
|
|
7895
|
-
* two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
|
|
7896
|
-
* rebuilt somewhere new.
|
|
7897
|
-
*/
|
|
7898
|
-
var USAGE_MISMATCH_SHARE = .02;
|
|
7899
|
-
/** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
|
|
7900
|
-
* warn on a single in-flight segment. */
|
|
7901
|
-
var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
|
|
7902
|
-
/**
|
|
7903
|
-
* One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
|
|
7904
|
-
* first (deviceId breaks a tie, so two consecutive calls compare).
|
|
7905
|
-
*
|
|
7906
|
-
* A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
|
|
7907
|
-
* 'unavailable'`, and a line saying so. Falling back to the index would answer
|
|
7908
|
-
* the same question with the wrong quantity and look exactly like a correct
|
|
7909
|
-
* answer, which is how this shipped in the first place.
|
|
7910
|
-
*/
|
|
7911
|
-
function buildDeviceUsage(input) {
|
|
7912
|
-
if (input.archive === null) {
|
|
7913
|
-
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(",") } });
|
|
7914
|
-
return {
|
|
7915
|
-
devices: [],
|
|
7916
|
-
totalUsedBytes: 0,
|
|
7917
|
-
read: "unavailable"
|
|
7918
|
-
};
|
|
7919
|
-
}
|
|
7920
|
-
const devices = [];
|
|
7921
|
-
let totalUsedBytes = 0;
|
|
7922
|
-
for (const [deviceId, archived] of input.archive) {
|
|
7923
|
-
const resident = input.resident(deviceId);
|
|
7924
|
-
totalUsedBytes += archived.bytes;
|
|
7925
|
-
devices.push({
|
|
7926
|
-
deviceId,
|
|
7927
|
-
usedBytes: archived.bytes,
|
|
7928
|
-
oldestMs: archived.oldestMs,
|
|
7929
|
-
segments: archived.segments,
|
|
7930
|
-
residentBytes: resident.bytes,
|
|
7931
|
-
residentOldestMs: resident.oldestMs
|
|
7932
|
-
});
|
|
7933
|
-
}
|
|
7934
|
-
return {
|
|
7935
|
-
devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
|
|
7936
|
-
totalUsedBytes,
|
|
7937
|
-
read: "archive"
|
|
7938
|
-
};
|
|
7939
|
-
}
|
|
7940
|
-
/**
|
|
7941
|
-
* Say it when the per-camera sum and the per-volume total disagree.
|
|
7942
|
-
*
|
|
7943
|
-
* The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
|
|
7944
|
-
* the same rows counted two ways, so a gap means one of the two reads saw a
|
|
7945
|
-
* fraction of the archive — and the whole point of D418 is that a page which
|
|
7946
|
-
* shows both without comparing them lets a 97% gap read as a fact about the
|
|
7947
|
-
* disks.
|
|
7948
|
-
*/
|
|
7949
|
-
function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
|
|
7950
|
-
if (read !== "archive") return;
|
|
7951
|
-
if (volumeBytes === 0) return;
|
|
7952
|
-
const gap = Math.abs(volumeBytes - deviceBytes);
|
|
7953
|
-
if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
|
|
7954
|
-
if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
|
|
7955
|
-
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: {
|
|
7956
|
-
deviceBytes,
|
|
7957
|
-
volumeBytes,
|
|
7958
|
-
gap
|
|
7959
|
-
} });
|
|
7960
|
-
}
|
|
7961
8260
|
/** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
|
|
7962
8261
|
* in `placement-store.ts`; kept local so this module stays dependency-free. */
|
|
7963
8262
|
function sourceKey(source) {
|
|
@@ -8590,6 +8889,7 @@ function buildRecordingProvider(deps) {
|
|
|
8590
8889
|
totalUsedBytes,
|
|
8591
8890
|
devices: [...devices],
|
|
8592
8891
|
devicesRead: deviceUsage.read,
|
|
8892
|
+
complete: deviceUsage.read === "archive" && (deps.archiveUsageComplete?.(locationIds) ?? false),
|
|
8593
8893
|
locations: locationUsages
|
|
8594
8894
|
};
|
|
8595
8895
|
},
|
|
@@ -9814,6 +10114,10 @@ var CENSUS_FIELDS = [
|
|
|
9814
10114
|
];
|
|
9815
10115
|
/** Summary rows folded into the mirror between yields. */
|
|
9816
10116
|
var LOAD_CHUNK = 5e3;
|
|
10117
|
+
/** Minimum spacing between ledger load attempts after a failure (D674). */
|
|
10118
|
+
var LEDGER_LOAD_RETRY_MS = 6e4;
|
|
10119
|
+
/** Spacing of the retries after the fast ones — slower, never stopped. */
|
|
10120
|
+
var LEDGER_LOAD_SLOW_RETRY_MS = 10 * 6e4;
|
|
9817
10121
|
/** The four scalars an accounting answer is made of, asked in one statement. */
|
|
9818
10122
|
var ACCOUNTING_FIELDS = [
|
|
9819
10123
|
{
|
|
@@ -10009,6 +10313,22 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
10009
10313
|
* the recorder happens to be placing that hour on.
|
|
10010
10314
|
*/
|
|
10011
10315
|
refusedWriterHours = /* @__PURE__ */ new Set();
|
|
10316
|
+
/**
|
|
10317
|
+
* Has a {@link load} ANSWERED — its query returned AND every row it returned
|
|
10318
|
+
* is staged? Before that the mirror is "not read yet" (or, mid-load, a
|
|
10319
|
+
* fraction), never "the archive is empty". `load` swallows its failure, so a
|
|
10320
|
+
* reader that must tell the two apart (the calendar's window floor, the
|
|
10321
|
+
* storage-usage rows a retention floor is read from — D674) asks here.
|
|
10322
|
+
*/
|
|
10323
|
+
loaded = false;
|
|
10324
|
+
/** The last successful load returned {@link rowCap} rows: hours past the cap
|
|
10325
|
+
* are UNKNOWN, so the mirror is an answer but not a WHOLE one. */
|
|
10326
|
+
loadHitRowCap = false;
|
|
10327
|
+
/** Load attempts made, boot included — the retry is bounded (D674). */
|
|
10328
|
+
loadAttempts = 0;
|
|
10329
|
+
lastLoadAttemptMs = null;
|
|
10330
|
+
loadInFlight = null;
|
|
10331
|
+
loadRetriesExhaustedReported = false;
|
|
10012
10332
|
/** Entry counts of the in-process maps, for the recorder census. */
|
|
10013
10333
|
memoryCensus() {
|
|
10014
10334
|
let accountedPaths = 0;
|
|
@@ -10057,17 +10377,70 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
10057
10377
|
* seed the index (D523).
|
|
10058
10378
|
*/
|
|
10059
10379
|
async load() {
|
|
10380
|
+
this.loadInFlight ??= this.loadOnce().finally(() => {
|
|
10381
|
+
this.loadInFlight = null;
|
|
10382
|
+
});
|
|
10383
|
+
return this.loadInFlight;
|
|
10384
|
+
}
|
|
10385
|
+
/**
|
|
10386
|
+
* Retry a load that has not answered — rate-limited, logged, never abandoned
|
|
10387
|
+
* (D674).
|
|
10388
|
+
*
|
|
10389
|
+
* A failed boot load used to last the runner's life: every reader then saw
|
|
10390
|
+
* only the rows written since boot, and `getStorageUsage` labelled them the
|
|
10391
|
+
* archive. Readers call this and never wait on it (it is fire-and-forget at
|
|
10392
|
+
* every call site); at most one attempt per {@link LEDGER_LOAD_RETRY_MS} for
|
|
10393
|
+
* the first {@link LEDGER_LOAD_MAX_ATTEMPTS}, then one per
|
|
10394
|
+
* {@link LEDGER_LOAD_SLOW_RETRY_MS}; the slowdown is said once.
|
|
10395
|
+
*/
|
|
10396
|
+
retryLoadIfUnanswered() {
|
|
10397
|
+
if (this.loaded || this.loadInFlight !== null) return;
|
|
10398
|
+
const slow = this.loadAttempts >= 10;
|
|
10399
|
+
if (slow && !this.loadRetriesExhaustedReported) {
|
|
10400
|
+
this.loadRetriesExhaustedReported = true;
|
|
10401
|
+
this.logger.warn("recorder: segment-hour ledger still not loaded — retrying at the slow interval; archive-wide answers stay REFUSED until it loads", { meta: {
|
|
10402
|
+
attempts: this.loadAttempts,
|
|
10403
|
+
slowRetryMs: LEDGER_LOAD_SLOW_RETRY_MS
|
|
10404
|
+
} });
|
|
10405
|
+
}
|
|
10406
|
+
const spacing = slow ? LEDGER_LOAD_SLOW_RETRY_MS : LEDGER_LOAD_RETRY_MS;
|
|
10407
|
+
const now = this.now();
|
|
10408
|
+
if (this.lastLoadAttemptMs !== null && now - this.lastLoadAttemptMs < spacing) return;
|
|
10409
|
+
if (!slow) this.logger.info("recorder: retrying the segment-hour ledger load", { meta: {
|
|
10410
|
+
attempt: this.loadAttempts + 1,
|
|
10411
|
+
fastAttempts: 10
|
|
10412
|
+
} });
|
|
10413
|
+
this.load();
|
|
10414
|
+
}
|
|
10415
|
+
/**
|
|
10416
|
+
* Is the mirror a WHOLE answer about the archive: loaded, and not cut at the
|
|
10417
|
+
* row cap? The storage-usage rows and the calendar floor are only facts about
|
|
10418
|
+
* the archive when this is true.
|
|
10419
|
+
*/
|
|
10420
|
+
isComplete() {
|
|
10421
|
+
return this.loaded && !this.loadHitRowCap;
|
|
10422
|
+
}
|
|
10423
|
+
async loadOnce() {
|
|
10060
10424
|
const startedMs = this.now();
|
|
10425
|
+
this.loadAttempts += 1;
|
|
10426
|
+
this.lastLoadAttemptMs = startedMs;
|
|
10061
10427
|
let records;
|
|
10062
10428
|
try {
|
|
10063
10429
|
records = await this.store.query.query({
|
|
10064
10430
|
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
10065
|
-
filter: {
|
|
10431
|
+
filter: {
|
|
10432
|
+
limit: this.rowCap,
|
|
10433
|
+
orderBy: {
|
|
10434
|
+
field: "hourStartMs",
|
|
10435
|
+
direction: "desc"
|
|
10436
|
+
}
|
|
10437
|
+
}
|
|
10066
10438
|
});
|
|
10067
10439
|
} catch (err) {
|
|
10068
10440
|
this.logger.warn("recorder: segment-hour ledger load failed — keeping the hours already held", { meta: {
|
|
10069
10441
|
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
10070
10442
|
held: this.ledger.size,
|
|
10443
|
+
attempt: this.loadAttempts,
|
|
10071
10444
|
error: String(err)
|
|
10072
10445
|
} });
|
|
10073
10446
|
return this.ledger.snapshot();
|
|
@@ -10083,10 +10456,12 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
10083
10456
|
skipped += 1;
|
|
10084
10457
|
continue;
|
|
10085
10458
|
}
|
|
10086
|
-
this.ledger.stage(row);
|
|
10459
|
+
if (!this.ledger.has(row.key)) this.ledger.stage(row);
|
|
10087
10460
|
}
|
|
10088
10461
|
if (end < records.length) await this.yieldBetween();
|
|
10089
10462
|
}
|
|
10463
|
+
this.loaded = true;
|
|
10464
|
+
this.loadHitRowCap = records.length >= this.rowCap;
|
|
10090
10465
|
if (skipped > 0) this.logger.warn("recorder: segment-hour rows skipped as malformed — they gate NOTHING", { meta: {
|
|
10091
10466
|
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
10092
10467
|
skipped
|
|
@@ -10108,6 +10483,31 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
10108
10483
|
snapshot() {
|
|
10109
10484
|
return this.ledger.snapshot();
|
|
10110
10485
|
}
|
|
10486
|
+
/** Whether a {@link load} has answered and staged every row it returned —
|
|
10487
|
+
* see {@link loaded}. {@link isComplete} is the stronger question. */
|
|
10488
|
+
hasLoaded() {
|
|
10489
|
+
return this.loaded;
|
|
10490
|
+
}
|
|
10491
|
+
/**
|
|
10492
|
+
* The OLDEST hour the archive holds per camera, across every profile and
|
|
10493
|
+
* every location — the floor below which no hour can hold footage (D674).
|
|
10494
|
+
*
|
|
10495
|
+
* Every row counts, including one whose segments were all dropped and that
|
|
10496
|
+
* the prune has not yet forgotten: an older floor only walks a few more
|
|
10497
|
+
* hours, while a floor that is too YOUNG hides footage. One fold, no I/O —
|
|
10498
|
+
* the same shape as {@link devicesWithFootage}. Meaningful only after
|
|
10499
|
+
* {@link isComplete}; even then a camera ABSENT from the map is unknown to
|
|
10500
|
+
* the calendar (an hour not adopted yet is on disk and absent here), never a
|
|
10501
|
+
* known "no footage" (D674).
|
|
10502
|
+
*/
|
|
10503
|
+
oldestHourByDevice() {
|
|
10504
|
+
const out = /* @__PURE__ */ new Map();
|
|
10505
|
+
for (const row of this.ledger.snapshot()) {
|
|
10506
|
+
const held = out.get(row.deviceId);
|
|
10507
|
+
if (held === void 0 || row.hourStartMs < held) out.set(row.deviceId, row.hourStartMs);
|
|
10508
|
+
}
|
|
10509
|
+
return out;
|
|
10510
|
+
}
|
|
10111
10511
|
/**
|
|
10112
10512
|
* Which profiles each of these cameras has footage for IN THE ARCHIVE.
|
|
10113
10513
|
*
|
|
@@ -20493,12 +20893,17 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
20493
20893
|
/** LAZY: a field initialiser runs before `initialize()` assigns `ctx`, and
|
|
20494
20894
|
* reading `this.ctx` there throws — the same trap that bit the playback tier. */
|
|
20495
20895
|
get calendar() {
|
|
20496
|
-
this.calendarInstance ??=
|
|
20896
|
+
this.calendarInstance ??= this.buildCalendar();
|
|
20897
|
+
return this.calendarInstance;
|
|
20898
|
+
}
|
|
20899
|
+
buildCalendar() {
|
|
20900
|
+
const floors = new ArchiveFloorReader(() => this.segmentHours);
|
|
20901
|
+
return new CalendarIndex({
|
|
20497
20902
|
roots: () => this.resolvedLocations.map((l) => l.root),
|
|
20498
20903
|
logger: this.ctx.logger,
|
|
20499
|
-
indexHour: (deviceId, profile, hourStartMs) => hourSegmentsFromIndex(this.index, deviceId, profile, hourStartMs)
|
|
20904
|
+
indexHour: (deviceId, profile, hourStartMs) => hourSegmentsFromIndex(this.index, deviceId, profile, hourStartMs),
|
|
20905
|
+
archiveFloor: (deviceId) => floors.floorOf(deviceId)
|
|
20500
20906
|
});
|
|
20501
|
-
return this.calendarInstance;
|
|
20502
20907
|
}
|
|
20503
20908
|
segmentStore = null;
|
|
20504
20909
|
/** Sync-sample tables captured at finalize, served by the directory so a
|
|
@@ -20963,7 +21368,8 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
20963
21368
|
refreshLocations: () => this.refreshLocations(),
|
|
20964
21369
|
capacity: (root) => this.locationCapacity(root),
|
|
20965
21370
|
archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
|
|
20966
|
-
archiveDeviceUsage: (locationIds) => this.segmentHours
|
|
21371
|
+
archiveDeviceUsage: (locationIds) => archiveDeviceUsageOf(this.segmentHours, locationIds),
|
|
21372
|
+
archiveUsageComplete: (locationIds) => archiveUsageComplete(this.segmentHours, locationIds),
|
|
20967
21373
|
archive: () => this.segmentHours,
|
|
20968
21374
|
ledgerWalk: this.ledgerWalkReconciler(),
|
|
20969
21375
|
hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
|