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