@camstack/addon-pipeline 1.2.154 → 1.2.156
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +5 -5
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-CNyaLgDE.js → dist-C499qEUf.js} +296 -4
- package/dist/{dist-B3LRtscY.mjs → dist-C9ohtgTQ.mjs} +296 -4
- package/dist/{lazy-sharp-BtcZ-K2I.js → lazy-sharp-D5uApbbe.js} +1 -1
- package/dist/{local-frame-registry-C5pZ5QSM.mjs → local-frame-registry-DAVug-jA.mjs} +1 -1
- package/dist/{local-frame-registry-TkoMo5_E.js → local-frame-registry-VkzIezFI.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-CtIkY7To.mjs → node-CwIlVFyz.mjs} +1 -1
- package/dist/{node-Ctiow9ZV.js → node-DEq8mutP.js} +1 -1
- package/dist/pipeline-runner/index.js +5 -5
- package/dist/pipeline-runner/index.mjs +4 -4
- package/dist/{process-memory-C87TCFw5.mjs → process-memory-DWw7FrV5.mjs} +1 -1
- package/dist/{process-memory-W1prDrmt.js → process-memory-DzycAIEm.js} +1 -1
- package/dist/recorder/index.js +668 -100
- package/dist/recorder/index.mjs +668 -100
- package/dist/{segment-demux-js-BXma5CQd.mjs → segment-demux-js-D2InpBpa.mjs} +1 -1
- package/dist/{segment-demux-js-CnRETfAK.js → segment-demux-js-Df1rAqNh.js} +1 -1
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-oS7jpj-4.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BJlcLzCs.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-lMPlG5w5.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-yWrVN7u8.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-BMp9L2lF.mjs → hostInit-QpJx-TgK.mjs} +3 -3
- package/dist/stream-broker/index.js +3 -3
- package/dist/stream-broker/index.mjs +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DlDGOO8p.mjs → worker-protocol-CFHcqXoS.mjs} +1 -1
- package/dist/{worker-protocol-DOFTntiF.js → worker-protocol-ge8Ce0Bo.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-CVQG3RZ2.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-Vtqzcp6N.mjs +0 -26
package/dist/recorder/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const require_dist = require("../dist-
|
|
2
|
-
const require_node = require("../node-
|
|
1
|
+
const require_dist = require("../dist-C499qEUf.js");
|
|
2
|
+
const require_node = require("../node-DEq8mutP.js");
|
|
3
3
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
4
4
|
const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
|
|
5
5
|
const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
|
|
@@ -608,7 +608,7 @@ function qualifyObjectTrigger(data) {
|
|
|
608
608
|
* la riga arrivi entro il primo minuto di un deploy rotto, abbastanza lungo
|
|
609
609
|
* perché a 4 eventi/s il campione sia significativo. */
|
|
610
610
|
var OBJECT_TRIGGER_HEALTH_WINDOW_MS = 6e4;
|
|
611
|
-
function emptyTally$
|
|
611
|
+
function emptyTally$2() {
|
|
612
612
|
return {
|
|
613
613
|
objectEvents: 0,
|
|
614
614
|
qualified: 0,
|
|
@@ -672,7 +672,7 @@ var ObjectTriggerHealth = class {
|
|
|
672
672
|
bucket(key) {
|
|
673
673
|
const existing = this.buckets.get(key);
|
|
674
674
|
if (existing !== void 0) return existing;
|
|
675
|
-
const fresh = emptyTally$
|
|
675
|
+
const fresh = emptyTally$2();
|
|
676
676
|
this.buckets.set(key, fresh);
|
|
677
677
|
return fresh;
|
|
678
678
|
}
|
|
@@ -822,14 +822,14 @@ function parseSegmentPath(relPath) {
|
|
|
822
822
|
//#region src/recorder/recording-index.ts
|
|
823
823
|
/** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
|
|
824
824
|
* already gives us — so "have I walked this?" costs one Set lookup. */
|
|
825
|
-
var HOUR_MS$
|
|
825
|
+
var HOUR_MS$8 = 36e5;
|
|
826
826
|
/** A device walked end-to-end. Cheaper and clearer than inserting every hour of
|
|
827
827
|
* the retention window into the set. */
|
|
828
828
|
var ALL_HOURS = Symbol("all-hours");
|
|
829
829
|
/** The hour buckets a half-open span touches. */
|
|
830
830
|
function* hourBuckets(fromMs, toMs) {
|
|
831
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
832
|
-
for (let h = first; h < toMs; h += HOUR_MS$
|
|
831
|
+
const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
|
|
832
|
+
for (let h = first; h < toMs; h += HOUR_MS$8) yield h;
|
|
833
833
|
}
|
|
834
834
|
/**
|
|
835
835
|
* Index of the first element whose `startMs` is GREATER than `startMs` (upper
|
|
@@ -1123,7 +1123,7 @@ var RecordingIndex = class {
|
|
|
1123
1123
|
changed = true;
|
|
1124
1124
|
}
|
|
1125
1125
|
if (changed) this.invalidateSorted(deviceId);
|
|
1126
|
-
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$
|
|
1126
|
+
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$8);
|
|
1127
1127
|
}
|
|
1128
1128
|
/**
|
|
1129
1129
|
* Insert/replace one finalized segment (idempotent by path).
|
|
@@ -1241,35 +1241,6 @@ var RecordingIndex = class {
|
|
|
1241
1241
|
hasSegment(deviceId, relPath) {
|
|
1242
1242
|
return this.byDevice.get(deviceId)?.has(relPath) === true;
|
|
1243
1243
|
}
|
|
1244
|
-
/**
|
|
1245
|
-
* Indexed-segment count per HOUR-BUCKET directory (`<dev>/<profile>/Y/M/D/H`)
|
|
1246
|
-
* for one (device, profile), in ONE pass over the device's rows.
|
|
1247
|
-
*
|
|
1248
|
-
* Exists because `SegmentStore.evict` has to answer "does this eviction cover
|
|
1249
|
-
* every segment the bucket holds?" for every bucket in the round, and it used
|
|
1250
|
-
* to answer it with `segments(deviceId, profile)` — a full COPY of the
|
|
1251
|
-
* device-profile archive plus a full `startsWith` scan, PER BUCKET. That is
|
|
1252
|
-
* O(buckets x archive), and D148 is what made it bite: before it, a retention
|
|
1253
|
-
* round touched a handful of buckets at the retention edge; after it, the
|
|
1254
|
-
* recovered backlog made ~8 900 sparse buckets across six weeks evictable in
|
|
1255
|
-
* ONE round. Measured on that shape (1.63 M live rows + 122 400 victims):
|
|
1256
|
-
* 182 596 ms of uninterrupted main-thread CPU, with no log line, which is the
|
|
1257
|
-
* silent 100%-CPU phase the operator saw. One pass answers all of them.
|
|
1258
|
-
* @residency archive-shaped
|
|
1259
|
-
*/
|
|
1260
|
-
bucketCounts(deviceId, profile) {
|
|
1261
|
-
const out = /* @__PURE__ */ new Map();
|
|
1262
|
-
const m = this.byDevice.get(deviceId);
|
|
1263
|
-
if (!m) return out;
|
|
1264
|
-
for (const s of m.values()) {
|
|
1265
|
-
if (s.profile !== profile) continue;
|
|
1266
|
-
const cut = s.path.lastIndexOf("/");
|
|
1267
|
-
if (cut <= 0) continue;
|
|
1268
|
-
const bucketDir = s.path.slice(0, cut);
|
|
1269
|
-
out.set(bucketDir, (out.get(bucketDir) ?? 0) + 1);
|
|
1270
|
-
}
|
|
1271
|
-
return out;
|
|
1272
|
-
}
|
|
1273
1244
|
/** Aggregate accounting for a device across ALL its locations (use `accountingForLocation` for one location). @residency archive-shaped */
|
|
1274
1245
|
accounting(deviceId) {
|
|
1275
1246
|
const m = this.byDevice.get(deviceId);
|
|
@@ -1601,7 +1572,7 @@ function resolveEvictionDomain(locations, locationId, realpath = node_fs.realpat
|
|
|
1601
1572
|
*/
|
|
1602
1573
|
var DEFAULT_THROTTLE_MBPS = 40;
|
|
1603
1574
|
/** The durable index's bucket, and therefore this mover's batch. */
|
|
1604
|
-
var HOUR_MS$
|
|
1575
|
+
var HOUR_MS$7 = 36e5;
|
|
1605
1576
|
/** Progress callback cadence (files) — cheap enough to fire often. */
|
|
1606
1577
|
var PROGRESS_EVERY_FILES = 25;
|
|
1607
1578
|
/** Free space a destination must keep when it declares no `config.minFreePercent`
|
|
@@ -1610,6 +1581,25 @@ var DEFAULT_MIN_FREE_PERCENT$1 = 10;
|
|
|
1610
1581
|
/** Prefix every destination-full error carries, so the coordinator can report
|
|
1611
1582
|
* "drain stalled: destination full" instead of a raw ENOSPC string. */
|
|
1612
1583
|
var DESTINATION_FULL_PREFIX = "destination full";
|
|
1584
|
+
/**
|
|
1585
|
+
* Ghost rows one job may FORGET, as a share of its own census — and the floor
|
|
1586
|
+
* below which the share is not the bound.
|
|
1587
|
+
*
|
|
1588
|
+
* D148 is why this is bounded at all: the ledger forgets only on a trusted walk
|
|
1589
|
+
* or an eviction, so that a transient read error cannot erase an operator's
|
|
1590
|
+
* index of real footage. A per-path ENOENT on a path derived from the row is
|
|
1591
|
+
* strong evidence about THAT path (D296) and weak evidence about a mount: a
|
|
1592
|
+
* root that vanished answers ENOENT for every path it is asked about, and looks
|
|
1593
|
+
* exactly like a location that is genuinely empty. The bound is what makes the
|
|
1594
|
+
* difference survivable — past it the mover keeps moving, keeps skipping, and
|
|
1595
|
+
* REPORTS, so an operator gets a reconcile to act on instead of an archive that
|
|
1596
|
+
* quietly emptied itself.
|
|
1597
|
+
*
|
|
1598
|
+
* 17.5 % of `recordingsLow:default` was ghosts on 2026-08-30 (11 074 of
|
|
1599
|
+
* 63 346), so a quarter admits a real reconcile of that size in one pass while
|
|
1600
|
+
* refusing a whole-location erase.
|
|
1601
|
+
*/
|
|
1602
|
+
var GHOST_FORGET_SHARE = .25;
|
|
1613
1603
|
function snapshot(j) {
|
|
1614
1604
|
return {
|
|
1615
1605
|
jobId: j.jobId,
|
|
@@ -1622,6 +1612,7 @@ function snapshot(j) {
|
|
|
1622
1612
|
bytesMoved: j.bytesMoved,
|
|
1623
1613
|
filesTotal: j.filesTotal,
|
|
1624
1614
|
rowsReconciled: j.rowsReconciled,
|
|
1615
|
+
rowsForgotten: j.rowsForgotten,
|
|
1625
1616
|
startedAt: j.startedAt,
|
|
1626
1617
|
finishedAt: j.finishedAt,
|
|
1627
1618
|
error: j.error
|
|
@@ -1764,6 +1755,9 @@ var RelocateEngine = class {
|
|
|
1764
1755
|
bytesMoved: 0,
|
|
1765
1756
|
filesTotal: null,
|
|
1766
1757
|
rowsReconciled: 0,
|
|
1758
|
+
rowsForgotten: 0,
|
|
1759
|
+
ghostsKept: 0,
|
|
1760
|
+
budgetAnnounced: false,
|
|
1767
1761
|
startedAt: this.deps.now(),
|
|
1768
1762
|
finishedAt: null,
|
|
1769
1763
|
error: null,
|
|
@@ -1918,7 +1912,9 @@ var RelocateEngine = class {
|
|
|
1918
1912
|
archiveSegments: job.filesTotal,
|
|
1919
1913
|
seen,
|
|
1920
1914
|
moved: job.filesMoved,
|
|
1921
|
-
rowsReconciled: job.rowsReconciled
|
|
1915
|
+
rowsReconciled: job.rowsReconciled,
|
|
1916
|
+
rowsForgotten: job.rowsForgotten,
|
|
1917
|
+
ghostsKept: job.ghostsKept
|
|
1922
1918
|
};
|
|
1923
1919
|
if (archive === null) {
|
|
1924
1920
|
this.deps.logger.warn("relocate: coverage UNKNOWN — with no durable ledger this node cannot say what the source still holds", {
|
|
@@ -2063,6 +2059,81 @@ var RelocateEngine = class {
|
|
|
2063
2059
|
return fixed;
|
|
2064
2060
|
}
|
|
2065
2061
|
/**
|
|
2062
|
+
* Forget the rows of one hour whose source file this mover CONFIRMED absent
|
|
2063
|
+
* — durably, under a bound, and never in silence.
|
|
2064
|
+
*
|
|
2065
|
+
* ## Why this observation is trustworthy enough to act on (D148)
|
|
2066
|
+
*
|
|
2067
|
+
* D148 lets the ledger forget only on a trusted walk or an eviction, so that
|
|
2068
|
+
* a fallible read can never delete an operator's index of real footage. This
|
|
2069
|
+
* is neither, and it is stronger than the walk it is not: the mover built the
|
|
2070
|
+
* absolute path from the ROW's own fields and `stat`ed exactly that path, so
|
|
2071
|
+
* the answer is per-path (D296) rather than "a directory listing came back
|
|
2072
|
+
* short" — the failure mode the walk-only rule exists to refuse. It is also
|
|
2073
|
+
* the same channel eviction already uses: `dropSegments` refuses an hour
|
|
2074
|
+
* whose detail it cannot read rather than emptying it, so a failed point read
|
|
2075
|
+
* here still changes nothing (D49).
|
|
2076
|
+
*
|
|
2077
|
+
* ## Why it is nevertheless BOUNDED
|
|
2078
|
+
*
|
|
2079
|
+
* Because per-path evidence is strong about a path and weak about a MOUNT. A
|
|
2080
|
+
* root that has gone away answers ENOENT for everything, and is
|
|
2081
|
+
* indistinguishable from a location that is genuinely empty — with the
|
|
2082
|
+
* archive as the difference. So the budget caps what one run may erase; past
|
|
2083
|
+
* it the mover keeps moving and keeps skipping, and the operator gets a
|
|
2084
|
+
* reported reconcile instead of an archive that emptied itself. A job with no
|
|
2085
|
+
* census forgets NOTHING: a coverage that cannot be stated is not one to
|
|
2086
|
+
* delete on (D295).
|
|
2087
|
+
*/
|
|
2088
|
+
async forgetGhosts(job, missing, from) {
|
|
2089
|
+
if (missing.length === 0) return;
|
|
2090
|
+
const ledger = this.deps.ledger?.() ?? null;
|
|
2091
|
+
const budget = this.ghostForgetBudget(job);
|
|
2092
|
+
const allowed = ledger === null ? 0 : Math.max(0, Math.min(missing.length, budget - job.rowsForgotten));
|
|
2093
|
+
if (allowed > 0 && ledger !== null) {
|
|
2094
|
+
await ledger.dropSegments(missing.slice(0, allowed));
|
|
2095
|
+
job.rowsForgotten += allowed;
|
|
2096
|
+
}
|
|
2097
|
+
missing.forEach((row, i) => {
|
|
2098
|
+
this.deps.logger.warn("relocate skipped missing source", {
|
|
2099
|
+
tags: { deviceId: row.deviceId },
|
|
2100
|
+
meta: {
|
|
2101
|
+
jobId: job.jobId,
|
|
2102
|
+
path: row.path,
|
|
2103
|
+
locationId: from.id,
|
|
2104
|
+
forgotten: i < allowed
|
|
2105
|
+
}
|
|
2106
|
+
});
|
|
2107
|
+
});
|
|
2108
|
+
const kept = missing.length - allowed;
|
|
2109
|
+
if (kept === 0) return;
|
|
2110
|
+
job.ghostsKept += kept;
|
|
2111
|
+
if (job.budgetAnnounced) return;
|
|
2112
|
+
job.budgetAnnounced = true;
|
|
2113
|
+
this.deps.logger.warn("relocate: ghost-row forget BUDGET reached — the durable rows are KEPT", {
|
|
2114
|
+
tags: { deviceId: missing[0].deviceId },
|
|
2115
|
+
meta: {
|
|
2116
|
+
jobId: job.jobId,
|
|
2117
|
+
fromLocationId: from.id,
|
|
2118
|
+
budget,
|
|
2119
|
+
forgotten: job.rowsForgotten,
|
|
2120
|
+
stillMissing: kept,
|
|
2121
|
+
archiveSegments: job.filesTotal,
|
|
2122
|
+
why: ledger === null ? "no durable hour ledger on this node — a RAM-only mover may not erase durable rows" : "a root that went away answers ENOENT for everything; past the bound this is reported, not deleted"
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* How many ghost rows THIS job may forget: the operator/test override, else a
|
|
2128
|
+
* share of the census it reported itself against, else nothing.
|
|
2129
|
+
*/
|
|
2130
|
+
ghostForgetBudget(job) {
|
|
2131
|
+
const override = this.deps.ghostForgetBudget;
|
|
2132
|
+
if (override !== void 0) return override;
|
|
2133
|
+
if (job.filesTotal === null) return 0;
|
|
2134
|
+
return Math.max(64, Math.ceil(job.filesTotal * GHOST_FORGET_SHARE));
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2066
2137
|
* Move ONE hour of one (device, profile): copy+verify every file, re-point
|
|
2067
2138
|
* the durable hour row, delete the sources, re-point the RAM index.
|
|
2068
2139
|
*
|
|
@@ -2075,6 +2146,8 @@ var RelocateEngine = class {
|
|
|
2075
2146
|
*/
|
|
2076
2147
|
async moveHour(job, rows, from, to, samePhysicalRoot, sleep, bytesPerMs) {
|
|
2077
2148
|
const movable = [];
|
|
2149
|
+
/** Rows of this hour whose source file the mover CONFIRMED absent. */
|
|
2150
|
+
const missing = [];
|
|
2078
2151
|
let copiedBytes = 0;
|
|
2079
2152
|
if (samePhysicalRoot) movable.push(...rows);
|
|
2080
2153
|
else for (const row of rows) {
|
|
@@ -2090,16 +2163,11 @@ var RelocateEngine = class {
|
|
|
2090
2163
|
} catch (err) {
|
|
2091
2164
|
if (!isEnoent(err)) throw err;
|
|
2092
2165
|
this.deps.index.removeSegments([row.path]);
|
|
2093
|
-
|
|
2094
|
-
tags: { deviceId: row.deviceId },
|
|
2095
|
-
meta: {
|
|
2096
|
-
path: row.path,
|
|
2097
|
-
locationId: from.id
|
|
2098
|
-
}
|
|
2099
|
-
});
|
|
2166
|
+
missing.push(row);
|
|
2100
2167
|
}
|
|
2101
2168
|
await sleep(pacedBytes / bytesPerMs);
|
|
2102
2169
|
}
|
|
2170
|
+
await this.forgetGhosts(job, missing, from);
|
|
2103
2171
|
if (movable.length === 0) return;
|
|
2104
2172
|
if (job.cancelRequested) return;
|
|
2105
2173
|
const ledger = this.deps.ledger?.() ?? null;
|
|
@@ -2209,7 +2277,7 @@ function profilesOfClass(footageClass) {
|
|
|
2209
2277
|
function groupByHour(rows) {
|
|
2210
2278
|
const out = /* @__PURE__ */ new Map();
|
|
2211
2279
|
for (const row of rows) {
|
|
2212
|
-
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$
|
|
2280
|
+
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
|
|
2213
2281
|
const found = out.get(key);
|
|
2214
2282
|
if (found) found.push(row);
|
|
2215
2283
|
else out.set(key, [row]);
|
|
@@ -2397,21 +2465,12 @@ var SegmentStore = class SegmentStore {
|
|
|
2397
2465
|
if (group) group.push(r);
|
|
2398
2466
|
else byBucket.set(bucketDir, [r]);
|
|
2399
2467
|
}
|
|
2400
|
-
const census = /* @__PURE__ */ new Map();
|
|
2401
|
-
const countsFor = (deviceId, profile) => {
|
|
2402
|
-
const key = `${deviceId}:${profile}`;
|
|
2403
|
-
const cached = census.get(key);
|
|
2404
|
-
if (cached) return cached;
|
|
2405
|
-
const counts = this.deps.index.bucketCounts(deviceId, profile);
|
|
2406
|
-
census.set(key, counts);
|
|
2407
|
-
return counts;
|
|
2408
|
-
};
|
|
2409
2468
|
const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
|
|
2410
2469
|
const yieldBetween = this.deps.yieldBetween ?? macrotask$3;
|
|
2411
2470
|
let bucketsRemoved = 0;
|
|
2412
2471
|
let handled = 0;
|
|
2413
2472
|
for (const [bucketDir, bucketRows] of byBucket) {
|
|
2414
|
-
if (bucketDir !== "" && this.bucketFullyEvicted(
|
|
2473
|
+
if (bucketDir !== "" && await this.bucketFullyEvicted(bucketRows, location)) try {
|
|
2415
2474
|
await this.deps.removeDir(location, bucketDir);
|
|
2416
2475
|
bucketsRemoved += 1;
|
|
2417
2476
|
for (const r of bucketRows) {
|
|
@@ -2522,27 +2581,37 @@ var SegmentStore = class SegmentStore {
|
|
|
2522
2581
|
* work now the per-bucket archive scan is gone. */
|
|
2523
2582
|
static DEFAULT_EVICT_CHUNK = 25;
|
|
2524
2583
|
/**
|
|
2525
|
-
* True when `victims` cover EVERY
|
|
2526
|
-
* bucket's hour has fully elapsed.
|
|
2527
|
-
*
|
|
2528
|
-
*
|
|
2584
|
+
* True when `victims` cover EVERY segment the ARCHIVE says the bucket holds,
|
|
2585
|
+
* AND the bucket's hour has fully elapsed.
|
|
2586
|
+
*
|
|
2587
|
+
* The current-hour guard matters because `onFinalized` relocates new segments
|
|
2588
|
+
* INTO the current bucket concurrently — an rm racing that rename would
|
|
2589
|
+
* orphan a just-indexed segment.
|
|
2590
|
+
*
|
|
2591
|
+
* The cover test is against `SegmentHourLedger`'s own hour row, never
|
|
2592
|
+
* `RecordingIndex`. A bucketDir is `<dev>/<profile>/Y/M/D/H`, so it names
|
|
2593
|
+
* exactly one durable hour row: "every path that row lists is a victim" is
|
|
2594
|
+
* the same question the `rm -rf` answers, asked of the authority for what
|
|
2595
|
+
* EXISTS (D295). Asked of the resident index it read EQUAL whenever the hour
|
|
2596
|
+
* was partly resident — both sides short by the same rows — and the directory
|
|
2597
|
+
* went with every file the round never named, leaving their durable rows
|
|
2598
|
+
* behind as ghosts.
|
|
2529
2599
|
*
|
|
2530
|
-
*
|
|
2531
|
-
*
|
|
2532
|
-
*
|
|
2533
|
-
* (device, profile), and the victims of a bucket are unique paths. Equal
|
|
2534
|
-
* counts with every victim indexed therefore means the same set.
|
|
2600
|
+
* An unanswerable archive returns `false`: per-file deletes are slower and
|
|
2601
|
+
* always correct, and the alternative is a recursive delete computed from a
|
|
2602
|
+
* fraction of the truth.
|
|
2535
2603
|
*/
|
|
2536
|
-
bucketFullyEvicted(
|
|
2604
|
+
async bucketFullyEvicted(victims, location) {
|
|
2537
2605
|
const first = victims[0];
|
|
2538
2606
|
if (!first) return false;
|
|
2539
2607
|
const bucketStartMs = Math.floor(first.startMs / SegmentStore.HOUR_MS) * SegmentStore.HOUR_MS;
|
|
2540
2608
|
if ((this.deps.now?.() ?? Date.now()) < bucketStartMs + SegmentStore.HOUR_MS) return false;
|
|
2541
|
-
const
|
|
2542
|
-
if (
|
|
2543
|
-
const
|
|
2544
|
-
|
|
2545
|
-
|
|
2609
|
+
const ask = this.deps.archiveHourPaths;
|
|
2610
|
+
if (ask === void 0) return false;
|
|
2611
|
+
const held = await ask(first.deviceId, first.profile, location, bucketStartMs);
|
|
2612
|
+
if (held === null || held.length === 0) return false;
|
|
2613
|
+
const doomed = new Set(victims.map((v) => v.path));
|
|
2614
|
+
return held.every((p) => doomed.has(p));
|
|
2546
2615
|
}
|
|
2547
2616
|
};
|
|
2548
2617
|
var macrotask$3 = () => new Promise((resolve) => {
|
|
@@ -3220,7 +3289,7 @@ async function profileHasFootage(root, deviceId, profile) {
|
|
|
3220
3289
|
//#endregion
|
|
3221
3290
|
//#region src/recorder/addon/calendar-index.ts
|
|
3222
3291
|
var DAY_MS$3 = 864e5;
|
|
3223
|
-
var HOUR_MS$
|
|
3292
|
+
var HOUR_MS$6 = 36e5;
|
|
3224
3293
|
/**
|
|
3225
3294
|
* UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
|
|
3226
3295
|
*
|
|
@@ -3258,8 +3327,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
|
|
|
3258
3327
|
* deploy gap). Unknown hours still fall through to one directory read.
|
|
3259
3328
|
*/
|
|
3260
3329
|
function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
|
|
3261
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
3262
|
-
const hourEnd = hour + HOUR_MS$
|
|
3330
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
|
|
3331
|
+
const hourEnd = hour + HOUR_MS$6;
|
|
3263
3332
|
if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
|
|
3264
3333
|
const rows = [];
|
|
3265
3334
|
for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
|
|
@@ -3416,7 +3485,7 @@ var CalendarIndex = class {
|
|
|
3416
3485
|
}));
|
|
3417
3486
|
const days = /* @__PURE__ */ new Set();
|
|
3418
3487
|
for (const { day, hours } of perDay) for (const h of hours) {
|
|
3419
|
-
const at = day + h * HOUR_MS$
|
|
3488
|
+
const at = day + h * HOUR_MS$6;
|
|
3420
3489
|
if (at < fromMs || at >= toMs) continue;
|
|
3421
3490
|
days.add(localDayStart(at, tzOffsetMinutes));
|
|
3422
3491
|
}
|
|
@@ -3478,7 +3547,7 @@ var CalendarIndex = class {
|
|
|
3478
3547
|
const day = utcDayOf(utcDayMs);
|
|
3479
3548
|
for (let h = 0; h < 24; h++) {
|
|
3480
3549
|
await this.awaitQuiet();
|
|
3481
|
-
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$
|
|
3550
|
+
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$6, true);
|
|
3482
3551
|
}
|
|
3483
3552
|
}
|
|
3484
3553
|
/**
|
|
@@ -3499,7 +3568,7 @@ var CalendarIndex = class {
|
|
|
3499
3568
|
*/
|
|
3500
3569
|
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
3501
3570
|
if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
|
|
3502
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
3571
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
|
|
3503
3572
|
const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
|
|
3504
3573
|
if (fromIndex !== null && fromIndex !== void 0) return {
|
|
3505
3574
|
segments: fromIndex,
|
|
@@ -3507,7 +3576,7 @@ var CalendarIndex = class {
|
|
|
3507
3576
|
};
|
|
3508
3577
|
const key = `${deviceId}:${profile}:${hour}`;
|
|
3509
3578
|
const now = this.deps.now?.() ?? Date.now();
|
|
3510
|
-
const isCurrentHour = hour === Math.floor(now / HOUR_MS$
|
|
3579
|
+
const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
|
|
3511
3580
|
const cached = this.hourSegs.get(key);
|
|
3512
3581
|
if (cached !== void 0) {
|
|
3513
3582
|
if (!isCurrentHour) return {
|
|
@@ -3571,7 +3640,7 @@ var CalendarIndex = class {
|
|
|
3571
3640
|
*/
|
|
3572
3641
|
async dayRanges(deviceId, profile, utcDayMs) {
|
|
3573
3642
|
const day = utcDayOf(utcDayMs);
|
|
3574
|
-
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$
|
|
3643
|
+
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$6), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
|
|
3575
3644
|
}
|
|
3576
3645
|
/**
|
|
3577
3646
|
* Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
|
|
@@ -3585,9 +3654,9 @@ var CalendarIndex = class {
|
|
|
3585
3654
|
*/
|
|
3586
3655
|
async rangesIn(deviceId, profile, fromMs, toMs) {
|
|
3587
3656
|
if (toMs <= fromMs) return [];
|
|
3588
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
3657
|
+
const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
|
|
3589
3658
|
const hours = [];
|
|
3590
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
3659
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
|
|
3591
3660
|
const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
|
|
3592
3661
|
const ranges = [];
|
|
3593
3662
|
for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
|
|
@@ -4045,7 +4114,7 @@ function handleStoreCensusAction(census, logger, input) {
|
|
|
4045
4114
|
}
|
|
4046
4115
|
//#endregion
|
|
4047
4116
|
//#region src/recorder/addon/directory-handler.ts
|
|
4048
|
-
var HOUR_MS$
|
|
4117
|
+
var HOUR_MS$5 = 36e5;
|
|
4049
4118
|
/** A window wider than this is refused: it is a client bug, and answering it
|
|
4050
4119
|
* would walk the archive on the read path — the thing this replaces. */
|
|
4051
4120
|
var MAX_WINDOW_MS = 32 * 864e5;
|
|
@@ -4092,9 +4161,9 @@ function withDirectoryRoute(deps, next) {
|
|
|
4092
4161
|
return;
|
|
4093
4162
|
}
|
|
4094
4163
|
const rows = [];
|
|
4095
|
-
const firstHour = Math.floor(fromMs / HOUR_MS$
|
|
4164
|
+
const firstHour = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
|
|
4096
4165
|
let hydrated = true;
|
|
4097
|
-
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$
|
|
4166
|
+
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$5) {
|
|
4098
4167
|
const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
|
|
4099
4168
|
if (!complete) hydrated = false;
|
|
4100
4169
|
for (const s of segments) {
|
|
@@ -6623,6 +6692,53 @@ function buildRecordingProvider(deps) {
|
|
|
6623
6692
|
bytes: census.bytes
|
|
6624
6693
|
};
|
|
6625
6694
|
},
|
|
6695
|
+
/**
|
|
6696
|
+
* Walk one location's claimed hours and diff them against the disk (D319).
|
|
6697
|
+
*
|
|
6698
|
+
* Refuses rather than guessing when the reconciler is not wired on this
|
|
6699
|
+
* node: a report whose numbers came from nowhere is worse than no report,
|
|
6700
|
+
* and `location-unknown` is the honest shape for "nothing here can answer".
|
|
6701
|
+
*/
|
|
6702
|
+
reconcileLedgerAgainstDisk: async (input) => {
|
|
6703
|
+
const reconciler = deps.ledgerWalk;
|
|
6704
|
+
if (reconciler === void 0) {
|
|
6705
|
+
deps.logger.warn("recorder: ledger walk unavailable on this node — no durable hour ledger is wired here", { meta: { locationId: input.locationId } });
|
|
6706
|
+
return {
|
|
6707
|
+
locationId: input.locationId,
|
|
6708
|
+
applied: input.apply === true,
|
|
6709
|
+
refused: "no-ledger",
|
|
6710
|
+
archiveSegments: null,
|
|
6711
|
+
archiveBytes: null,
|
|
6712
|
+
hoursClaimed: 0,
|
|
6713
|
+
hoursWalked: 0,
|
|
6714
|
+
hoursMissing: 0,
|
|
6715
|
+
listings: 0,
|
|
6716
|
+
segmentsClaimed: 0,
|
|
6717
|
+
ghostSegments: 0,
|
|
6718
|
+
ghostBytes: 0,
|
|
6719
|
+
ghostHoursWhole: 0,
|
|
6720
|
+
forgottenSegments: 0,
|
|
6721
|
+
forgottenBytes: 0,
|
|
6722
|
+
orphanFiles: 0,
|
|
6723
|
+
orphanSample: [],
|
|
6724
|
+
hoursSkipped: 0,
|
|
6725
|
+
skippedByReason: {
|
|
6726
|
+
"live-tail": 0,
|
|
6727
|
+
"listing-error": 0,
|
|
6728
|
+
"path-mismatch": 0,
|
|
6729
|
+
"durable-refused": 0
|
|
6730
|
+
},
|
|
6731
|
+
bounded: false,
|
|
6732
|
+
byDevice: []
|
|
6733
|
+
};
|
|
6734
|
+
}
|
|
6735
|
+
return reconciler.run({
|
|
6736
|
+
locationId: input.locationId,
|
|
6737
|
+
...input.apply !== void 0 ? { apply: input.apply } : {},
|
|
6738
|
+
...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
6739
|
+
...input.profiles !== void 0 ? { profiles: input.profiles } : {}
|
|
6740
|
+
});
|
|
6741
|
+
},
|
|
6626
6742
|
cancelRelocateJob: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
|
|
6627
6743
|
planStorageRebalance: (input) => runRebalance(deps, input, false),
|
|
6628
6744
|
startStorageRebalance: (input) => runRebalance(deps, input, true),
|
|
@@ -7121,7 +7237,7 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
|
7121
7237
|
const p2 = (n) => String(n).padStart(2, "0");
|
|
7122
7238
|
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
7123
7239
|
}
|
|
7124
|
-
var HOUR_MS$
|
|
7240
|
+
var HOUR_MS$4 = 36e5;
|
|
7125
7241
|
/**
|
|
7126
7242
|
* Hydrate ONLY the hours a window spans, instead of walking the archive.
|
|
7127
7243
|
*
|
|
@@ -7166,7 +7282,7 @@ var HOUR_MS$3 = 36e5;
|
|
|
7166
7282
|
*/
|
|
7167
7283
|
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
|
|
7168
7284
|
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
7169
|
-
const liveHour = Math.floor(Date.now() / HOUR_MS$
|
|
7285
|
+
const liveHour = Math.floor(Date.now() / HOUR_MS$4) * HOUR_MS$4;
|
|
7170
7286
|
if (ledger !== void 0) {
|
|
7171
7287
|
const startedMs = Date.now();
|
|
7172
7288
|
const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
|
|
@@ -7203,12 +7319,12 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
7203
7319
|
});
|
|
7204
7320
|
continue;
|
|
7205
7321
|
}
|
|
7206
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
7322
|
+
const first = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
|
|
7207
7323
|
const reuseBefore = index.hydrateReuseStats();
|
|
7208
7324
|
let hoursRead = 0;
|
|
7209
7325
|
let repaired = 0;
|
|
7210
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
7211
|
-
if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$
|
|
7326
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$4) {
|
|
7327
|
+
if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$4) === "hydrated") continue;
|
|
7212
7328
|
for (const profile of profiles) {
|
|
7213
7329
|
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
7214
7330
|
let files;
|
|
@@ -7221,7 +7337,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
7221
7337
|
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
7222
7338
|
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
7223
7339
|
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
7224
|
-
if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$
|
|
7340
|
+
if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$4 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
|
|
7225
7341
|
repaired += 1;
|
|
7226
7342
|
await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
|
|
7227
7343
|
}
|
|
@@ -7485,7 +7601,7 @@ var DurableLedger = class DurableLedger {
|
|
|
7485
7601
|
};
|
|
7486
7602
|
//#endregion
|
|
7487
7603
|
//#region src/recorder/addon/segment-hour-ledger.ts
|
|
7488
|
-
var HOUR_MS$
|
|
7604
|
+
var HOUR_MS$3 = 36e5;
|
|
7489
7605
|
var macrotask$1 = () => new Promise((resolve) => {
|
|
7490
7606
|
setImmediate(resolve);
|
|
7491
7607
|
});
|
|
@@ -7798,7 +7914,7 @@ var EMPTY_ACCOUNTING = {
|
|
|
7798
7914
|
newestMs: null
|
|
7799
7915
|
};
|
|
7800
7916
|
function hourStartMs(startMs) {
|
|
7801
|
-
return Math.floor(startMs / HOUR_MS$
|
|
7917
|
+
return Math.floor(startMs / HOUR_MS$3) * HOUR_MS$3;
|
|
7802
7918
|
}
|
|
7803
7919
|
function segmentHourKey(deviceId, profile, locationId, hour) {
|
|
7804
7920
|
return `${deviceId}|${profile}|${locationId}|${hour}`;
|
|
@@ -8257,7 +8373,7 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
8257
8373
|
/** The hour a write path may touch: current + previous. Materialised at boot
|
|
8258
8374
|
* so {@link recordSegment}'s dedup gate answers from RAM (D49). */
|
|
8259
8375
|
async materializeLiveTail() {
|
|
8260
|
-
const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$
|
|
8376
|
+
const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$3;
|
|
8261
8377
|
const keys = [];
|
|
8262
8378
|
for (const row of this.ledger.snapshot()) if (row.hourStartMs >= oldest && !row.pathsResident) keys.push(row.key);
|
|
8263
8379
|
return (await this.materialize(keys)).length;
|
|
@@ -9047,7 +9163,7 @@ function locationKey(deviceId, locationId) {
|
|
|
9047
9163
|
* Work it does not finish is left for the next pass and REPORTED, never
|
|
9048
9164
|
* silently dropped.
|
|
9049
9165
|
*/
|
|
9050
|
-
var HOUR_MS$
|
|
9166
|
+
var HOUR_MS$2 = 36e5;
|
|
9051
9167
|
/**
|
|
9052
9168
|
* Group resolved locations by PHYSICAL root, so two aliases of one disk are
|
|
9053
9169
|
* probed once and a `true` from each is not mistaken for the file existing in
|
|
@@ -9069,7 +9185,7 @@ function ghostProbeRoots(locations, realpath) {
|
|
|
9069
9185
|
aliases
|
|
9070
9186
|
})).toSorted((a, b) => a.key.localeCompare(b.key));
|
|
9071
9187
|
}
|
|
9072
|
-
function emptyTally() {
|
|
9188
|
+
function emptyTally$1() {
|
|
9073
9189
|
return {
|
|
9074
9190
|
repointedHours: 0,
|
|
9075
9191
|
repointedSegments: 0,
|
|
@@ -9084,7 +9200,7 @@ function emptyTally() {
|
|
|
9084
9200
|
}
|
|
9085
9201
|
/** Every reason at zero — so a reason that never fired is REPORTED as zero
|
|
9086
9202
|
* rather than absent, and the summary shape does not change between passes. */
|
|
9087
|
-
function zeroReasons() {
|
|
9203
|
+
function zeroReasons$1() {
|
|
9088
9204
|
return {
|
|
9089
9205
|
"live-tail": 0,
|
|
9090
9206
|
"detail-unavailable": 0,
|
|
@@ -9110,7 +9226,7 @@ var GhostLocationSweeper = class {
|
|
|
9110
9226
|
async sweep() {
|
|
9111
9227
|
const maxHours = this.deps.maxHoursPerPass ?? 16;
|
|
9112
9228
|
const maxProbes = this.deps.maxProbesPerPass ?? 4e3;
|
|
9113
|
-
const skippedByReason = zeroReasons();
|
|
9229
|
+
const skippedByReason = zeroReasons$1();
|
|
9114
9230
|
const byDevice = /* @__PURE__ */ new Map();
|
|
9115
9231
|
const locations = this.deps.locations();
|
|
9116
9232
|
const roots = ghostProbeRoots(locations, this.deps.realpath);
|
|
@@ -9156,7 +9272,7 @@ var GhostLocationSweeper = class {
|
|
|
9156
9272
|
refused: null
|
|
9157
9273
|
};
|
|
9158
9274
|
}
|
|
9159
|
-
const liveTailFrom = this.deps.now() - HOUR_MS$
|
|
9275
|
+
const liveTailFrom = this.deps.now() - HOUR_MS$2;
|
|
9160
9276
|
let examined = 0;
|
|
9161
9277
|
let probes = 0;
|
|
9162
9278
|
let repointedHours = 0;
|
|
@@ -9168,7 +9284,7 @@ var GhostLocationSweeper = class {
|
|
|
9168
9284
|
const tallyOf = (deviceId) => {
|
|
9169
9285
|
const held = byDevice.get(deviceId);
|
|
9170
9286
|
if (held) return held;
|
|
9171
|
-
const fresh = emptyTally();
|
|
9287
|
+
const fresh = emptyTally$1();
|
|
9172
9288
|
byDevice.set(deviceId, fresh);
|
|
9173
9289
|
return fresh;
|
|
9174
9290
|
};
|
|
@@ -9493,11 +9609,13 @@ Object.freeze({
|
|
|
9493
9609
|
"pipeline-analytics": {
|
|
9494
9610
|
"getAudioEvents": { "limit": 1e3 },
|
|
9495
9611
|
"getKeyEvents": { "limit": 50 },
|
|
9612
|
+
"getKeyEventsBatch": { "limit": 50 },
|
|
9496
9613
|
"getMotionEvents": { "limit": 1e3 },
|
|
9497
9614
|
"getObjectEvents": { "limit": 1e3 },
|
|
9498
9615
|
"getSensorEvents": { "limit": 1e3 },
|
|
9499
9616
|
"listGroups": { "limit": 40 },
|
|
9500
9617
|
"listRecentTracks": { "limit": 200 },
|
|
9618
|
+
"reclaimDebugMedia": { "mode": "report" },
|
|
9501
9619
|
"searchObjectEvents": {
|
|
9502
9620
|
"limit": 50,
|
|
9503
9621
|
"minScore": .2
|
|
@@ -9696,6 +9814,405 @@ function withLastKnownConfig(deps) {
|
|
|
9696
9814
|
};
|
|
9697
9815
|
}
|
|
9698
9816
|
//#endregion
|
|
9817
|
+
//#region src/recorder/addon/ledger-walk-reconcile.ts
|
|
9818
|
+
var HOUR_MS$1 = 36e5;
|
|
9819
|
+
/** Every reason at zero, so a reason that never fired reports as zero rather
|
|
9820
|
+
* than absent and the summary shape is constant between passes. */
|
|
9821
|
+
function zeroReasons() {
|
|
9822
|
+
return {
|
|
9823
|
+
"live-tail": 0,
|
|
9824
|
+
"listing-error": 0,
|
|
9825
|
+
"path-mismatch": 0,
|
|
9826
|
+
"durable-refused": 0
|
|
9827
|
+
};
|
|
9828
|
+
}
|
|
9829
|
+
function emptyTally() {
|
|
9830
|
+
return {
|
|
9831
|
+
hoursWalked: 0,
|
|
9832
|
+
hoursMissing: 0,
|
|
9833
|
+
ghostSegments: 0,
|
|
9834
|
+
ghostBytes: 0,
|
|
9835
|
+
forgottenSegments: 0,
|
|
9836
|
+
orphanFiles: 0
|
|
9837
|
+
};
|
|
9838
|
+
}
|
|
9839
|
+
/** The basename of a segment path — what a listing returns. */
|
|
9840
|
+
function fileName(relPath) {
|
|
9841
|
+
const cut = relPath.lastIndexOf("/");
|
|
9842
|
+
return cut === -1 ? relPath : relPath.slice(cut + 1);
|
|
9843
|
+
}
|
|
9844
|
+
/** Group one archive page into whole hours. A page always carries complete hour
|
|
9845
|
+
* rows (`streamSegments` expands them whole), so no hour is ever split. */
|
|
9846
|
+
function claimsOf(rows) {
|
|
9847
|
+
const byHour = /* @__PURE__ */ new Map();
|
|
9848
|
+
for (const row of rows) {
|
|
9849
|
+
const hour = hourStartMs(row.startMs);
|
|
9850
|
+
const key = `${row.deviceId}|${row.profile}|${hour}`;
|
|
9851
|
+
const held = byHour.get(key);
|
|
9852
|
+
if (held) held.push(row);
|
|
9853
|
+
else byHour.set(key, [row]);
|
|
9854
|
+
}
|
|
9855
|
+
const out = [];
|
|
9856
|
+
for (const group of byHour.values()) {
|
|
9857
|
+
const first = group[0];
|
|
9858
|
+
if (first === void 0) continue;
|
|
9859
|
+
out.push({
|
|
9860
|
+
deviceId: first.deviceId,
|
|
9861
|
+
profile: first.profile,
|
|
9862
|
+
hourStartMs: hourStartMs(first.startMs),
|
|
9863
|
+
rows: group
|
|
9864
|
+
});
|
|
9865
|
+
}
|
|
9866
|
+
return out.toSorted((a, b) => a.hourStartMs - b.hourStartMs || a.deviceId - b.deviceId);
|
|
9867
|
+
}
|
|
9868
|
+
/**
|
|
9869
|
+
* One reconcile pass over one location's claimed hours.
|
|
9870
|
+
*
|
|
9871
|
+
* Never throws: a per-hour failure is a counted refusal and the pass continues;
|
|
9872
|
+
* a precondition failure is a named refusal and the pass does nothing at all.
|
|
9873
|
+
*/
|
|
9874
|
+
var LedgerWalkReconciler = class {
|
|
9875
|
+
deps;
|
|
9876
|
+
constructor(deps) {
|
|
9877
|
+
this.deps = deps;
|
|
9878
|
+
}
|
|
9879
|
+
async run(request) {
|
|
9880
|
+
const apply = request.apply === true;
|
|
9881
|
+
const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
|
|
9882
|
+
if (location === void 0) return this.refusedReport(request.locationId, apply, "location-unknown");
|
|
9883
|
+
if ((location.enabled ?? true) && !location.readOnly) {
|
|
9884
|
+
this.deps.logger.warn("recorder: ledger walk REFUSED — the source is still a write target, so a directory listing is a lower bound, not the truth", { meta: {
|
|
9885
|
+
locationId: location.id,
|
|
9886
|
+
root: location.root,
|
|
9887
|
+
enabled: location.enabled ?? true,
|
|
9888
|
+
readOnly: location.readOnly,
|
|
9889
|
+
remedy: "set the location readOnly (or disable it) and re-run; a LIVE location is reconciled by the mover, under its bound (D318)"
|
|
9890
|
+
} });
|
|
9891
|
+
return this.refusedReport(location.id, apply, "source-writable");
|
|
9892
|
+
}
|
|
9893
|
+
const ledger = this.deps.ledger();
|
|
9894
|
+
if (ledger === null) {
|
|
9895
|
+
this.deps.logger.warn("recorder: ledger walk REFUSED — this node holds no durable hour ledger, so there is no archive to reconcile", { meta: { locationId: location.id } });
|
|
9896
|
+
return this.refusedReport(location.id, apply, "no-ledger");
|
|
9897
|
+
}
|
|
9898
|
+
const anchor = await this.readAnchor(location);
|
|
9899
|
+
if (!anchor.ok) return this.refusedReport(location.id, apply, anchor.refusal);
|
|
9900
|
+
const scope = {
|
|
9901
|
+
locationId: location.id,
|
|
9902
|
+
...request.deviceId !== void 0 ? { deviceId: request.deviceId } : {},
|
|
9903
|
+
...request.profiles !== void 0 && request.profiles.length > 0 ? { profiles: request.profiles } : {}
|
|
9904
|
+
};
|
|
9905
|
+
const census = await ledger.census(scope);
|
|
9906
|
+
if (census === null) {
|
|
9907
|
+
this.deps.logger.warn("recorder: ledger walk REFUSED — the archive could not say what this location holds, so no walk of it can be reported against anything", { meta: { locationId: location.id } });
|
|
9908
|
+
return this.refusedReport(location.id, apply, "archive-unreadable");
|
|
9909
|
+
}
|
|
9910
|
+
const state = {
|
|
9911
|
+
location,
|
|
9912
|
+
ledger,
|
|
9913
|
+
apply,
|
|
9914
|
+
anchor: anchor.volumeId,
|
|
9915
|
+
liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$1,
|
|
9916
|
+
maxHours: this.deps.maxHours ?? 4096,
|
|
9917
|
+
byDevice: /* @__PURE__ */ new Map(),
|
|
9918
|
+
skippedByReason: zeroReasons(),
|
|
9919
|
+
orphanSample: [],
|
|
9920
|
+
hoursClaimed: 0,
|
|
9921
|
+
hoursWalked: 0,
|
|
9922
|
+
hoursMissing: 0,
|
|
9923
|
+
listings: 0,
|
|
9924
|
+
segmentsClaimed: 0,
|
|
9925
|
+
ghostSegments: 0,
|
|
9926
|
+
ghostBytes: 0,
|
|
9927
|
+
ghostHoursWhole: 0,
|
|
9928
|
+
forgottenSegments: 0,
|
|
9929
|
+
forgottenBytes: 0,
|
|
9930
|
+
orphanFiles: 0,
|
|
9931
|
+
hoursSkipped: 0,
|
|
9932
|
+
bounded: false,
|
|
9933
|
+
refused: null
|
|
9934
|
+
};
|
|
9935
|
+
await this.walk(state, scope);
|
|
9936
|
+
const report = {
|
|
9937
|
+
locationId: location.id,
|
|
9938
|
+
applied: apply,
|
|
9939
|
+
refused: state.refused,
|
|
9940
|
+
archiveSegments: census.segments,
|
|
9941
|
+
archiveBytes: census.bytes,
|
|
9942
|
+
hoursClaimed: state.hoursClaimed,
|
|
9943
|
+
hoursWalked: state.hoursWalked,
|
|
9944
|
+
hoursMissing: state.hoursMissing,
|
|
9945
|
+
listings: state.listings,
|
|
9946
|
+
segmentsClaimed: state.segmentsClaimed,
|
|
9947
|
+
ghostSegments: state.ghostSegments,
|
|
9948
|
+
ghostBytes: state.ghostBytes,
|
|
9949
|
+
ghostHoursWhole: state.ghostHoursWhole,
|
|
9950
|
+
forgottenSegments: state.forgottenSegments,
|
|
9951
|
+
forgottenBytes: state.forgottenBytes,
|
|
9952
|
+
orphanFiles: state.orphanFiles,
|
|
9953
|
+
orphanSample: [...state.orphanSample],
|
|
9954
|
+
hoursSkipped: state.hoursSkipped,
|
|
9955
|
+
skippedByReason: { ...state.skippedByReason },
|
|
9956
|
+
bounded: state.bounded,
|
|
9957
|
+
byDevice: [...state.byDevice.entries()].map(([deviceId, tally]) => ({
|
|
9958
|
+
deviceId,
|
|
9959
|
+
...tally
|
|
9960
|
+
})).toSorted((a, b) => a.deviceId - b.deviceId)
|
|
9961
|
+
};
|
|
9962
|
+
this.reportPass(report);
|
|
9963
|
+
return report;
|
|
9964
|
+
}
|
|
9965
|
+
/** Walk the archive page by page, one hour at a time. */
|
|
9966
|
+
async walk(state, scope) {
|
|
9967
|
+
for await (const page of state.ledger.streamSegments(scope)) for (const claim of claimsOf(page)) {
|
|
9968
|
+
if (state.hoursClaimed >= state.maxHours) {
|
|
9969
|
+
state.bounded = true;
|
|
9970
|
+
return;
|
|
9971
|
+
}
|
|
9972
|
+
state.hoursClaimed += 1;
|
|
9973
|
+
state.segmentsClaimed += claim.rows.length;
|
|
9974
|
+
if (!await this.reconcileHour(state, claim)) return;
|
|
9975
|
+
}
|
|
9976
|
+
}
|
|
9977
|
+
/**
|
|
9978
|
+
* Diff ONE hour, and forget what it proved absent.
|
|
9979
|
+
*
|
|
9980
|
+
* `false` ends the whole pass — the only thing that returns it is an anchor
|
|
9981
|
+
* that stopped agreeing, and every listing taken after that point is about a
|
|
9982
|
+
* filesystem this pass did not start on.
|
|
9983
|
+
*/
|
|
9984
|
+
async reconcileHour(state, claim) {
|
|
9985
|
+
const tally = this.tallyOf(state, claim.deviceId);
|
|
9986
|
+
if (claim.hourStartMs >= state.liveTailFrom) {
|
|
9987
|
+
this.skip(state, claim, "live-tail", `hourStartMs=${claim.hourStartMs}`);
|
|
9988
|
+
return true;
|
|
9989
|
+
}
|
|
9990
|
+
const relDir = hourDirRelPath(claim.deviceId, claim.profile, claim.hourStartMs);
|
|
9991
|
+
for (const row of claim.rows) if (parseSegmentPath(row.path) === null || row.path !== `${relDir}/${fileName(row.path)}`) {
|
|
9992
|
+
this.skip(state, claim, "path-mismatch", row.path);
|
|
9993
|
+
return true;
|
|
9994
|
+
}
|
|
9995
|
+
let listing;
|
|
9996
|
+
try {
|
|
9997
|
+
listing = await this.deps.listHour(state.location.root, relDir);
|
|
9998
|
+
} catch (err) {
|
|
9999
|
+
this.skip(state, claim, "listing-error", require_dist.errMsg(err));
|
|
10000
|
+
return true;
|
|
10001
|
+
}
|
|
10002
|
+
state.listings += 1;
|
|
10003
|
+
if (state.listings % 8 === 0) await this.deps.pace();
|
|
10004
|
+
state.hoursWalked += 1;
|
|
10005
|
+
tally.hoursWalked += 1;
|
|
10006
|
+
const diff = this.diff(claim, listing);
|
|
10007
|
+
if (diff.missingDirectory) {
|
|
10008
|
+
state.hoursMissing += 1;
|
|
10009
|
+
tally.hoursMissing += 1;
|
|
10010
|
+
}
|
|
10011
|
+
if (diff.orphans.length > 0) {
|
|
10012
|
+
state.orphanFiles += diff.orphans.length;
|
|
10013
|
+
tally.orphanFiles += diff.orphans.length;
|
|
10014
|
+
for (const orphan of diff.orphans) {
|
|
10015
|
+
if (state.orphanSample.length >= 50) break;
|
|
10016
|
+
state.orphanSample.push(`${relDir}/${orphan}`);
|
|
10017
|
+
}
|
|
10018
|
+
this.deps.logger.warn("recorder: files under a claimed hour that the durable ledger does NOT name — reported, never deleted", {
|
|
10019
|
+
tags: { deviceId: claim.deviceId },
|
|
10020
|
+
meta: {
|
|
10021
|
+
locationId: state.location.id,
|
|
10022
|
+
profile: claim.profile,
|
|
10023
|
+
hourStartMs: claim.hourStartMs,
|
|
10024
|
+
orphans: diff.orphans.length,
|
|
10025
|
+
sample: diff.orphans.slice(0, 5)
|
|
10026
|
+
}
|
|
10027
|
+
});
|
|
10028
|
+
}
|
|
10029
|
+
if (diff.ghosts.length === 0) return true;
|
|
10030
|
+
const ghostBytes = diff.ghosts.reduce((sum, row) => sum + row.bytes, 0);
|
|
10031
|
+
state.ghostSegments += diff.ghosts.length;
|
|
10032
|
+
state.ghostBytes += ghostBytes;
|
|
10033
|
+
tally.ghostSegments += diff.ghosts.length;
|
|
10034
|
+
tally.ghostBytes += ghostBytes;
|
|
10035
|
+
if (diff.ghosts.length === claim.rows.length) state.ghostHoursWhole += 1;
|
|
10036
|
+
const anchor = await this.readAnchor(state.location);
|
|
10037
|
+
if (!anchor.ok || anchor.volumeId !== state.anchor) {
|
|
10038
|
+
state.refused = anchor.ok ? "anchor-moved" : anchor.refusal;
|
|
10039
|
+
this.deps.logger.warn("recorder: ledger walk STOPPED — the volume under this location is not the one the pass started on, so every listing since is about another filesystem and NOTHING more is forgotten", {
|
|
10040
|
+
tags: { deviceId: claim.deviceId },
|
|
10041
|
+
meta: {
|
|
10042
|
+
locationId: state.location.id,
|
|
10043
|
+
root: state.location.root,
|
|
10044
|
+
startedOn: state.anchor,
|
|
10045
|
+
foundNow: anchor.ok ? anchor.volumeId : anchor.refusal,
|
|
10046
|
+
forgottenBeforeStop: state.forgottenSegments
|
|
10047
|
+
}
|
|
10048
|
+
});
|
|
10049
|
+
return false;
|
|
10050
|
+
}
|
|
10051
|
+
this.deps.logger.warn("recorder: durable hour row claims segments the directory does not hold", {
|
|
10052
|
+
tags: { deviceId: claim.deviceId },
|
|
10053
|
+
meta: {
|
|
10054
|
+
locationId: state.location.id,
|
|
10055
|
+
profile: claim.profile,
|
|
10056
|
+
hourStartMs: claim.hourStartMs,
|
|
10057
|
+
claimed: claim.rows.length,
|
|
10058
|
+
ghosts: diff.ghosts.length,
|
|
10059
|
+
ghostBytes,
|
|
10060
|
+
wholeHour: diff.ghosts.length === claim.rows.length,
|
|
10061
|
+
missingDirectory: diff.missingDirectory,
|
|
10062
|
+
apply: state.apply
|
|
10063
|
+
}
|
|
10064
|
+
});
|
|
10065
|
+
if (!state.apply) return true;
|
|
10066
|
+
await state.ledger.dropSegments(diff.ghosts);
|
|
10067
|
+
if (!await this.forgetLanded(state, claim, diff.ghosts)) {
|
|
10068
|
+
this.skip(state, claim, "durable-refused", "the ledger still lists a dropped path");
|
|
10069
|
+
return true;
|
|
10070
|
+
}
|
|
10071
|
+
this.deps.applyToIndex(diff.ghosts.map((row) => row.path));
|
|
10072
|
+
state.forgottenSegments += diff.ghosts.length;
|
|
10073
|
+
state.forgottenBytes += ghostBytes;
|
|
10074
|
+
tally.forgottenSegments += diff.ghosts.length;
|
|
10075
|
+
return true;
|
|
10076
|
+
}
|
|
10077
|
+
/** Did the durable drop actually land? */
|
|
10078
|
+
async forgetLanded(state, claim, ghosts) {
|
|
10079
|
+
const { deviceId, profile, hourStartMs: hour } = claim;
|
|
10080
|
+
if (!state.ledger.knowsHour(deviceId, profile, state.location.id, hour)) return true;
|
|
10081
|
+
const held = await state.ledger.hourPaths(deviceId, profile, state.location.id, hour);
|
|
10082
|
+
if (held === null) return false;
|
|
10083
|
+
const remaining = new Set(held);
|
|
10084
|
+
return ghosts.every((row) => !remaining.has(row.path));
|
|
10085
|
+
}
|
|
10086
|
+
/**
|
|
10087
|
+
* What the hour row claims, against what the directory holds — BOTH ways.
|
|
10088
|
+
*
|
|
10089
|
+
* A missing directory is an empty listing here, which makes every claim a
|
|
10090
|
+
* ghost. That is only admissible because the caller re-reads the volume
|
|
10091
|
+
* anchor before acting on it.
|
|
10092
|
+
*/
|
|
10093
|
+
diff(claim, listing) {
|
|
10094
|
+
const present = new Set((listing ?? []).filter((name) => name.endsWith(".m4s")));
|
|
10095
|
+
const claimed = new Set(claim.rows.map((row) => fileName(row.path)));
|
|
10096
|
+
return {
|
|
10097
|
+
ghosts: claim.rows.filter((row) => !present.has(fileName(row.path))),
|
|
10098
|
+
orphans: [...present].filter((name) => !claimed.has(name)).toSorted(),
|
|
10099
|
+
missingDirectory: listing === null
|
|
10100
|
+
};
|
|
10101
|
+
}
|
|
10102
|
+
/** The volume anchor, or the refusal its absence produces. */
|
|
10103
|
+
async readAnchor(location) {
|
|
10104
|
+
let marker;
|
|
10105
|
+
try {
|
|
10106
|
+
marker = await this.deps.anchorOf(location.root);
|
|
10107
|
+
} catch (err) {
|
|
10108
|
+
this.deps.logger.warn("recorder: ledger walk cannot read the volume marker — an unreadable root decides nothing", { meta: {
|
|
10109
|
+
locationId: location.id,
|
|
10110
|
+
root: location.root,
|
|
10111
|
+
error: require_dist.errMsg(err)
|
|
10112
|
+
} });
|
|
10113
|
+
return {
|
|
10114
|
+
ok: false,
|
|
10115
|
+
refusal: "anchor-unreadable"
|
|
10116
|
+
};
|
|
10117
|
+
}
|
|
10118
|
+
if (marker === null) {
|
|
10119
|
+
this.deps.logger.warn("recorder: ledger walk has NO volume marker at this root — a mountpoint directory without its disk answers ENOENT for every hour, which is indistinguishable from an empty location", { meta: {
|
|
10120
|
+
locationId: location.id,
|
|
10121
|
+
root: location.root
|
|
10122
|
+
} });
|
|
10123
|
+
return {
|
|
10124
|
+
ok: false,
|
|
10125
|
+
refusal: "anchor-absent"
|
|
10126
|
+
};
|
|
10127
|
+
}
|
|
10128
|
+
return {
|
|
10129
|
+
ok: true,
|
|
10130
|
+
volumeId: marker
|
|
10131
|
+
};
|
|
10132
|
+
}
|
|
10133
|
+
tallyOf(state, deviceId) {
|
|
10134
|
+
const held = state.byDevice.get(deviceId);
|
|
10135
|
+
if (held) return held;
|
|
10136
|
+
const fresh = emptyTally();
|
|
10137
|
+
state.byDevice.set(deviceId, fresh);
|
|
10138
|
+
return fresh;
|
|
10139
|
+
}
|
|
10140
|
+
skip(state, claim, reason, detail) {
|
|
10141
|
+
state.hoursSkipped += 1;
|
|
10142
|
+
state.skippedByReason[reason] += 1;
|
|
10143
|
+
this.deps.logger.debug("recorder: ledger walk left an hour exactly as it found it", {
|
|
10144
|
+
tags: { deviceId: claim.deviceId },
|
|
10145
|
+
meta: {
|
|
10146
|
+
locationId: state.location.id,
|
|
10147
|
+
profile: claim.profile,
|
|
10148
|
+
hourStartMs: claim.hourStartMs,
|
|
10149
|
+
reason,
|
|
10150
|
+
detail
|
|
10151
|
+
}
|
|
10152
|
+
});
|
|
10153
|
+
}
|
|
10154
|
+
refusedReport(locationId, applied, refused) {
|
|
10155
|
+
return {
|
|
10156
|
+
locationId,
|
|
10157
|
+
applied,
|
|
10158
|
+
refused,
|
|
10159
|
+
archiveSegments: null,
|
|
10160
|
+
archiveBytes: null,
|
|
10161
|
+
hoursClaimed: 0,
|
|
10162
|
+
hoursWalked: 0,
|
|
10163
|
+
hoursMissing: 0,
|
|
10164
|
+
listings: 0,
|
|
10165
|
+
segmentsClaimed: 0,
|
|
10166
|
+
ghostSegments: 0,
|
|
10167
|
+
ghostBytes: 0,
|
|
10168
|
+
ghostHoursWhole: 0,
|
|
10169
|
+
forgottenSegments: 0,
|
|
10170
|
+
forgottenBytes: 0,
|
|
10171
|
+
orphanFiles: 0,
|
|
10172
|
+
orphanSample: [],
|
|
10173
|
+
hoursSkipped: 0,
|
|
10174
|
+
skippedByReason: zeroReasons(),
|
|
10175
|
+
bounded: false,
|
|
10176
|
+
byDevice: []
|
|
10177
|
+
};
|
|
10178
|
+
}
|
|
10179
|
+
/** One line per camera, then one for the pass. */
|
|
10180
|
+
reportPass(report) {
|
|
10181
|
+
for (const device of report.byDevice) this.deps.logger.info("recorder: ledger walk — camera summary", {
|
|
10182
|
+
tags: { deviceId: device.deviceId },
|
|
10183
|
+
meta: {
|
|
10184
|
+
locationId: report.locationId,
|
|
10185
|
+
hoursWalked: device.hoursWalked,
|
|
10186
|
+
hoursMissing: device.hoursMissing,
|
|
10187
|
+
ghostSegments: device.ghostSegments,
|
|
10188
|
+
ghostBytes: device.ghostBytes,
|
|
10189
|
+
forgottenSegments: device.forgottenSegments,
|
|
10190
|
+
orphanFiles: device.orphanFiles
|
|
10191
|
+
}
|
|
10192
|
+
});
|
|
10193
|
+
this.deps.logger.info("recorder: ledger walk complete", { meta: {
|
|
10194
|
+
locationId: report.locationId,
|
|
10195
|
+
applied: report.applied,
|
|
10196
|
+
refused: report.refused,
|
|
10197
|
+
archiveSegments: report.archiveSegments,
|
|
10198
|
+
segmentsClaimed: report.segmentsClaimed,
|
|
10199
|
+
hoursClaimed: report.hoursClaimed,
|
|
10200
|
+
hoursWalked: report.hoursWalked,
|
|
10201
|
+
hoursMissing: report.hoursMissing,
|
|
10202
|
+
listings: report.listings,
|
|
10203
|
+
ghostSegments: report.ghostSegments,
|
|
10204
|
+
ghostBytes: report.ghostBytes,
|
|
10205
|
+
ghostHoursWhole: report.ghostHoursWhole,
|
|
10206
|
+
forgottenSegments: report.forgottenSegments,
|
|
10207
|
+
forgottenBytes: report.forgottenBytes,
|
|
10208
|
+
orphanFiles: report.orphanFiles,
|
|
10209
|
+
hoursSkipped: report.hoursSkipped,
|
|
10210
|
+
skippedByReason: report.skippedByReason,
|
|
10211
|
+
bounded: report.bounded
|
|
10212
|
+
} });
|
|
10213
|
+
}
|
|
10214
|
+
};
|
|
10215
|
+
//#endregion
|
|
9699
10216
|
//#region src/recorder/addon/location-capacity.ts
|
|
9700
10217
|
/** Human-readable failure text for a thrown value of unknown shape. */
|
|
9701
10218
|
function reasonOf(err) {
|
|
@@ -15056,6 +15573,17 @@ var GHOST_LOCATION_SWEEP_SLOW_AFTER_MS = 10 * 6e4;
|
|
|
15056
15573
|
/** Awaited every `GHOST_PROBE_BATCH` probes. The janitor's brake: it keeps a
|
|
15057
15574
|
* reconcile pass off the array's critical path rather than racing it. */
|
|
15058
15575
|
var GHOST_LOCATION_PROBE_PAUSE_MS = 50;
|
|
15576
|
+
/**
|
|
15577
|
+
* Awaited every `WALK_HOUR_BATCH` hour listings by the ledger↔disk walk (D319).
|
|
15578
|
+
*
|
|
15579
|
+
* Twice the ghost sweep's brake, because the syscall between brakes is far
|
|
15580
|
+
* heavier: that pass paces `storage.exists` on one path, this one paces a
|
|
15581
|
+
* `readdir` of a whole hour directory — measured at ~650 ms on the live share
|
|
15582
|
+
* under load. The walk is operator-armed and one-off, so the right trade is to
|
|
15583
|
+
* be slower and invisible rather than fast and felt: a `find` over this array
|
|
15584
|
+
* hung an SSH session on 2026-08-29.
|
|
15585
|
+
*/
|
|
15586
|
+
var LEDGER_WALK_PAUSE_MS = 100;
|
|
15059
15587
|
/** What the per-volume usage row reports when the hour ledger is unavailable:
|
|
15060
15588
|
* nothing measured, rather than a total counted off a partial RAM index. */
|
|
15061
15589
|
var EMPTY_ARCHIVE_ACCOUNTING = {
|
|
@@ -15331,6 +15859,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
15331
15859
|
await node_fs.promises.unlink(fromAbs);
|
|
15332
15860
|
}
|
|
15333
15861
|
},
|
|
15862
|
+
archiveHourPaths: async (deviceId, profile, locationId, hourStartMs) => await this.segmentHours?.hourPaths(deviceId, profile, locationId, hourStartMs) ?? null,
|
|
15334
15863
|
removeDir: async (locationId, relDir) => {
|
|
15335
15864
|
const loc = this.resolvedLocations.find((l) => l.id === locationId);
|
|
15336
15865
|
if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
|
|
@@ -15520,6 +16049,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
15520
16049
|
capacity: (root) => this.locationCapacity(root),
|
|
15521
16050
|
archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
|
|
15522
16051
|
archive: () => this.segmentHours,
|
|
16052
|
+
ledgerWalk: this.ledgerWalkReconciler(),
|
|
15523
16053
|
hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
|
|
15524
16054
|
reconcileHourLedger: async (deviceId) => {
|
|
15525
16055
|
await this.segmentHours?.reconcileFromIndex(this.index, [deviceId], Date.now());
|
|
@@ -15768,6 +16298,44 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
15768
16298
|
return resolveEvictionDomain(this.resolvedLocations, locationId);
|
|
15769
16299
|
}
|
|
15770
16300
|
/**
|
|
16301
|
+
* The operator's "does my ledger tell the truth?" pass (D319).
|
|
16302
|
+
*
|
|
16303
|
+
* Built once and handed to the provider, but every dependency it holds is a
|
|
16304
|
+
* THUNK: `this.segmentHours` is null until the collection declares, and
|
|
16305
|
+
* `this.resolvedLocations` is re-resolved whenever a disk appears. A
|
|
16306
|
+
* reconciler that captured either would answer for the boot instant forever.
|
|
16307
|
+
*
|
|
16308
|
+
* `listHour` is a SHALLOW `readdir` of exactly one hour directory — never
|
|
16309
|
+
* `recursive: true`, which is the call that takes minutes over `/recordings`
|
|
16310
|
+
* and the reason a shell `find` hung an SSH session. A directory that is not
|
|
16311
|
+
* there answers `null`; anything else throws, and a throw decides nothing.
|
|
16312
|
+
*
|
|
16313
|
+
* `anchorOf` is the placement volume marker, which lives ON the disk: it is
|
|
16314
|
+
* the only thing here that can tell a location whose hours were deleted from
|
|
16315
|
+
* a mountpoint whose disk went away.
|
|
16316
|
+
*/
|
|
16317
|
+
ledgerWalkReconciler() {
|
|
16318
|
+
return new LedgerWalkReconciler({
|
|
16319
|
+
ledger: () => this.segmentHours,
|
|
16320
|
+
locations: () => this.resolvedLocations,
|
|
16321
|
+
listHour: async (root, relDir) => {
|
|
16322
|
+
try {
|
|
16323
|
+
return await node_fs.promises.readdir(node_path.default.join(root, relDir));
|
|
16324
|
+
} catch (err) {
|
|
16325
|
+
if (err.code === "ENOENT") return null;
|
|
16326
|
+
throw err;
|
|
16327
|
+
}
|
|
16328
|
+
},
|
|
16329
|
+
anchorOf: async (root) => (await readLocationMarker(root))?.volumeId ?? null,
|
|
16330
|
+
applyToIndex: (paths) => {
|
|
16331
|
+
this.index.removeSegments(paths);
|
|
16332
|
+
},
|
|
16333
|
+
logger: this.ctx.logger,
|
|
16334
|
+
now: () => Date.now(),
|
|
16335
|
+
pace: () => require_dist.sleep(LEDGER_WALK_PAUSE_MS)
|
|
16336
|
+
});
|
|
16337
|
+
}
|
|
16338
|
+
/**
|
|
15771
16339
|
* Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
|
|
15772
16340
|
* recordings locations and rebuild the in-memory index from `storage.list`
|
|
15773
16341
|
* for every device that has a persisted config. The list result is the
|