@camstack/addon-pipeline 1.2.89 → 1.2.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/recorder/index.js +546 -16
- package/dist/recorder/index.mjs +546 -16
- package/package.json +1 -1
package/dist/recorder/index.js
CHANGED
|
@@ -507,14 +507,14 @@ function parseSegmentPath(relPath) {
|
|
|
507
507
|
//#region src/recorder/recording-index.ts
|
|
508
508
|
/** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
|
|
509
509
|
* already gives us — so "have I walked this?" costs one Set lookup. */
|
|
510
|
-
var HOUR_MS$
|
|
510
|
+
var HOUR_MS$5 = 36e5;
|
|
511
511
|
/** A device walked end-to-end. Cheaper and clearer than inserting every hour of
|
|
512
512
|
* the retention window into the set. */
|
|
513
513
|
var ALL_HOURS = Symbol("all-hours");
|
|
514
514
|
/** The hour buckets a half-open span touches. */
|
|
515
515
|
function* hourBuckets(fromMs, toMs) {
|
|
516
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
517
|
-
for (let h = first; h < toMs; h += HOUR_MS$
|
|
516
|
+
const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
|
|
517
|
+
for (let h = first; h < toMs; h += HOUR_MS$5) yield h;
|
|
518
518
|
}
|
|
519
519
|
var RecordingIndex = class {
|
|
520
520
|
byDevice = /* @__PURE__ */ new Map();
|
|
@@ -630,7 +630,7 @@ var RecordingIndex = class {
|
|
|
630
630
|
});
|
|
631
631
|
}
|
|
632
632
|
this.invalidateSorted(deviceId);
|
|
633
|
-
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$
|
|
633
|
+
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$5);
|
|
634
634
|
}
|
|
635
635
|
/** Insert/replace one finalized segment (idempotent by path). */
|
|
636
636
|
addSegment(s) {
|
|
@@ -1371,6 +1371,8 @@ var SegmentStore = class SegmentStore {
|
|
|
1371
1371
|
if (removed.length > 0) {
|
|
1372
1372
|
this.deps.index.removeSegments(removed);
|
|
1373
1373
|
this.notifyEvicted(rows);
|
|
1374
|
+
const gone = new Set(removed);
|
|
1375
|
+
this.deps.onDeindexed?.(rows.filter((r) => gone.has(r.path)));
|
|
1374
1376
|
}
|
|
1375
1377
|
await this.pruneEmptyAncestors(location, [...byBucket.keys()]);
|
|
1376
1378
|
this.deps.logger.info("recorder: eviction round complete", { meta: {
|
|
@@ -1936,7 +1938,7 @@ async function profileHasFootage(root, deviceId, profile) {
|
|
|
1936
1938
|
//#endregion
|
|
1937
1939
|
//#region src/recorder/addon/calendar-index.ts
|
|
1938
1940
|
var DAY_MS$3 = 864e5;
|
|
1939
|
-
var HOUR_MS$
|
|
1941
|
+
var HOUR_MS$4 = 36e5;
|
|
1940
1942
|
/**
|
|
1941
1943
|
* UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
|
|
1942
1944
|
*
|
|
@@ -2109,7 +2111,7 @@ var CalendarIndex = class {
|
|
|
2109
2111
|
}));
|
|
2110
2112
|
const days = /* @__PURE__ */ new Set();
|
|
2111
2113
|
for (const { day, hours } of perDay) for (const h of hours) {
|
|
2112
|
-
const at = day + h * HOUR_MS$
|
|
2114
|
+
const at = day + h * HOUR_MS$4;
|
|
2113
2115
|
if (at < fromMs || at >= toMs) continue;
|
|
2114
2116
|
days.add(localDayStart(at, tzOffsetMinutes));
|
|
2115
2117
|
}
|
|
@@ -2171,7 +2173,7 @@ var CalendarIndex = class {
|
|
|
2171
2173
|
const day = utcDayOf(utcDayMs);
|
|
2172
2174
|
for (let h = 0; h < 24; h++) {
|
|
2173
2175
|
await this.awaitQuiet();
|
|
2174
|
-
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$
|
|
2176
|
+
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$4, true);
|
|
2175
2177
|
}
|
|
2176
2178
|
}
|
|
2177
2179
|
/**
|
|
@@ -2192,10 +2194,10 @@ var CalendarIndex = class {
|
|
|
2192
2194
|
*/
|
|
2193
2195
|
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
2194
2196
|
if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
|
|
2195
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
2197
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$4) * HOUR_MS$4;
|
|
2196
2198
|
const key = `${deviceId}:${profile}:${hour}`;
|
|
2197
2199
|
const now = this.deps.now?.() ?? Date.now();
|
|
2198
|
-
const isCurrentHour = hour === Math.floor(now / HOUR_MS$
|
|
2200
|
+
const isCurrentHour = hour === Math.floor(now / HOUR_MS$4) * HOUR_MS$4;
|
|
2199
2201
|
const cached = this.hourSegs.get(key);
|
|
2200
2202
|
if (cached !== void 0) {
|
|
2201
2203
|
if (!isCurrentHour) return {
|
|
@@ -2259,7 +2261,7 @@ var CalendarIndex = class {
|
|
|
2259
2261
|
*/
|
|
2260
2262
|
async dayRanges(deviceId, profile, utcDayMs) {
|
|
2261
2263
|
const day = utcDayOf(utcDayMs);
|
|
2262
|
-
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$
|
|
2264
|
+
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$4), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
|
|
2263
2265
|
}
|
|
2264
2266
|
/**
|
|
2265
2267
|
* Warm the days either side of the one being viewed, in the BACKGROUND.
|
|
@@ -2437,7 +2439,7 @@ function activeModeForConfig(config) {
|
|
|
2437
2439
|
}
|
|
2438
2440
|
//#endregion
|
|
2439
2441
|
//#region src/recorder/addon/directory-handler.ts
|
|
2440
|
-
var HOUR_MS$
|
|
2442
|
+
var HOUR_MS$3 = 36e5;
|
|
2441
2443
|
/** A window wider than this is refused: it is a client bug, and answering it
|
|
2442
2444
|
* would walk the archive on the read path — the thing this replaces. */
|
|
2443
2445
|
var MAX_WINDOW_MS = 32 * 864e5;
|
|
@@ -2484,9 +2486,9 @@ function withDirectoryRoute(deps, next) {
|
|
|
2484
2486
|
return;
|
|
2485
2487
|
}
|
|
2486
2488
|
const rows = [];
|
|
2487
|
-
const firstHour = Math.floor(fromMs / HOUR_MS$
|
|
2489
|
+
const firstHour = Math.floor(fromMs / HOUR_MS$3) * HOUR_MS$3;
|
|
2488
2490
|
let hydrated = true;
|
|
2489
|
-
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$
|
|
2491
|
+
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$3) {
|
|
2490
2492
|
const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
|
|
2491
2493
|
if (!complete) hydrated = false;
|
|
2492
2494
|
for (const s of segments) {
|
|
@@ -5186,7 +5188,7 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
|
5186
5188
|
const p2 = (n) => String(n).padStart(2, "0");
|
|
5187
5189
|
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
5188
5190
|
}
|
|
5189
|
-
var HOUR_MS$
|
|
5191
|
+
var HOUR_MS$2 = 36e5;
|
|
5190
5192
|
/**
|
|
5191
5193
|
* Hydrate ONLY the hours a window spans, instead of walking the archive.
|
|
5192
5194
|
*
|
|
@@ -5221,8 +5223,8 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
5221
5223
|
});
|
|
5222
5224
|
continue;
|
|
5223
5225
|
}
|
|
5224
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
5225
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
5226
|
+
const first = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
|
|
5227
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$2) for (const profile of profiles) {
|
|
5226
5228
|
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
5227
5229
|
let files;
|
|
5228
5230
|
try {
|
|
@@ -7040,6 +7042,503 @@ function makeBrokerCall(handle) {
|
|
|
7040
7042
|
return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
|
|
7041
7043
|
}
|
|
7042
7044
|
//#endregion
|
|
7045
|
+
//#region src/durable/durable-ledger.ts
|
|
7046
|
+
/** Default reseed cap — every current consumer's row set is installation-bounded. */
|
|
7047
|
+
var DEFAULT_LOAD_LIMIT = 1e5;
|
|
7048
|
+
var DurableLedger = class DurableLedger {
|
|
7049
|
+
mirror = /* @__PURE__ */ new Map();
|
|
7050
|
+
spec;
|
|
7051
|
+
store;
|
|
7052
|
+
logger;
|
|
7053
|
+
constructor(deps) {
|
|
7054
|
+
this.spec = deps.spec;
|
|
7055
|
+
this.store = deps.store;
|
|
7056
|
+
this.logger = deps.logger;
|
|
7057
|
+
}
|
|
7058
|
+
/**
|
|
7059
|
+
* Register the collection. MUST run at boot, before any read or write: the
|
|
7060
|
+
* SQLite backend answers 412 for an undeclared collection and takes the whole
|
|
7061
|
+
* runner down with it (the addon-ai boot-crash lesson).
|
|
7062
|
+
*/
|
|
7063
|
+
static declare(store, spec) {
|
|
7064
|
+
return store.declareCollection.mutate({
|
|
7065
|
+
collection: spec.collection,
|
|
7066
|
+
columns: [...spec.columns],
|
|
7067
|
+
...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
|
|
7068
|
+
});
|
|
7069
|
+
}
|
|
7070
|
+
declare() {
|
|
7071
|
+
return DurableLedger.declare(this.store, this.spec);
|
|
7072
|
+
}
|
|
7073
|
+
/** The collection this ledger owns — for the caller's own log lines. */
|
|
7074
|
+
get collection() {
|
|
7075
|
+
return this.spec.collection;
|
|
7076
|
+
}
|
|
7077
|
+
/**
|
|
7078
|
+
* Boot reseed. Replaces the mirror with what the store holds and returns the
|
|
7079
|
+
* rows, so a caller that must hydrate something else (a watcher, a registry)
|
|
7080
|
+
* gets them without a second read.
|
|
7081
|
+
*
|
|
7082
|
+
* **A failure returns what is already mirrored** rather than clearing it —
|
|
7083
|
+
* see contract rule 2. The count is worth logging out loud at the call site:
|
|
7084
|
+
* "loaded 0" after a container recreate is the one line that explains a
|
|
7085
|
+
* silent flood.
|
|
7086
|
+
*/
|
|
7087
|
+
async load() {
|
|
7088
|
+
try {
|
|
7089
|
+
const records = await this.store.query.query({
|
|
7090
|
+
collection: this.spec.collection,
|
|
7091
|
+
filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
|
|
7092
|
+
});
|
|
7093
|
+
const next = /* @__PURE__ */ new Map();
|
|
7094
|
+
let skipped = 0;
|
|
7095
|
+
for (const record of records) {
|
|
7096
|
+
const row = this.spec.fromRecord(record.id, record.data);
|
|
7097
|
+
if (row === null) {
|
|
7098
|
+
skipped += 1;
|
|
7099
|
+
continue;
|
|
7100
|
+
}
|
|
7101
|
+
next.set(this.spec.keyOf(row), row);
|
|
7102
|
+
}
|
|
7103
|
+
this.mirror.clear();
|
|
7104
|
+
for (const [key, row] of next) this.mirror.set(key, row);
|
|
7105
|
+
if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
|
|
7106
|
+
collection: this.spec.collection,
|
|
7107
|
+
skipped
|
|
7108
|
+
} });
|
|
7109
|
+
return [...this.mirror.values()];
|
|
7110
|
+
} catch (err) {
|
|
7111
|
+
this.logger.warn("durable load failed — keeping the state already in memory", { meta: {
|
|
7112
|
+
collection: this.spec.collection,
|
|
7113
|
+
error: String(err),
|
|
7114
|
+
held: this.mirror.size
|
|
7115
|
+
} });
|
|
7116
|
+
return [...this.mirror.values()];
|
|
7117
|
+
}
|
|
7118
|
+
}
|
|
7119
|
+
/** Every mirrored row, insertion-ordered. */
|
|
7120
|
+
snapshot() {
|
|
7121
|
+
return [...this.mirror.values()];
|
|
7122
|
+
}
|
|
7123
|
+
/** The row currently held for a key, if any. Pure RAM — never I/O. */
|
|
7124
|
+
get(key) {
|
|
7125
|
+
return this.mirror.get(key);
|
|
7126
|
+
}
|
|
7127
|
+
has(key) {
|
|
7128
|
+
return this.mirror.has(key);
|
|
7129
|
+
}
|
|
7130
|
+
get size() {
|
|
7131
|
+
return this.mirror.size;
|
|
7132
|
+
}
|
|
7133
|
+
/**
|
|
7134
|
+
* Judge ONE observation against what the ledger already accepted, and advance
|
|
7135
|
+
* it.
|
|
7136
|
+
*
|
|
7137
|
+
* Synchronous on purpose: the verdict is a function of the in-RAM mirror
|
|
7138
|
+
* alone, so a decision can never be gated on an I/O that might fail (D49).
|
|
7139
|
+
* The durable write is kicked off behind it and its failure changes no
|
|
7140
|
+
* verdict.
|
|
7141
|
+
*
|
|
7142
|
+
* `no-flip` leaves the held row UNTOUCHED — including any timestamp it
|
|
7143
|
+
* carries, which therefore means "when this key last CHANGED", not "when it
|
|
7144
|
+
* last spoke". That is the timestamp anyone reading the table wants.
|
|
7145
|
+
*/
|
|
7146
|
+
observe(row) {
|
|
7147
|
+
const equalFact = this.spec.equalFact;
|
|
7148
|
+
if (equalFact === void 0) throw new Error(`DurableLedger(${this.spec.collection}): observe() requires the spec to declare equalFact`);
|
|
7149
|
+
const key = this.spec.keyOf(row);
|
|
7150
|
+
const held = this.mirror.get(key);
|
|
7151
|
+
if (held !== void 0 && equalFact(held, row)) return "no-flip";
|
|
7152
|
+
this.mirror.set(key, row);
|
|
7153
|
+
this.persist(row);
|
|
7154
|
+
return held === void 0 ? "seeded" : "flip";
|
|
7155
|
+
}
|
|
7156
|
+
/**
|
|
7157
|
+
* Upsert a row without a verdict — the write path for a ledger whose owner
|
|
7158
|
+
* has already decided the value changed.
|
|
7159
|
+
*
|
|
7160
|
+
* The order of the mirror advance and the durable write is the spec's
|
|
7161
|
+
* {@link DurableWriteMode}, not the call site's: two call sites that
|
|
7162
|
+
* disagreed about it would be two different durability guarantees on one
|
|
7163
|
+
* collection.
|
|
7164
|
+
*/
|
|
7165
|
+
async put(row) {
|
|
7166
|
+
const key = this.spec.keyOf(row);
|
|
7167
|
+
if (this.spec.writeMode === "write-behind") {
|
|
7168
|
+
this.mirror.set(key, row);
|
|
7169
|
+
await this.persist(row);
|
|
7170
|
+
return;
|
|
7171
|
+
}
|
|
7172
|
+
await this.store.set.mutate({
|
|
7173
|
+
collection: this.spec.collection,
|
|
7174
|
+
key,
|
|
7175
|
+
value: this.spec.toValue(row)
|
|
7176
|
+
});
|
|
7177
|
+
this.mirror.set(key, row);
|
|
7178
|
+
}
|
|
7179
|
+
/**
|
|
7180
|
+
* Advance the mirror WITHOUT persisting, for an owner that deliberately
|
|
7181
|
+
* coalesces its writes.
|
|
7182
|
+
*
|
|
7183
|
+
* The stationary registry is the reason this exists: a parked car is
|
|
7184
|
+
* re-confirmed on every processed frame (5–30 Hz), and persisting each
|
|
7185
|
+
* confirmation would offer thousands of commits a day to the checkpoint
|
|
7186
|
+
* lottery (D96) to maintain a handful of rows. It stages the advance and
|
|
7187
|
+
* flushes on its 5-minute sweep — 288 writes a day instead of ~10⁶.
|
|
7188
|
+
*
|
|
7189
|
+
* **The cost is stated, not hidden**: a staged value that is never flushed
|
|
7190
|
+
* is lost on a crash. An owner may only stage a field whose staleness its
|
|
7191
|
+
* own TTL absorbs. Anything that GATES work must go through {@link put} or
|
|
7192
|
+
* {@link observe}.
|
|
7193
|
+
*/
|
|
7194
|
+
stage(row) {
|
|
7195
|
+
this.mirror.set(this.spec.keyOf(row), row);
|
|
7196
|
+
}
|
|
7197
|
+
/**
|
|
7198
|
+
* Drop a key from the MIRROR only — the durable row survives.
|
|
7199
|
+
*
|
|
7200
|
+
* What a scope-unbind needs: this process stops holding the value, and a
|
|
7201
|
+
* rebind reloads it from the store. Deliberately distinct from
|
|
7202
|
+
* {@link forget}, which deletes; conflating the two is how an unbind turns
|
|
7203
|
+
* into a wipe.
|
|
7204
|
+
*/
|
|
7205
|
+
evict(key) {
|
|
7206
|
+
this.mirror.delete(key);
|
|
7207
|
+
}
|
|
7208
|
+
/**
|
|
7209
|
+
* Drop one key, mirror and row. Best-effort on the durable half: a failed
|
|
7210
|
+
* delete leaves a row that the next load will re-mirror, which is a stale
|
|
7211
|
+
* value rather than a lost one.
|
|
7212
|
+
*/
|
|
7213
|
+
async forget(key) {
|
|
7214
|
+
this.mirror.delete(key);
|
|
7215
|
+
try {
|
|
7216
|
+
await this.store.delete.mutate({
|
|
7217
|
+
collection: this.spec.collection,
|
|
7218
|
+
key
|
|
7219
|
+
});
|
|
7220
|
+
} catch (err) {
|
|
7221
|
+
this.logger.debug("durable delete failed", { meta: {
|
|
7222
|
+
collection: this.spec.collection,
|
|
7223
|
+
key,
|
|
7224
|
+
error: String(err)
|
|
7225
|
+
} });
|
|
7226
|
+
}
|
|
7227
|
+
}
|
|
7228
|
+
/**
|
|
7229
|
+
* Drop every mirrored key NOT in `activeKeys`. Returns how many rows went.
|
|
7230
|
+
*
|
|
7231
|
+
* **The caller must hold an AUTHORITATIVE active set.** A prune driven by a
|
|
7232
|
+
* fallible read is work destroyed on an error (D49/D130) — that is why this
|
|
7233
|
+
* is a method a feature opts into rather than a policy the primitive runs.
|
|
7234
|
+
* Best-effort per row: a failed delete keeps the key (retried next prune)
|
|
7235
|
+
* rather than aborting the sweep.
|
|
7236
|
+
*/
|
|
7237
|
+
async pruneExcept(activeKeys) {
|
|
7238
|
+
let pruned = 0;
|
|
7239
|
+
for (const key of [...this.mirror.keys()]) {
|
|
7240
|
+
if (activeKeys.has(key)) continue;
|
|
7241
|
+
try {
|
|
7242
|
+
await this.store.delete.mutate({
|
|
7243
|
+
collection: this.spec.collection,
|
|
7244
|
+
key
|
|
7245
|
+
});
|
|
7246
|
+
this.mirror.delete(key);
|
|
7247
|
+
pruned += 1;
|
|
7248
|
+
} catch (err) {
|
|
7249
|
+
this.logger.debug("durable prune delete failed", { meta: {
|
|
7250
|
+
collection: this.spec.collection,
|
|
7251
|
+
key,
|
|
7252
|
+
error: String(err)
|
|
7253
|
+
} });
|
|
7254
|
+
}
|
|
7255
|
+
}
|
|
7256
|
+
return pruned;
|
|
7257
|
+
}
|
|
7258
|
+
/**
|
|
7259
|
+
* Write-behind durable upsert. Best-effort and logged, never thrown at the
|
|
7260
|
+
* decision path: the mirror already holds the truth for this process, and the
|
|
7261
|
+
* worst a lost write can do is one silent re-seed after the next restart.
|
|
7262
|
+
*/
|
|
7263
|
+
async persist(row) {
|
|
7264
|
+
const deviceId = this.spec.deviceIdOf?.(row);
|
|
7265
|
+
try {
|
|
7266
|
+
await this.store.set.mutate({
|
|
7267
|
+
collection: this.spec.collection,
|
|
7268
|
+
key: this.spec.keyOf(row),
|
|
7269
|
+
value: this.spec.toValue(row)
|
|
7270
|
+
});
|
|
7271
|
+
} catch (err) {
|
|
7272
|
+
this.logger.warn("durable persist failed — this key may re-seed on boot", {
|
|
7273
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
7274
|
+
meta: {
|
|
7275
|
+
collection: this.spec.collection,
|
|
7276
|
+
key: this.spec.keyOf(row),
|
|
7277
|
+
error: String(err)
|
|
7278
|
+
}
|
|
7279
|
+
});
|
|
7280
|
+
}
|
|
7281
|
+
}
|
|
7282
|
+
};
|
|
7283
|
+
//#endregion
|
|
7284
|
+
//#region src/recorder/addon/segment-hour-ledger.ts
|
|
7285
|
+
var HOUR_MS$1 = 36e5;
|
|
7286
|
+
/**
|
|
7287
|
+
* @durable class=mirror owner=recorder
|
|
7288
|
+
* write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
|
|
7289
|
+
* retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
|
|
7290
|
+
*/
|
|
7291
|
+
var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
|
|
7292
|
+
var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
7293
|
+
{
|
|
7294
|
+
name: "key",
|
|
7295
|
+
type: "TEXT",
|
|
7296
|
+
primaryKey: true,
|
|
7297
|
+
notNull: true
|
|
7298
|
+
},
|
|
7299
|
+
{
|
|
7300
|
+
name: "deviceId",
|
|
7301
|
+
type: "INTEGER",
|
|
7302
|
+
notNull: true
|
|
7303
|
+
},
|
|
7304
|
+
{
|
|
7305
|
+
name: "profile",
|
|
7306
|
+
type: "TEXT",
|
|
7307
|
+
notNull: true
|
|
7308
|
+
},
|
|
7309
|
+
{
|
|
7310
|
+
name: "locationId",
|
|
7311
|
+
type: "TEXT",
|
|
7312
|
+
notNull: true
|
|
7313
|
+
},
|
|
7314
|
+
{
|
|
7315
|
+
name: "hourStartMs",
|
|
7316
|
+
type: "INTEGER",
|
|
7317
|
+
notNull: true
|
|
7318
|
+
},
|
|
7319
|
+
{
|
|
7320
|
+
name: "paths",
|
|
7321
|
+
type: "TEXT",
|
|
7322
|
+
notNull: true
|
|
7323
|
+
}
|
|
7324
|
+
];
|
|
7325
|
+
var RECORDING_SEGMENT_HOURS_INDEXES = [{
|
|
7326
|
+
name: "idx_recorder_segment_hours_device",
|
|
7327
|
+
columns: ["deviceId"]
|
|
7328
|
+
}, {
|
|
7329
|
+
name: "idx_recorder_segment_hours_hour",
|
|
7330
|
+
columns: ["hourStartMs"]
|
|
7331
|
+
}];
|
|
7332
|
+
function hourStartMs(startMs) {
|
|
7333
|
+
return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
|
|
7334
|
+
}
|
|
7335
|
+
function segmentHourKey(deviceId, profile, locationId, hour) {
|
|
7336
|
+
return `${deviceId}|${profile}|${locationId}|${hour}`;
|
|
7337
|
+
}
|
|
7338
|
+
function hourOf(row) {
|
|
7339
|
+
const hour = hourStartMs(row.startMs);
|
|
7340
|
+
return {
|
|
7341
|
+
hourStartMs: hour,
|
|
7342
|
+
key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
|
|
7343
|
+
};
|
|
7344
|
+
}
|
|
7345
|
+
function uniquePaths(paths) {
|
|
7346
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7347
|
+
const out = [];
|
|
7348
|
+
for (const p of paths) {
|
|
7349
|
+
if (seen.has(p)) continue;
|
|
7350
|
+
seen.add(p);
|
|
7351
|
+
out.push(p);
|
|
7352
|
+
}
|
|
7353
|
+
return out;
|
|
7354
|
+
}
|
|
7355
|
+
function samePaths(a, b) {
|
|
7356
|
+
if (a.length !== b.length) return false;
|
|
7357
|
+
const set = new Set(a);
|
|
7358
|
+
for (const p of b) if (!set.has(p)) return false;
|
|
7359
|
+
return true;
|
|
7360
|
+
}
|
|
7361
|
+
function rowToValue(row) {
|
|
7362
|
+
return {
|
|
7363
|
+
deviceId: row.deviceId,
|
|
7364
|
+
profile: row.profile,
|
|
7365
|
+
locationId: row.locationId,
|
|
7366
|
+
hourStartMs: row.hourStartMs,
|
|
7367
|
+
paths: JSON.stringify(row.paths)
|
|
7368
|
+
};
|
|
7369
|
+
}
|
|
7370
|
+
function recordToRow(key, data) {
|
|
7371
|
+
const deviceId = Number(data["deviceId"]);
|
|
7372
|
+
const profile = data["profile"];
|
|
7373
|
+
const locationId = data["locationId"];
|
|
7374
|
+
const hour = Number(data["hourStartMs"]);
|
|
7375
|
+
let raw = data["paths"];
|
|
7376
|
+
if (typeof raw === "string") try {
|
|
7377
|
+
raw = JSON.parse(raw);
|
|
7378
|
+
} catch {
|
|
7379
|
+
return null;
|
|
7380
|
+
}
|
|
7381
|
+
if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour) || !Array.isArray(raw) || !raw.every((p) => typeof p === "string" && p.length > 0)) return null;
|
|
7382
|
+
return {
|
|
7383
|
+
key,
|
|
7384
|
+
deviceId,
|
|
7385
|
+
profile,
|
|
7386
|
+
locationId,
|
|
7387
|
+
hourStartMs: hour,
|
|
7388
|
+
paths: raw
|
|
7389
|
+
};
|
|
7390
|
+
}
|
|
7391
|
+
var SPEC = {
|
|
7392
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
7393
|
+
columns: RECORDING_SEGMENT_HOURS_COLUMNS,
|
|
7394
|
+
indexes: RECORDING_SEGMENT_HOURS_INDEXES,
|
|
7395
|
+
writeMode: "write-behind",
|
|
7396
|
+
loadLimit: 1e5,
|
|
7397
|
+
keyOf: (row) => row.key,
|
|
7398
|
+
toValue: rowToValue,
|
|
7399
|
+
fromRecord: recordToRow,
|
|
7400
|
+
deviceIdOf: (row) => row.deviceId
|
|
7401
|
+
};
|
|
7402
|
+
var SegmentHourLedger = class {
|
|
7403
|
+
ledger;
|
|
7404
|
+
logger;
|
|
7405
|
+
constructor(deps) {
|
|
7406
|
+
this.logger = deps.logger;
|
|
7407
|
+
this.ledger = new DurableLedger({
|
|
7408
|
+
spec: SPEC,
|
|
7409
|
+
store: deps.store,
|
|
7410
|
+
logger: deps.logger
|
|
7411
|
+
});
|
|
7412
|
+
}
|
|
7413
|
+
static declare(store) {
|
|
7414
|
+
return DurableLedger.declare(store, SPEC);
|
|
7415
|
+
}
|
|
7416
|
+
declare() {
|
|
7417
|
+
return this.ledger.declare();
|
|
7418
|
+
}
|
|
7419
|
+
async load() {
|
|
7420
|
+
const rows = await this.ledger.load();
|
|
7421
|
+
this.logger.info("recorder: segment-hour ledger loaded", { meta: {
|
|
7422
|
+
hours: rows.length,
|
|
7423
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION
|
|
7424
|
+
} });
|
|
7425
|
+
return rows;
|
|
7426
|
+
}
|
|
7427
|
+
snapshot() {
|
|
7428
|
+
return this.ledger.snapshot();
|
|
7429
|
+
}
|
|
7430
|
+
/**
|
|
7431
|
+
* Seed {@link RecordingIndex} from the mirrored hour rows.
|
|
7432
|
+
*
|
|
7433
|
+
* Hours present in the ledger become `hydrated` so a seek in those windows
|
|
7434
|
+
* does not pay for a disk walk. Hours the ledger never held stay `unknown`
|
|
7435
|
+
* — that is the fail-toward-walk direction.
|
|
7436
|
+
*/
|
|
7437
|
+
hydrateIndex(index) {
|
|
7438
|
+
for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
7439
|
+
}
|
|
7440
|
+
/** Write-behind: a just-finalized segment joins its hour row. */
|
|
7441
|
+
async recordSegment(row) {
|
|
7442
|
+
const { key, hourStartMs: hour } = hourOf(row);
|
|
7443
|
+
const held = this.ledger.get(key);
|
|
7444
|
+
if (held !== void 0 && held.paths.includes(row.path)) return;
|
|
7445
|
+
await this.ledger.put({
|
|
7446
|
+
key,
|
|
7447
|
+
deviceId: row.deviceId,
|
|
7448
|
+
profile: row.profile,
|
|
7449
|
+
locationId: row.locationId,
|
|
7450
|
+
hourStartMs: hour,
|
|
7451
|
+
paths: held === void 0 ? [row.path] : [...held.paths, row.path]
|
|
7452
|
+
});
|
|
7453
|
+
}
|
|
7454
|
+
/**
|
|
7455
|
+
* Eviction confirmed these paths are gone from disk. Drop them; forget the
|
|
7456
|
+
* hour when it holds nothing.
|
|
7457
|
+
*/
|
|
7458
|
+
async dropSegments(rows) {
|
|
7459
|
+
const doomed = /* @__PURE__ */ new Map();
|
|
7460
|
+
for (const row of rows) {
|
|
7461
|
+
const { key } = hourOf(row);
|
|
7462
|
+
let set = doomed.get(key);
|
|
7463
|
+
if (!set) {
|
|
7464
|
+
set = /* @__PURE__ */ new Set();
|
|
7465
|
+
doomed.set(key, set);
|
|
7466
|
+
}
|
|
7467
|
+
set.add(row.path);
|
|
7468
|
+
}
|
|
7469
|
+
for (const [key, paths] of doomed) {
|
|
7470
|
+
const held = this.ledger.get(key);
|
|
7471
|
+
if (held === void 0) continue;
|
|
7472
|
+
const next = held.paths.filter((p) => !paths.has(p));
|
|
7473
|
+
if (next.length === 0) {
|
|
7474
|
+
await this.ledger.forget(key);
|
|
7475
|
+
continue;
|
|
7476
|
+
}
|
|
7477
|
+
if (next.length === held.paths.length) continue;
|
|
7478
|
+
await this.ledger.put({
|
|
7479
|
+
...held,
|
|
7480
|
+
paths: next
|
|
7481
|
+
});
|
|
7482
|
+
}
|
|
7483
|
+
}
|
|
7484
|
+
/**
|
|
7485
|
+
* After a disk walk of `deviceIds`, make the ledger match the index — with
|
|
7486
|
+
* two D148 exceptions:
|
|
7487
|
+
*
|
|
7488
|
+
* - an hour the walk did not see is dropped ONLY if it is in the past;
|
|
7489
|
+
* the current hour may still hold a live writer whose file the readdir
|
|
7490
|
+
* raced;
|
|
7491
|
+
* - the current hour UNIONs ledger paths with index paths rather than
|
|
7492
|
+
* replacing, for the same race.
|
|
7493
|
+
*
|
|
7494
|
+
* Devices not in `deviceIds` are left untouched (a partial walk must not
|
|
7495
|
+
* prune cameras it did not look at).
|
|
7496
|
+
*/
|
|
7497
|
+
async reconcileFromIndex(index, deviceIds, nowMs) {
|
|
7498
|
+
const walked = new Set(deviceIds);
|
|
7499
|
+
const currentHour = hourStartMs(nowMs);
|
|
7500
|
+
const fromIndex = /* @__PURE__ */ new Map();
|
|
7501
|
+
for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
|
|
7502
|
+
const { key, hourStartMs: hour } = hourOf(seg);
|
|
7503
|
+
const existing = fromIndex.get(key);
|
|
7504
|
+
if (existing === void 0) fromIndex.set(key, {
|
|
7505
|
+
key,
|
|
7506
|
+
deviceId: seg.deviceId,
|
|
7507
|
+
profile: seg.profile,
|
|
7508
|
+
locationId: seg.locationId,
|
|
7509
|
+
hourStartMs: hour,
|
|
7510
|
+
paths: [seg.path]
|
|
7511
|
+
});
|
|
7512
|
+
else fromIndex.set(key, {
|
|
7513
|
+
...existing,
|
|
7514
|
+
paths: [...existing.paths, seg.path]
|
|
7515
|
+
});
|
|
7516
|
+
}
|
|
7517
|
+
for (const row of this.ledger.snapshot()) {
|
|
7518
|
+
if (!walked.has(row.deviceId)) continue;
|
|
7519
|
+
const disk = fromIndex.get(row.key);
|
|
7520
|
+
if (disk === void 0) {
|
|
7521
|
+
if (row.hourStartMs === currentHour) continue;
|
|
7522
|
+
await this.ledger.forget(row.key);
|
|
7523
|
+
continue;
|
|
7524
|
+
}
|
|
7525
|
+
if (row.hourStartMs === currentHour) {
|
|
7526
|
+
const union = uniquePaths([...disk.paths, ...row.paths]);
|
|
7527
|
+
if (!samePaths(union, row.paths)) await this.ledger.put({
|
|
7528
|
+
...row,
|
|
7529
|
+
paths: union
|
|
7530
|
+
});
|
|
7531
|
+
continue;
|
|
7532
|
+
}
|
|
7533
|
+
if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
|
|
7534
|
+
}
|
|
7535
|
+
for (const [key, disk] of fromIndex) {
|
|
7536
|
+
if (this.ledger.has(key)) continue;
|
|
7537
|
+
await this.ledger.put(disk);
|
|
7538
|
+
}
|
|
7539
|
+
}
|
|
7540
|
+
};
|
|
7541
|
+
//#endregion
|
|
7043
7542
|
//#region src/recorder/addon/recording-export-provider.ts
|
|
7044
7543
|
/** Content type of every rendered export — the engine only ever writes MP4. */
|
|
7045
7544
|
var EXPORT_CONTENT_TYPE = "video/mp4";
|
|
@@ -7861,6 +8360,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
7861
8360
|
isRecordingNode = true;
|
|
7862
8361
|
index = new RecordingIndex();
|
|
7863
8362
|
/**
|
|
8363
|
+
* Durable hour-directory copy of {@link index}. Null on an inert
|
|
8364
|
+
* (non-recording) node. Boot reseeds the RAM index from this; the
|
|
8365
|
+
* deferred archive walk reconciles it against disk.
|
|
8366
|
+
*/
|
|
8367
|
+
segmentHours = null;
|
|
8368
|
+
/**
|
|
7864
8369
|
* The calendar rungs — which days, which hours, and a day's exact ranges.
|
|
7865
8370
|
*
|
|
7866
8371
|
* Separate from `RecordingIndex` on purpose: that one is the SEGMENT model
|
|
@@ -7932,6 +8437,16 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
7932
8437
|
} });
|
|
7933
8438
|
return { providers: [] };
|
|
7934
8439
|
}
|
|
8440
|
+
this.segmentHours = new SegmentHourLedger({
|
|
8441
|
+
store: this.ctx.api.settingsStore,
|
|
8442
|
+
logger: this.ctx.logger
|
|
8443
|
+
});
|
|
8444
|
+
try {
|
|
8445
|
+
await this.segmentHours.declare();
|
|
8446
|
+
} catch (err) {
|
|
8447
|
+
this.ctx.logger.warn("recorder: segment-hour ledger declare failed — index stays RAM-only this boot", { meta: { error: require_dist.errMsg(err) } });
|
|
8448
|
+
this.segmentHours = null;
|
|
8449
|
+
}
|
|
7935
8450
|
const ingestOwner = await this.resolveIngestOwner();
|
|
7936
8451
|
const consumerHostname = this.nodeId === ingestOwner.ownerNodeId ? void 0 : ingestOwner.reachableHost ?? resolveRecordingHubHostname(this.config.recordingHubHostname, process.env["CAMSTACK_HUB_URL"]);
|
|
7937
8452
|
if (this.nodeId !== ingestOwner.ownerNodeId) {
|
|
@@ -7968,7 +8483,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
7968
8483
|
onEvicted: (deviceId, atMs) => {
|
|
7969
8484
|
this.calendar.invalidate(deviceId, atMs);
|
|
7970
8485
|
},
|
|
8486
|
+
onDeindexed: (rows) => {
|
|
8487
|
+
this.segmentHours?.dropSegments(rows);
|
|
8488
|
+
},
|
|
7971
8489
|
onIndexed: (row, absPath) => {
|
|
8490
|
+
this.segmentHours?.recordSegment(row);
|
|
7972
8491
|
if (!isMfraTableServable(row.startMs, Date.now())) return;
|
|
7973
8492
|
readMfraTable(absPath, row.bytes).then((table) => {
|
|
7974
8493
|
this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
|
|
@@ -8345,6 +8864,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
8345
8864
|
this.controller?.onTrigger(deviceId, "motion", atMs);
|
|
8346
8865
|
}
|
|
8347
8866
|
});
|
|
8867
|
+
try {
|
|
8868
|
+
await this.segmentHours?.load();
|
|
8869
|
+
this.segmentHours?.hydrateIndex(this.index);
|
|
8870
|
+
} catch (err) {
|
|
8871
|
+
this.ctx.logger.warn("recorder: segment-hour ledger load failed — falling through to the archive walk", { meta: { error: require_dist.errMsg(err) } });
|
|
8872
|
+
}
|
|
8348
8873
|
let configs;
|
|
8349
8874
|
try {
|
|
8350
8875
|
configs = await readDeviceConfigs(this.configStore());
|
|
@@ -8832,6 +9357,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
8832
9357
|
locationCount: this.resolvedLocations.length,
|
|
8833
9358
|
ms: Date.now() - hydrateStartedMs
|
|
8834
9359
|
} });
|
|
9360
|
+
try {
|
|
9361
|
+
await this.segmentHours?.reconcileFromIndex(this.index, deviceIds, Date.now());
|
|
9362
|
+
} catch (err) {
|
|
9363
|
+
this.ctx.logger.warn("recorder: segment-hour ledger reconcile failed — walk remains the authority", { meta: { error: require_dist.errMsg(err) } });
|
|
9364
|
+
}
|
|
8835
9365
|
const loggedDomains = /* @__PURE__ */ new Set();
|
|
8836
9366
|
for (const loc of this.resolvedLocations) {
|
|
8837
9367
|
const domain = this.locationDomain(loc.id);
|
package/dist/recorder/index.mjs
CHANGED
|
@@ -506,14 +506,14 @@ function parseSegmentPath(relPath) {
|
|
|
506
506
|
//#region src/recorder/recording-index.ts
|
|
507
507
|
/** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
|
|
508
508
|
* already gives us — so "have I walked this?" costs one Set lookup. */
|
|
509
|
-
var HOUR_MS$
|
|
509
|
+
var HOUR_MS$5 = 36e5;
|
|
510
510
|
/** A device walked end-to-end. Cheaper and clearer than inserting every hour of
|
|
511
511
|
* the retention window into the set. */
|
|
512
512
|
var ALL_HOURS = Symbol("all-hours");
|
|
513
513
|
/** The hour buckets a half-open span touches. */
|
|
514
514
|
function* hourBuckets(fromMs, toMs) {
|
|
515
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
516
|
-
for (let h = first; h < toMs; h += HOUR_MS$
|
|
515
|
+
const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
|
|
516
|
+
for (let h = first; h < toMs; h += HOUR_MS$5) yield h;
|
|
517
517
|
}
|
|
518
518
|
var RecordingIndex = class {
|
|
519
519
|
byDevice = /* @__PURE__ */ new Map();
|
|
@@ -629,7 +629,7 @@ var RecordingIndex = class {
|
|
|
629
629
|
});
|
|
630
630
|
}
|
|
631
631
|
this.invalidateSorted(deviceId);
|
|
632
|
-
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$
|
|
632
|
+
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$5);
|
|
633
633
|
}
|
|
634
634
|
/** Insert/replace one finalized segment (idempotent by path). */
|
|
635
635
|
addSegment(s) {
|
|
@@ -1370,6 +1370,8 @@ var SegmentStore = class SegmentStore {
|
|
|
1370
1370
|
if (removed.length > 0) {
|
|
1371
1371
|
this.deps.index.removeSegments(removed);
|
|
1372
1372
|
this.notifyEvicted(rows);
|
|
1373
|
+
const gone = new Set(removed);
|
|
1374
|
+
this.deps.onDeindexed?.(rows.filter((r) => gone.has(r.path)));
|
|
1373
1375
|
}
|
|
1374
1376
|
await this.pruneEmptyAncestors(location, [...byBucket.keys()]);
|
|
1375
1377
|
this.deps.logger.info("recorder: eviction round complete", { meta: {
|
|
@@ -1935,7 +1937,7 @@ async function profileHasFootage(root, deviceId, profile) {
|
|
|
1935
1937
|
//#endregion
|
|
1936
1938
|
//#region src/recorder/addon/calendar-index.ts
|
|
1937
1939
|
var DAY_MS$3 = 864e5;
|
|
1938
|
-
var HOUR_MS$
|
|
1940
|
+
var HOUR_MS$4 = 36e5;
|
|
1939
1941
|
/**
|
|
1940
1942
|
* UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
|
|
1941
1943
|
*
|
|
@@ -2108,7 +2110,7 @@ var CalendarIndex = class {
|
|
|
2108
2110
|
}));
|
|
2109
2111
|
const days = /* @__PURE__ */ new Set();
|
|
2110
2112
|
for (const { day, hours } of perDay) for (const h of hours) {
|
|
2111
|
-
const at = day + h * HOUR_MS$
|
|
2113
|
+
const at = day + h * HOUR_MS$4;
|
|
2112
2114
|
if (at < fromMs || at >= toMs) continue;
|
|
2113
2115
|
days.add(localDayStart(at, tzOffsetMinutes));
|
|
2114
2116
|
}
|
|
@@ -2170,7 +2172,7 @@ var CalendarIndex = class {
|
|
|
2170
2172
|
const day = utcDayOf(utcDayMs);
|
|
2171
2173
|
for (let h = 0; h < 24; h++) {
|
|
2172
2174
|
await this.awaitQuiet();
|
|
2173
|
-
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$
|
|
2175
|
+
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$4, true);
|
|
2174
2176
|
}
|
|
2175
2177
|
}
|
|
2176
2178
|
/**
|
|
@@ -2191,10 +2193,10 @@ var CalendarIndex = class {
|
|
|
2191
2193
|
*/
|
|
2192
2194
|
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
2193
2195
|
if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
|
|
2194
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
2196
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$4) * HOUR_MS$4;
|
|
2195
2197
|
const key = `${deviceId}:${profile}:${hour}`;
|
|
2196
2198
|
const now = this.deps.now?.() ?? Date.now();
|
|
2197
|
-
const isCurrentHour = hour === Math.floor(now / HOUR_MS$
|
|
2199
|
+
const isCurrentHour = hour === Math.floor(now / HOUR_MS$4) * HOUR_MS$4;
|
|
2198
2200
|
const cached = this.hourSegs.get(key);
|
|
2199
2201
|
if (cached !== void 0) {
|
|
2200
2202
|
if (!isCurrentHour) return {
|
|
@@ -2258,7 +2260,7 @@ var CalendarIndex = class {
|
|
|
2258
2260
|
*/
|
|
2259
2261
|
async dayRanges(deviceId, profile, utcDayMs) {
|
|
2260
2262
|
const day = utcDayOf(utcDayMs);
|
|
2261
|
-
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$
|
|
2263
|
+
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$4), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
|
|
2262
2264
|
}
|
|
2263
2265
|
/**
|
|
2264
2266
|
* Warm the days either side of the one being viewed, in the BACKGROUND.
|
|
@@ -2436,7 +2438,7 @@ function activeModeForConfig(config) {
|
|
|
2436
2438
|
}
|
|
2437
2439
|
//#endregion
|
|
2438
2440
|
//#region src/recorder/addon/directory-handler.ts
|
|
2439
|
-
var HOUR_MS$
|
|
2441
|
+
var HOUR_MS$3 = 36e5;
|
|
2440
2442
|
/** A window wider than this is refused: it is a client bug, and answering it
|
|
2441
2443
|
* would walk the archive on the read path — the thing this replaces. */
|
|
2442
2444
|
var MAX_WINDOW_MS = 32 * 864e5;
|
|
@@ -2483,9 +2485,9 @@ function withDirectoryRoute(deps, next) {
|
|
|
2483
2485
|
return;
|
|
2484
2486
|
}
|
|
2485
2487
|
const rows = [];
|
|
2486
|
-
const firstHour = Math.floor(fromMs / HOUR_MS$
|
|
2488
|
+
const firstHour = Math.floor(fromMs / HOUR_MS$3) * HOUR_MS$3;
|
|
2487
2489
|
let hydrated = true;
|
|
2488
|
-
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$
|
|
2490
|
+
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$3) {
|
|
2489
2491
|
const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
|
|
2490
2492
|
if (!complete) hydrated = false;
|
|
2491
2493
|
for (const s of segments) {
|
|
@@ -5185,7 +5187,7 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
|
5185
5187
|
const p2 = (n) => String(n).padStart(2, "0");
|
|
5186
5188
|
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
5187
5189
|
}
|
|
5188
|
-
var HOUR_MS$
|
|
5190
|
+
var HOUR_MS$2 = 36e5;
|
|
5189
5191
|
/**
|
|
5190
5192
|
* Hydrate ONLY the hours a window spans, instead of walking the archive.
|
|
5191
5193
|
*
|
|
@@ -5220,8 +5222,8 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
5220
5222
|
});
|
|
5221
5223
|
continue;
|
|
5222
5224
|
}
|
|
5223
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
5224
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
5225
|
+
const first = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
|
|
5226
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$2) for (const profile of profiles) {
|
|
5225
5227
|
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
5226
5228
|
let files;
|
|
5227
5229
|
try {
|
|
@@ -7039,6 +7041,503 @@ function makeBrokerCall(handle) {
|
|
|
7039
7041
|
return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
|
|
7040
7042
|
}
|
|
7041
7043
|
//#endregion
|
|
7044
|
+
//#region src/durable/durable-ledger.ts
|
|
7045
|
+
/** Default reseed cap — every current consumer's row set is installation-bounded. */
|
|
7046
|
+
var DEFAULT_LOAD_LIMIT = 1e5;
|
|
7047
|
+
var DurableLedger = class DurableLedger {
|
|
7048
|
+
mirror = /* @__PURE__ */ new Map();
|
|
7049
|
+
spec;
|
|
7050
|
+
store;
|
|
7051
|
+
logger;
|
|
7052
|
+
constructor(deps) {
|
|
7053
|
+
this.spec = deps.spec;
|
|
7054
|
+
this.store = deps.store;
|
|
7055
|
+
this.logger = deps.logger;
|
|
7056
|
+
}
|
|
7057
|
+
/**
|
|
7058
|
+
* Register the collection. MUST run at boot, before any read or write: the
|
|
7059
|
+
* SQLite backend answers 412 for an undeclared collection and takes the whole
|
|
7060
|
+
* runner down with it (the addon-ai boot-crash lesson).
|
|
7061
|
+
*/
|
|
7062
|
+
static declare(store, spec) {
|
|
7063
|
+
return store.declareCollection.mutate({
|
|
7064
|
+
collection: spec.collection,
|
|
7065
|
+
columns: [...spec.columns],
|
|
7066
|
+
...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
|
|
7067
|
+
});
|
|
7068
|
+
}
|
|
7069
|
+
declare() {
|
|
7070
|
+
return DurableLedger.declare(this.store, this.spec);
|
|
7071
|
+
}
|
|
7072
|
+
/** The collection this ledger owns — for the caller's own log lines. */
|
|
7073
|
+
get collection() {
|
|
7074
|
+
return this.spec.collection;
|
|
7075
|
+
}
|
|
7076
|
+
/**
|
|
7077
|
+
* Boot reseed. Replaces the mirror with what the store holds and returns the
|
|
7078
|
+
* rows, so a caller that must hydrate something else (a watcher, a registry)
|
|
7079
|
+
* gets them without a second read.
|
|
7080
|
+
*
|
|
7081
|
+
* **A failure returns what is already mirrored** rather than clearing it —
|
|
7082
|
+
* see contract rule 2. The count is worth logging out loud at the call site:
|
|
7083
|
+
* "loaded 0" after a container recreate is the one line that explains a
|
|
7084
|
+
* silent flood.
|
|
7085
|
+
*/
|
|
7086
|
+
async load() {
|
|
7087
|
+
try {
|
|
7088
|
+
const records = await this.store.query.query({
|
|
7089
|
+
collection: this.spec.collection,
|
|
7090
|
+
filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
|
|
7091
|
+
});
|
|
7092
|
+
const next = /* @__PURE__ */ new Map();
|
|
7093
|
+
let skipped = 0;
|
|
7094
|
+
for (const record of records) {
|
|
7095
|
+
const row = this.spec.fromRecord(record.id, record.data);
|
|
7096
|
+
if (row === null) {
|
|
7097
|
+
skipped += 1;
|
|
7098
|
+
continue;
|
|
7099
|
+
}
|
|
7100
|
+
next.set(this.spec.keyOf(row), row);
|
|
7101
|
+
}
|
|
7102
|
+
this.mirror.clear();
|
|
7103
|
+
for (const [key, row] of next) this.mirror.set(key, row);
|
|
7104
|
+
if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
|
|
7105
|
+
collection: this.spec.collection,
|
|
7106
|
+
skipped
|
|
7107
|
+
} });
|
|
7108
|
+
return [...this.mirror.values()];
|
|
7109
|
+
} catch (err) {
|
|
7110
|
+
this.logger.warn("durable load failed — keeping the state already in memory", { meta: {
|
|
7111
|
+
collection: this.spec.collection,
|
|
7112
|
+
error: String(err),
|
|
7113
|
+
held: this.mirror.size
|
|
7114
|
+
} });
|
|
7115
|
+
return [...this.mirror.values()];
|
|
7116
|
+
}
|
|
7117
|
+
}
|
|
7118
|
+
/** Every mirrored row, insertion-ordered. */
|
|
7119
|
+
snapshot() {
|
|
7120
|
+
return [...this.mirror.values()];
|
|
7121
|
+
}
|
|
7122
|
+
/** The row currently held for a key, if any. Pure RAM — never I/O. */
|
|
7123
|
+
get(key) {
|
|
7124
|
+
return this.mirror.get(key);
|
|
7125
|
+
}
|
|
7126
|
+
has(key) {
|
|
7127
|
+
return this.mirror.has(key);
|
|
7128
|
+
}
|
|
7129
|
+
get size() {
|
|
7130
|
+
return this.mirror.size;
|
|
7131
|
+
}
|
|
7132
|
+
/**
|
|
7133
|
+
* Judge ONE observation against what the ledger already accepted, and advance
|
|
7134
|
+
* it.
|
|
7135
|
+
*
|
|
7136
|
+
* Synchronous on purpose: the verdict is a function of the in-RAM mirror
|
|
7137
|
+
* alone, so a decision can never be gated on an I/O that might fail (D49).
|
|
7138
|
+
* The durable write is kicked off behind it and its failure changes no
|
|
7139
|
+
* verdict.
|
|
7140
|
+
*
|
|
7141
|
+
* `no-flip` leaves the held row UNTOUCHED — including any timestamp it
|
|
7142
|
+
* carries, which therefore means "when this key last CHANGED", not "when it
|
|
7143
|
+
* last spoke". That is the timestamp anyone reading the table wants.
|
|
7144
|
+
*/
|
|
7145
|
+
observe(row) {
|
|
7146
|
+
const equalFact = this.spec.equalFact;
|
|
7147
|
+
if (equalFact === void 0) throw new Error(`DurableLedger(${this.spec.collection}): observe() requires the spec to declare equalFact`);
|
|
7148
|
+
const key = this.spec.keyOf(row);
|
|
7149
|
+
const held = this.mirror.get(key);
|
|
7150
|
+
if (held !== void 0 && equalFact(held, row)) return "no-flip";
|
|
7151
|
+
this.mirror.set(key, row);
|
|
7152
|
+
this.persist(row);
|
|
7153
|
+
return held === void 0 ? "seeded" : "flip";
|
|
7154
|
+
}
|
|
7155
|
+
/**
|
|
7156
|
+
* Upsert a row without a verdict — the write path for a ledger whose owner
|
|
7157
|
+
* has already decided the value changed.
|
|
7158
|
+
*
|
|
7159
|
+
* The order of the mirror advance and the durable write is the spec's
|
|
7160
|
+
* {@link DurableWriteMode}, not the call site's: two call sites that
|
|
7161
|
+
* disagreed about it would be two different durability guarantees on one
|
|
7162
|
+
* collection.
|
|
7163
|
+
*/
|
|
7164
|
+
async put(row) {
|
|
7165
|
+
const key = this.spec.keyOf(row);
|
|
7166
|
+
if (this.spec.writeMode === "write-behind") {
|
|
7167
|
+
this.mirror.set(key, row);
|
|
7168
|
+
await this.persist(row);
|
|
7169
|
+
return;
|
|
7170
|
+
}
|
|
7171
|
+
await this.store.set.mutate({
|
|
7172
|
+
collection: this.spec.collection,
|
|
7173
|
+
key,
|
|
7174
|
+
value: this.spec.toValue(row)
|
|
7175
|
+
});
|
|
7176
|
+
this.mirror.set(key, row);
|
|
7177
|
+
}
|
|
7178
|
+
/**
|
|
7179
|
+
* Advance the mirror WITHOUT persisting, for an owner that deliberately
|
|
7180
|
+
* coalesces its writes.
|
|
7181
|
+
*
|
|
7182
|
+
* The stationary registry is the reason this exists: a parked car is
|
|
7183
|
+
* re-confirmed on every processed frame (5–30 Hz), and persisting each
|
|
7184
|
+
* confirmation would offer thousands of commits a day to the checkpoint
|
|
7185
|
+
* lottery (D96) to maintain a handful of rows. It stages the advance and
|
|
7186
|
+
* flushes on its 5-minute sweep — 288 writes a day instead of ~10⁶.
|
|
7187
|
+
*
|
|
7188
|
+
* **The cost is stated, not hidden**: a staged value that is never flushed
|
|
7189
|
+
* is lost on a crash. An owner may only stage a field whose staleness its
|
|
7190
|
+
* own TTL absorbs. Anything that GATES work must go through {@link put} or
|
|
7191
|
+
* {@link observe}.
|
|
7192
|
+
*/
|
|
7193
|
+
stage(row) {
|
|
7194
|
+
this.mirror.set(this.spec.keyOf(row), row);
|
|
7195
|
+
}
|
|
7196
|
+
/**
|
|
7197
|
+
* Drop a key from the MIRROR only — the durable row survives.
|
|
7198
|
+
*
|
|
7199
|
+
* What a scope-unbind needs: this process stops holding the value, and a
|
|
7200
|
+
* rebind reloads it from the store. Deliberately distinct from
|
|
7201
|
+
* {@link forget}, which deletes; conflating the two is how an unbind turns
|
|
7202
|
+
* into a wipe.
|
|
7203
|
+
*/
|
|
7204
|
+
evict(key) {
|
|
7205
|
+
this.mirror.delete(key);
|
|
7206
|
+
}
|
|
7207
|
+
/**
|
|
7208
|
+
* Drop one key, mirror and row. Best-effort on the durable half: a failed
|
|
7209
|
+
* delete leaves a row that the next load will re-mirror, which is a stale
|
|
7210
|
+
* value rather than a lost one.
|
|
7211
|
+
*/
|
|
7212
|
+
async forget(key) {
|
|
7213
|
+
this.mirror.delete(key);
|
|
7214
|
+
try {
|
|
7215
|
+
await this.store.delete.mutate({
|
|
7216
|
+
collection: this.spec.collection,
|
|
7217
|
+
key
|
|
7218
|
+
});
|
|
7219
|
+
} catch (err) {
|
|
7220
|
+
this.logger.debug("durable delete failed", { meta: {
|
|
7221
|
+
collection: this.spec.collection,
|
|
7222
|
+
key,
|
|
7223
|
+
error: String(err)
|
|
7224
|
+
} });
|
|
7225
|
+
}
|
|
7226
|
+
}
|
|
7227
|
+
/**
|
|
7228
|
+
* Drop every mirrored key NOT in `activeKeys`. Returns how many rows went.
|
|
7229
|
+
*
|
|
7230
|
+
* **The caller must hold an AUTHORITATIVE active set.** A prune driven by a
|
|
7231
|
+
* fallible read is work destroyed on an error (D49/D130) — that is why this
|
|
7232
|
+
* is a method a feature opts into rather than a policy the primitive runs.
|
|
7233
|
+
* Best-effort per row: a failed delete keeps the key (retried next prune)
|
|
7234
|
+
* rather than aborting the sweep.
|
|
7235
|
+
*/
|
|
7236
|
+
async pruneExcept(activeKeys) {
|
|
7237
|
+
let pruned = 0;
|
|
7238
|
+
for (const key of [...this.mirror.keys()]) {
|
|
7239
|
+
if (activeKeys.has(key)) continue;
|
|
7240
|
+
try {
|
|
7241
|
+
await this.store.delete.mutate({
|
|
7242
|
+
collection: this.spec.collection,
|
|
7243
|
+
key
|
|
7244
|
+
});
|
|
7245
|
+
this.mirror.delete(key);
|
|
7246
|
+
pruned += 1;
|
|
7247
|
+
} catch (err) {
|
|
7248
|
+
this.logger.debug("durable prune delete failed", { meta: {
|
|
7249
|
+
collection: this.spec.collection,
|
|
7250
|
+
key,
|
|
7251
|
+
error: String(err)
|
|
7252
|
+
} });
|
|
7253
|
+
}
|
|
7254
|
+
}
|
|
7255
|
+
return pruned;
|
|
7256
|
+
}
|
|
7257
|
+
/**
|
|
7258
|
+
* Write-behind durable upsert. Best-effort and logged, never thrown at the
|
|
7259
|
+
* decision path: the mirror already holds the truth for this process, and the
|
|
7260
|
+
* worst a lost write can do is one silent re-seed after the next restart.
|
|
7261
|
+
*/
|
|
7262
|
+
async persist(row) {
|
|
7263
|
+
const deviceId = this.spec.deviceIdOf?.(row);
|
|
7264
|
+
try {
|
|
7265
|
+
await this.store.set.mutate({
|
|
7266
|
+
collection: this.spec.collection,
|
|
7267
|
+
key: this.spec.keyOf(row),
|
|
7268
|
+
value: this.spec.toValue(row)
|
|
7269
|
+
});
|
|
7270
|
+
} catch (err) {
|
|
7271
|
+
this.logger.warn("durable persist failed — this key may re-seed on boot", {
|
|
7272
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
7273
|
+
meta: {
|
|
7274
|
+
collection: this.spec.collection,
|
|
7275
|
+
key: this.spec.keyOf(row),
|
|
7276
|
+
error: String(err)
|
|
7277
|
+
}
|
|
7278
|
+
});
|
|
7279
|
+
}
|
|
7280
|
+
}
|
|
7281
|
+
};
|
|
7282
|
+
//#endregion
|
|
7283
|
+
//#region src/recorder/addon/segment-hour-ledger.ts
|
|
7284
|
+
var HOUR_MS$1 = 36e5;
|
|
7285
|
+
/**
|
|
7286
|
+
* @durable class=mirror owner=recorder
|
|
7287
|
+
* write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
|
|
7288
|
+
* retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
|
|
7289
|
+
*/
|
|
7290
|
+
var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
|
|
7291
|
+
var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
7292
|
+
{
|
|
7293
|
+
name: "key",
|
|
7294
|
+
type: "TEXT",
|
|
7295
|
+
primaryKey: true,
|
|
7296
|
+
notNull: true
|
|
7297
|
+
},
|
|
7298
|
+
{
|
|
7299
|
+
name: "deviceId",
|
|
7300
|
+
type: "INTEGER",
|
|
7301
|
+
notNull: true
|
|
7302
|
+
},
|
|
7303
|
+
{
|
|
7304
|
+
name: "profile",
|
|
7305
|
+
type: "TEXT",
|
|
7306
|
+
notNull: true
|
|
7307
|
+
},
|
|
7308
|
+
{
|
|
7309
|
+
name: "locationId",
|
|
7310
|
+
type: "TEXT",
|
|
7311
|
+
notNull: true
|
|
7312
|
+
},
|
|
7313
|
+
{
|
|
7314
|
+
name: "hourStartMs",
|
|
7315
|
+
type: "INTEGER",
|
|
7316
|
+
notNull: true
|
|
7317
|
+
},
|
|
7318
|
+
{
|
|
7319
|
+
name: "paths",
|
|
7320
|
+
type: "TEXT",
|
|
7321
|
+
notNull: true
|
|
7322
|
+
}
|
|
7323
|
+
];
|
|
7324
|
+
var RECORDING_SEGMENT_HOURS_INDEXES = [{
|
|
7325
|
+
name: "idx_recorder_segment_hours_device",
|
|
7326
|
+
columns: ["deviceId"]
|
|
7327
|
+
}, {
|
|
7328
|
+
name: "idx_recorder_segment_hours_hour",
|
|
7329
|
+
columns: ["hourStartMs"]
|
|
7330
|
+
}];
|
|
7331
|
+
function hourStartMs(startMs) {
|
|
7332
|
+
return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
|
|
7333
|
+
}
|
|
7334
|
+
function segmentHourKey(deviceId, profile, locationId, hour) {
|
|
7335
|
+
return `${deviceId}|${profile}|${locationId}|${hour}`;
|
|
7336
|
+
}
|
|
7337
|
+
function hourOf(row) {
|
|
7338
|
+
const hour = hourStartMs(row.startMs);
|
|
7339
|
+
return {
|
|
7340
|
+
hourStartMs: hour,
|
|
7341
|
+
key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
|
|
7342
|
+
};
|
|
7343
|
+
}
|
|
7344
|
+
function uniquePaths(paths) {
|
|
7345
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7346
|
+
const out = [];
|
|
7347
|
+
for (const p of paths) {
|
|
7348
|
+
if (seen.has(p)) continue;
|
|
7349
|
+
seen.add(p);
|
|
7350
|
+
out.push(p);
|
|
7351
|
+
}
|
|
7352
|
+
return out;
|
|
7353
|
+
}
|
|
7354
|
+
function samePaths(a, b) {
|
|
7355
|
+
if (a.length !== b.length) return false;
|
|
7356
|
+
const set = new Set(a);
|
|
7357
|
+
for (const p of b) if (!set.has(p)) return false;
|
|
7358
|
+
return true;
|
|
7359
|
+
}
|
|
7360
|
+
function rowToValue(row) {
|
|
7361
|
+
return {
|
|
7362
|
+
deviceId: row.deviceId,
|
|
7363
|
+
profile: row.profile,
|
|
7364
|
+
locationId: row.locationId,
|
|
7365
|
+
hourStartMs: row.hourStartMs,
|
|
7366
|
+
paths: JSON.stringify(row.paths)
|
|
7367
|
+
};
|
|
7368
|
+
}
|
|
7369
|
+
function recordToRow(key, data) {
|
|
7370
|
+
const deviceId = Number(data["deviceId"]);
|
|
7371
|
+
const profile = data["profile"];
|
|
7372
|
+
const locationId = data["locationId"];
|
|
7373
|
+
const hour = Number(data["hourStartMs"]);
|
|
7374
|
+
let raw = data["paths"];
|
|
7375
|
+
if (typeof raw === "string") try {
|
|
7376
|
+
raw = JSON.parse(raw);
|
|
7377
|
+
} catch {
|
|
7378
|
+
return null;
|
|
7379
|
+
}
|
|
7380
|
+
if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour) || !Array.isArray(raw) || !raw.every((p) => typeof p === "string" && p.length > 0)) return null;
|
|
7381
|
+
return {
|
|
7382
|
+
key,
|
|
7383
|
+
deviceId,
|
|
7384
|
+
profile,
|
|
7385
|
+
locationId,
|
|
7386
|
+
hourStartMs: hour,
|
|
7387
|
+
paths: raw
|
|
7388
|
+
};
|
|
7389
|
+
}
|
|
7390
|
+
var SPEC = {
|
|
7391
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION,
|
|
7392
|
+
columns: RECORDING_SEGMENT_HOURS_COLUMNS,
|
|
7393
|
+
indexes: RECORDING_SEGMENT_HOURS_INDEXES,
|
|
7394
|
+
writeMode: "write-behind",
|
|
7395
|
+
loadLimit: 1e5,
|
|
7396
|
+
keyOf: (row) => row.key,
|
|
7397
|
+
toValue: rowToValue,
|
|
7398
|
+
fromRecord: recordToRow,
|
|
7399
|
+
deviceIdOf: (row) => row.deviceId
|
|
7400
|
+
};
|
|
7401
|
+
var SegmentHourLedger = class {
|
|
7402
|
+
ledger;
|
|
7403
|
+
logger;
|
|
7404
|
+
constructor(deps) {
|
|
7405
|
+
this.logger = deps.logger;
|
|
7406
|
+
this.ledger = new DurableLedger({
|
|
7407
|
+
spec: SPEC,
|
|
7408
|
+
store: deps.store,
|
|
7409
|
+
logger: deps.logger
|
|
7410
|
+
});
|
|
7411
|
+
}
|
|
7412
|
+
static declare(store) {
|
|
7413
|
+
return DurableLedger.declare(store, SPEC);
|
|
7414
|
+
}
|
|
7415
|
+
declare() {
|
|
7416
|
+
return this.ledger.declare();
|
|
7417
|
+
}
|
|
7418
|
+
async load() {
|
|
7419
|
+
const rows = await this.ledger.load();
|
|
7420
|
+
this.logger.info("recorder: segment-hour ledger loaded", { meta: {
|
|
7421
|
+
hours: rows.length,
|
|
7422
|
+
collection: RECORDING_SEGMENT_HOURS_COLLECTION
|
|
7423
|
+
} });
|
|
7424
|
+
return rows;
|
|
7425
|
+
}
|
|
7426
|
+
snapshot() {
|
|
7427
|
+
return this.ledger.snapshot();
|
|
7428
|
+
}
|
|
7429
|
+
/**
|
|
7430
|
+
* Seed {@link RecordingIndex} from the mirrored hour rows.
|
|
7431
|
+
*
|
|
7432
|
+
* Hours present in the ledger become `hydrated` so a seek in those windows
|
|
7433
|
+
* does not pay for a disk walk. Hours the ledger never held stay `unknown`
|
|
7434
|
+
* — that is the fail-toward-walk direction.
|
|
7435
|
+
*/
|
|
7436
|
+
hydrateIndex(index) {
|
|
7437
|
+
for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
|
|
7438
|
+
}
|
|
7439
|
+
/** Write-behind: a just-finalized segment joins its hour row. */
|
|
7440
|
+
async recordSegment(row) {
|
|
7441
|
+
const { key, hourStartMs: hour } = hourOf(row);
|
|
7442
|
+
const held = this.ledger.get(key);
|
|
7443
|
+
if (held !== void 0 && held.paths.includes(row.path)) return;
|
|
7444
|
+
await this.ledger.put({
|
|
7445
|
+
key,
|
|
7446
|
+
deviceId: row.deviceId,
|
|
7447
|
+
profile: row.profile,
|
|
7448
|
+
locationId: row.locationId,
|
|
7449
|
+
hourStartMs: hour,
|
|
7450
|
+
paths: held === void 0 ? [row.path] : [...held.paths, row.path]
|
|
7451
|
+
});
|
|
7452
|
+
}
|
|
7453
|
+
/**
|
|
7454
|
+
* Eviction confirmed these paths are gone from disk. Drop them; forget the
|
|
7455
|
+
* hour when it holds nothing.
|
|
7456
|
+
*/
|
|
7457
|
+
async dropSegments(rows) {
|
|
7458
|
+
const doomed = /* @__PURE__ */ new Map();
|
|
7459
|
+
for (const row of rows) {
|
|
7460
|
+
const { key } = hourOf(row);
|
|
7461
|
+
let set = doomed.get(key);
|
|
7462
|
+
if (!set) {
|
|
7463
|
+
set = /* @__PURE__ */ new Set();
|
|
7464
|
+
doomed.set(key, set);
|
|
7465
|
+
}
|
|
7466
|
+
set.add(row.path);
|
|
7467
|
+
}
|
|
7468
|
+
for (const [key, paths] of doomed) {
|
|
7469
|
+
const held = this.ledger.get(key);
|
|
7470
|
+
if (held === void 0) continue;
|
|
7471
|
+
const next = held.paths.filter((p) => !paths.has(p));
|
|
7472
|
+
if (next.length === 0) {
|
|
7473
|
+
await this.ledger.forget(key);
|
|
7474
|
+
continue;
|
|
7475
|
+
}
|
|
7476
|
+
if (next.length === held.paths.length) continue;
|
|
7477
|
+
await this.ledger.put({
|
|
7478
|
+
...held,
|
|
7479
|
+
paths: next
|
|
7480
|
+
});
|
|
7481
|
+
}
|
|
7482
|
+
}
|
|
7483
|
+
/**
|
|
7484
|
+
* After a disk walk of `deviceIds`, make the ledger match the index — with
|
|
7485
|
+
* two D148 exceptions:
|
|
7486
|
+
*
|
|
7487
|
+
* - an hour the walk did not see is dropped ONLY if it is in the past;
|
|
7488
|
+
* the current hour may still hold a live writer whose file the readdir
|
|
7489
|
+
* raced;
|
|
7490
|
+
* - the current hour UNIONs ledger paths with index paths rather than
|
|
7491
|
+
* replacing, for the same race.
|
|
7492
|
+
*
|
|
7493
|
+
* Devices not in `deviceIds` are left untouched (a partial walk must not
|
|
7494
|
+
* prune cameras it did not look at).
|
|
7495
|
+
*/
|
|
7496
|
+
async reconcileFromIndex(index, deviceIds, nowMs) {
|
|
7497
|
+
const walked = new Set(deviceIds);
|
|
7498
|
+
const currentHour = hourStartMs(nowMs);
|
|
7499
|
+
const fromIndex = /* @__PURE__ */ new Map();
|
|
7500
|
+
for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
|
|
7501
|
+
const { key, hourStartMs: hour } = hourOf(seg);
|
|
7502
|
+
const existing = fromIndex.get(key);
|
|
7503
|
+
if (existing === void 0) fromIndex.set(key, {
|
|
7504
|
+
key,
|
|
7505
|
+
deviceId: seg.deviceId,
|
|
7506
|
+
profile: seg.profile,
|
|
7507
|
+
locationId: seg.locationId,
|
|
7508
|
+
hourStartMs: hour,
|
|
7509
|
+
paths: [seg.path]
|
|
7510
|
+
});
|
|
7511
|
+
else fromIndex.set(key, {
|
|
7512
|
+
...existing,
|
|
7513
|
+
paths: [...existing.paths, seg.path]
|
|
7514
|
+
});
|
|
7515
|
+
}
|
|
7516
|
+
for (const row of this.ledger.snapshot()) {
|
|
7517
|
+
if (!walked.has(row.deviceId)) continue;
|
|
7518
|
+
const disk = fromIndex.get(row.key);
|
|
7519
|
+
if (disk === void 0) {
|
|
7520
|
+
if (row.hourStartMs === currentHour) continue;
|
|
7521
|
+
await this.ledger.forget(row.key);
|
|
7522
|
+
continue;
|
|
7523
|
+
}
|
|
7524
|
+
if (row.hourStartMs === currentHour) {
|
|
7525
|
+
const union = uniquePaths([...disk.paths, ...row.paths]);
|
|
7526
|
+
if (!samePaths(union, row.paths)) await this.ledger.put({
|
|
7527
|
+
...row,
|
|
7528
|
+
paths: union
|
|
7529
|
+
});
|
|
7530
|
+
continue;
|
|
7531
|
+
}
|
|
7532
|
+
if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
|
|
7533
|
+
}
|
|
7534
|
+
for (const [key, disk] of fromIndex) {
|
|
7535
|
+
if (this.ledger.has(key)) continue;
|
|
7536
|
+
await this.ledger.put(disk);
|
|
7537
|
+
}
|
|
7538
|
+
}
|
|
7539
|
+
};
|
|
7540
|
+
//#endregion
|
|
7042
7541
|
//#region src/recorder/addon/recording-export-provider.ts
|
|
7043
7542
|
/** Content type of every rendered export — the engine only ever writes MP4. */
|
|
7044
7543
|
var EXPORT_CONTENT_TYPE = "video/mp4";
|
|
@@ -7860,6 +8359,12 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
7860
8359
|
isRecordingNode = true;
|
|
7861
8360
|
index = new RecordingIndex();
|
|
7862
8361
|
/**
|
|
8362
|
+
* Durable hour-directory copy of {@link index}. Null on an inert
|
|
8363
|
+
* (non-recording) node. Boot reseeds the RAM index from this; the
|
|
8364
|
+
* deferred archive walk reconciles it against disk.
|
|
8365
|
+
*/
|
|
8366
|
+
segmentHours = null;
|
|
8367
|
+
/**
|
|
7863
8368
|
* The calendar rungs — which days, which hours, and a day's exact ranges.
|
|
7864
8369
|
*
|
|
7865
8370
|
* Separate from `RecordingIndex` on purpose: that one is the SEGMENT model
|
|
@@ -7931,6 +8436,16 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
7931
8436
|
} });
|
|
7932
8437
|
return { providers: [] };
|
|
7933
8438
|
}
|
|
8439
|
+
this.segmentHours = new SegmentHourLedger({
|
|
8440
|
+
store: this.ctx.api.settingsStore,
|
|
8441
|
+
logger: this.ctx.logger
|
|
8442
|
+
});
|
|
8443
|
+
try {
|
|
8444
|
+
await this.segmentHours.declare();
|
|
8445
|
+
} catch (err) {
|
|
8446
|
+
this.ctx.logger.warn("recorder: segment-hour ledger declare failed — index stays RAM-only this boot", { meta: { error: errMsg(err) } });
|
|
8447
|
+
this.segmentHours = null;
|
|
8448
|
+
}
|
|
7934
8449
|
const ingestOwner = await this.resolveIngestOwner();
|
|
7935
8450
|
const consumerHostname = this.nodeId === ingestOwner.ownerNodeId ? void 0 : ingestOwner.reachableHost ?? resolveRecordingHubHostname(this.config.recordingHubHostname, process.env["CAMSTACK_HUB_URL"]);
|
|
7936
8451
|
if (this.nodeId !== ingestOwner.ownerNodeId) {
|
|
@@ -7967,7 +8482,11 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
7967
8482
|
onEvicted: (deviceId, atMs) => {
|
|
7968
8483
|
this.calendar.invalidate(deviceId, atMs);
|
|
7969
8484
|
},
|
|
8485
|
+
onDeindexed: (rows) => {
|
|
8486
|
+
this.segmentHours?.dropSegments(rows);
|
|
8487
|
+
},
|
|
7970
8488
|
onIndexed: (row, absPath) => {
|
|
8489
|
+
this.segmentHours?.recordSegment(row);
|
|
7971
8490
|
if (!isMfraTableServable(row.startMs, Date.now())) return;
|
|
7972
8491
|
readMfraTable(absPath, row.bytes).then((table) => {
|
|
7973
8492
|
this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
|
|
@@ -8344,6 +8863,12 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
8344
8863
|
this.controller?.onTrigger(deviceId, "motion", atMs);
|
|
8345
8864
|
}
|
|
8346
8865
|
});
|
|
8866
|
+
try {
|
|
8867
|
+
await this.segmentHours?.load();
|
|
8868
|
+
this.segmentHours?.hydrateIndex(this.index);
|
|
8869
|
+
} catch (err) {
|
|
8870
|
+
this.ctx.logger.warn("recorder: segment-hour ledger load failed — falling through to the archive walk", { meta: { error: errMsg(err) } });
|
|
8871
|
+
}
|
|
8347
8872
|
let configs;
|
|
8348
8873
|
try {
|
|
8349
8874
|
configs = await readDeviceConfigs(this.configStore());
|
|
@@ -8831,6 +9356,11 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
8831
9356
|
locationCount: this.resolvedLocations.length,
|
|
8832
9357
|
ms: Date.now() - hydrateStartedMs
|
|
8833
9358
|
} });
|
|
9359
|
+
try {
|
|
9360
|
+
await this.segmentHours?.reconcileFromIndex(this.index, deviceIds, Date.now());
|
|
9361
|
+
} catch (err) {
|
|
9362
|
+
this.ctx.logger.warn("recorder: segment-hour ledger reconcile failed — walk remains the authority", { meta: { error: errMsg(err) } });
|
|
9363
|
+
}
|
|
8834
9364
|
const loggedDomains = /* @__PURE__ */ new Set();
|
|
8835
9365
|
for (const loc of this.resolvedLocations) {
|
|
8836
9366
|
const domain = this.locationDomain(loc.id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-pipeline",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.90",
|
|
4
4
|
"description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|