@camstack/system 1.2.165 → 1.2.166
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/addon-runner.js +1 -1
- package/dist/addon-runner.mjs +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/location-occupancy.d.ts +19 -0
- package/dist/builtins/storage-orchestrator/storage-cleanup-coordinator.d.ts +68 -0
- package/dist/builtins/storage-orchestrator/storage-migration-coordinator.d.ts +14 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +13 -8
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +441 -100
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +443 -100
- package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +11 -13
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-BhE8zNfY.js → dist-CFTKQGym.js} +185 -7
- package/dist/{dist-BpsHt11f.mjs → dist-Dw9cm1NS.mjs} +180 -8
- package/dist/index.js +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{manifest-system-deps-uflHvHDB.js → manifest-system-deps-BIxY1Y5e.js} +1 -1
- package/dist/{manifest-system-deps-D2GebQTp.mjs → manifest-system-deps-DTz_zNQf.mjs} +1 -1
- package/dist/{retired-settings-keys-CqDbI-vK.js → retired-settings-keys-dehyu2N3.js} +1 -1
- package/dist/{retired-settings-keys-DLwieFXR.mjs → retired-settings-keys-diW1NmyP.mjs} +1 -1
- package/package.json +1 -1
|
@@ -3,7 +3,8 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-CFTKQGym.js");
|
|
7
|
+
let zod = require("zod");
|
|
7
8
|
let node_crypto = require("node:crypto");
|
|
8
9
|
let node_fs_promises = require("node:fs/promises");
|
|
9
10
|
node_fs_promises = require_chunk.__toESM(node_fs_promises);
|
|
@@ -292,17 +293,22 @@ var StorageMigrationCoordinator = class {
|
|
|
292
293
|
for (const storageClass of STORAGE_CLASSES) {
|
|
293
294
|
const targetId = input.destinations[storageClass];
|
|
294
295
|
if (targetId === void 0) continue;
|
|
295
|
-
const
|
|
296
|
-
|
|
296
|
+
const namedSourceId = input.sources?.[storageClass];
|
|
297
|
+
const defaultLoc = this.deps.locations.getDefaultLocation(storageClass);
|
|
298
|
+
const source = namedSourceId ? this.deps.locations.getLocationById(namedSourceId) : defaultLoc;
|
|
299
|
+
if (!source) throw new Error(namedSourceId ? `Storage location "${namedSourceId}" not found` : `No default storage location for "${storageClass}"`);
|
|
300
|
+
if (source.type !== storageClass) throw new Error(`Storage location "${source.id}" is type "${source.type}", expected "${storageClass}"`);
|
|
297
301
|
const target = this.deps.locations.getLocationById(targetId);
|
|
298
302
|
if (!target) throw new Error(`Storage location "${targetId}" not found`);
|
|
299
303
|
if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
|
|
300
|
-
if (source.id === target.id) throw new Error(`Storage location "${targetId}" is already the "${storageClass}" default`);
|
|
304
|
+
if (source.id === target.id) throw new Error(namedSourceId ? `Source and destination are the same location ("${targetId}")` : `Storage location "${targetId}" is already the "${storageClass}" default`);
|
|
305
|
+
const freezeSource = defaultLoc !== void 0 && source.id !== defaultLoc.id;
|
|
301
306
|
if (!MOVER_CLASSES.includes(storageClass)) throw new Error(`No mover owns "${storageClass}" — the migration can repoint its default but cannot move its bytes. Move it by hand, then repoint the default with upsertLocation.`);
|
|
302
307
|
moves.push({
|
|
303
308
|
storageClass,
|
|
304
309
|
fromLocationId: source.id,
|
|
305
310
|
toLocationId: target.id,
|
|
311
|
+
freezeSource,
|
|
306
312
|
moverJobId: null,
|
|
307
313
|
state: null,
|
|
308
314
|
error: null,
|
|
@@ -333,11 +339,13 @@ var StorageMigrationCoordinator = class {
|
|
|
333
339
|
}
|
|
334
340
|
return {
|
|
335
341
|
destinations: input.destinations,
|
|
342
|
+
sources: input.sources,
|
|
336
343
|
mode,
|
|
337
|
-
moves: moves.map(({ storageClass, fromLocationId, toLocationId }) => ({
|
|
344
|
+
moves: moves.map(({ storageClass, fromLocationId, toLocationId, freezeSource }) => ({
|
|
338
345
|
storageClass,
|
|
339
346
|
fromLocationId,
|
|
340
|
-
toLocationId
|
|
347
|
+
toLocationId,
|
|
348
|
+
freezeSource
|
|
341
349
|
})),
|
|
342
350
|
findings
|
|
343
351
|
};
|
|
@@ -367,12 +375,12 @@ var StorageMigrationCoordinator = class {
|
|
|
367
375
|
this.startReserved = true;
|
|
368
376
|
try {
|
|
369
377
|
const existing = await this.status();
|
|
370
|
-
if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
|
|
378
|
+
if (existing && isTerminal$1(existing) && existing.pausedParticipants.length > 0) {
|
|
371
379
|
await this.releaseAfterTerminal(existing);
|
|
372
380
|
await this.persist(existing);
|
|
373
381
|
if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
|
|
374
382
|
}
|
|
375
|
-
if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
|
|
383
|
+
if (existing && !isTerminal$1(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
|
|
376
384
|
const plan = await this.plan(input);
|
|
377
385
|
const now = this.deps.now();
|
|
378
386
|
const job = {
|
|
@@ -380,6 +388,7 @@ var StorageMigrationCoordinator = class {
|
|
|
380
388
|
phase: "planning",
|
|
381
389
|
mode: plan.mode,
|
|
382
390
|
destinations: input.destinations,
|
|
391
|
+
sources: input.sources,
|
|
383
392
|
throttleMbps: input.throttleMbps ?? 40,
|
|
384
393
|
moves: plan.moves.map((move) => ({
|
|
385
394
|
...move,
|
|
@@ -411,10 +420,15 @@ var StorageMigrationCoordinator = class {
|
|
|
411
420
|
if (jobId !== void 0 && job?.jobId !== jobId) return null;
|
|
412
421
|
return job;
|
|
413
422
|
}
|
|
423
|
+
/** Finished jobs, newest first, excluding the one `status` currently shows. */
|
|
424
|
+
async history() {
|
|
425
|
+
const current = await this.status();
|
|
426
|
+
return (await this.deps.history?.get() ?? []).filter((job) => job.jobId !== current?.jobId);
|
|
427
|
+
}
|
|
414
428
|
async cancel(jobId) {
|
|
415
429
|
const job = await this.status(jobId);
|
|
416
430
|
const cutoverInFlight = job !== null && job.repointed && (job.phase === "refreshing" || job.phase === "resuming");
|
|
417
|
-
if (!job || isTerminal(job) || cutoverInFlight) return false;
|
|
431
|
+
if (!job || isTerminal$1(job) || cutoverInFlight) return false;
|
|
418
432
|
job.cancelRequested = true;
|
|
419
433
|
await this.persist(job);
|
|
420
434
|
await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
|
|
@@ -471,18 +485,22 @@ var StorageMigrationCoordinator = class {
|
|
|
471
485
|
const target = this.deps.locations.getDefaultLocation(storageClass);
|
|
472
486
|
if (!target) continue;
|
|
473
487
|
if (laneOf(storageClass) === "media") {
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
488
|
+
for (const source of this.deps.locations.listLocations({ type: storageClass })) {
|
|
489
|
+
if (source.id === target.id) continue;
|
|
490
|
+
const count = await unanswerable(this.deps.participants.analytics.residue({
|
|
491
|
+
toLocationId: target.id,
|
|
492
|
+
fromLocationId: source.id,
|
|
493
|
+
mode: "move"
|
|
494
|
+
}));
|
|
495
|
+
if (count !== null && count.rows === 0) continue;
|
|
496
|
+
out.push({
|
|
497
|
+
storageClass,
|
|
498
|
+
fromLocationId: source.id,
|
|
499
|
+
toLocationId: target.id,
|
|
500
|
+
items: count?.rows ?? null,
|
|
501
|
+
bytes: null
|
|
502
|
+
});
|
|
503
|
+
}
|
|
486
504
|
continue;
|
|
487
505
|
}
|
|
488
506
|
for (const source of this.deps.locations.listLocations({ type: storageClass })) {
|
|
@@ -536,12 +554,12 @@ var StorageMigrationCoordinator = class {
|
|
|
536
554
|
this.startReserved = true;
|
|
537
555
|
try {
|
|
538
556
|
const existing = await this.status();
|
|
539
|
-
if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
|
|
557
|
+
if (existing && isTerminal$1(existing) && existing.pausedParticipants.length > 0) {
|
|
540
558
|
await this.releaseAfterTerminal(existing);
|
|
541
559
|
await this.persist(existing);
|
|
542
560
|
if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
|
|
543
561
|
}
|
|
544
|
-
if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
|
|
562
|
+
if (existing && !isTerminal$1(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
|
|
545
563
|
for (const storageClass of input.classes) {
|
|
546
564
|
if (MOVER_CLASSES.includes(storageClass)) continue;
|
|
547
565
|
throw new Error(`No mover owns "${storageClass}" — there is nothing that can drain it. Move it by hand.`);
|
|
@@ -606,7 +624,7 @@ var StorageMigrationCoordinator = class {
|
|
|
606
624
|
await this.persist(job);
|
|
607
625
|
return;
|
|
608
626
|
}
|
|
609
|
-
if (isTerminal(job)) {
|
|
627
|
+
if (isTerminal$1(job)) {
|
|
610
628
|
if (job.pausedParticipants.length > 0) {
|
|
611
629
|
await this.releaseAfterTerminal(job);
|
|
612
630
|
await this.persist(job);
|
|
@@ -667,11 +685,12 @@ var StorageMigrationCoordinator = class {
|
|
|
667
685
|
if (job.phase === "verifying" && !nonBlocking) {
|
|
668
686
|
await this.verifyMoves(job);
|
|
669
687
|
if (job.cancelRequested) return this.finishCancelled(job);
|
|
688
|
+
await this.freezeDrainedSources(job);
|
|
670
689
|
await this.setPhase(job, "repointing");
|
|
671
690
|
}
|
|
672
691
|
if (job.phase === "repointing") {
|
|
673
|
-
const targets = new Map(job.moves.map((move) => [move.storageClass, move.toLocationId]));
|
|
674
|
-
await this.deps.locations.setDefaultLocations(targets);
|
|
692
|
+
const targets = new Map(job.moves.filter((move) => move.freezeSource !== true).map((move) => [move.storageClass, move.toLocationId]));
|
|
693
|
+
if (targets.size > 0) await this.deps.locations.setDefaultLocations(targets);
|
|
675
694
|
job.repointed = true;
|
|
676
695
|
await this.setPhase(job, "refreshing");
|
|
677
696
|
}
|
|
@@ -694,6 +713,7 @@ var StorageMigrationCoordinator = class {
|
|
|
694
713
|
if (job.phase === "verifying" && nonBlocking) {
|
|
695
714
|
await this.verifyMoves(job);
|
|
696
715
|
if (job.cancelRequested) return this.finishCancelled(job);
|
|
716
|
+
await this.freezeDrainedSources(job);
|
|
697
717
|
await this.setPhase(job, "done");
|
|
698
718
|
}
|
|
699
719
|
} catch (err) {
|
|
@@ -709,7 +729,7 @@ var StorageMigrationCoordinator = class {
|
|
|
709
729
|
await this.releaseAfterTerminal(job);
|
|
710
730
|
await this.persist(job);
|
|
711
731
|
} finally {
|
|
712
|
-
if (isTerminal(job)) {
|
|
732
|
+
if (isTerminal$1(job)) {
|
|
713
733
|
await this.releaseAfterTerminal(job);
|
|
714
734
|
this.active = job;
|
|
715
735
|
}
|
|
@@ -869,6 +889,24 @@ var StorageMigrationCoordinator = class {
|
|
|
869
889
|
for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
|
|
870
890
|
}
|
|
871
891
|
/**
|
|
892
|
+
* After a from→to copy whose source was NOT the class default: stop new
|
|
893
|
+
* writes to `from`. The default is unchanged (that is the whole point of
|
|
894
|
+
* naming a non-default source). A location that is already disabled is
|
|
895
|
+
* left alone.
|
|
896
|
+
*/
|
|
897
|
+
freezeDrainedSources(job) {
|
|
898
|
+
for (const move of job.moves) {
|
|
899
|
+
if (move.freezeSource !== true) continue;
|
|
900
|
+
const loc = this.deps.locations.getLocationById(move.fromLocationId);
|
|
901
|
+
if (!loc || loc.enabled === false) continue;
|
|
902
|
+
const { createdAt: _c, updatedAt: _u, capacity: _cap, ...rest } = loc;
|
|
903
|
+
this.deps.locations.upsertLocation({
|
|
904
|
+
...rest,
|
|
905
|
+
enabled: false
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
872
910
|
* Arm one class's mover.
|
|
873
911
|
*
|
|
874
912
|
* `draining` runs after every writer has been resumed, so there is no lease
|
|
@@ -881,6 +919,7 @@ var StorageMigrationCoordinator = class {
|
|
|
881
919
|
if (laneOf(move.storageClass) === "media") {
|
|
882
920
|
const input = {
|
|
883
921
|
toLocationId: move.toLocationId,
|
|
922
|
+
fromLocationId: move.fromLocationId,
|
|
884
923
|
throttleMbps: job.throttleMbps,
|
|
885
924
|
mode: "move"
|
|
886
925
|
};
|
|
@@ -914,7 +953,7 @@ var StorageMigrationCoordinator = class {
|
|
|
914
953
|
}
|
|
915
954
|
async setPhase(job, phase) {
|
|
916
955
|
job.phase = phase;
|
|
917
|
-
if (isTerminal(job)) job.finishedAt = this.deps.now();
|
|
956
|
+
if (isTerminal$1(job)) job.finishedAt = this.deps.now();
|
|
918
957
|
await this.persist(job);
|
|
919
958
|
}
|
|
920
959
|
async finishCancelled(job) {
|
|
@@ -926,6 +965,9 @@ var StorageMigrationCoordinator = class {
|
|
|
926
965
|
async persist(job) {
|
|
927
966
|
job.updatedAt = this.deps.now();
|
|
928
967
|
await this.deps.state.set(job);
|
|
968
|
+
if (!isTerminal$1(job) || this.deps.history === void 0) return;
|
|
969
|
+
const prev = [...await this.deps.history.get() ?? []];
|
|
970
|
+
await this.deps.history.set([job, ...prev.filter((row) => row.jobId !== job.jobId)].slice(0, HISTORY_CAP));
|
|
929
971
|
}
|
|
930
972
|
};
|
|
931
973
|
/**
|
|
@@ -951,9 +993,10 @@ function requireLease(job) {
|
|
|
951
993
|
if (job.pauseLeaseId === null) throw new Error("storage migration has no maintenance lease");
|
|
952
994
|
return job.pauseLeaseId;
|
|
953
995
|
}
|
|
954
|
-
function isTerminal(job) {
|
|
996
|
+
function isTerminal$1(job) {
|
|
955
997
|
return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
|
|
956
998
|
}
|
|
999
|
+
var HISTORY_CAP = 20;
|
|
957
1000
|
/**
|
|
958
1001
|
* Which single-flight engine owns a class.
|
|
959
1002
|
*/
|
|
@@ -1075,6 +1118,78 @@ function canProbeOccupancyLocally(locality) {
|
|
|
1075
1118
|
return locality === true;
|
|
1076
1119
|
}
|
|
1077
1120
|
//#endregion
|
|
1121
|
+
//#region src/builtins/storage-orchestrator/location-occupancy.ts
|
|
1122
|
+
/**
|
|
1123
|
+
* Delete-time occupancy of a location's on-disk root.
|
|
1124
|
+
*
|
|
1125
|
+
* A recordings disk may host several classes at one `basePath`
|
|
1126
|
+
* (`<camera>/high|mid`, `<camera>/low`, `<camera>/events`). Occupancy for
|
|
1127
|
+
* a class is therefore the class subtree, not the shared root — otherwise a
|
|
1128
|
+
* drained `eventMedia:default` still looks full because high footage is
|
|
1129
|
+
* writing next to it (observed 2026-09-01).
|
|
1130
|
+
*
|
|
1131
|
+
* The walk is shallow: one `readdir` of the root (one dirent per camera)
|
|
1132
|
+
* plus `opendir`+first-entry of each named subtree. No recursive find of
|
|
1133
|
+
* segments — that is the walk that saturates a USB disk.
|
|
1134
|
+
*/
|
|
1135
|
+
/**
|
|
1136
|
+
* Class → directories under `<root>/<camera>/`. Same map as the discard
|
|
1137
|
+
* wipe (`DISCARD_SUBTREES` in the recorder / pipeline-analytics addons).
|
|
1138
|
+
* Types not listed keep the legacy "any non-dot entry at the root" rule
|
|
1139
|
+
* (backups, addon-data, …).
|
|
1140
|
+
*/
|
|
1141
|
+
var STORAGE_CLASS_SUBTREES = {
|
|
1142
|
+
eventMedia: ["events"],
|
|
1143
|
+
recordingsLow: ["low"],
|
|
1144
|
+
recordings: ["high", "mid"]
|
|
1145
|
+
};
|
|
1146
|
+
function isDotEntry(name) {
|
|
1147
|
+
return name.startsWith(".");
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* True when `dir` exists and contains at least one dirent. Missing → false.
|
|
1151
|
+
* Any other errno throws so the caller can answer `unknown` (D49).
|
|
1152
|
+
*/
|
|
1153
|
+
async function dirHasAnyEntry(dir) {
|
|
1154
|
+
let handle;
|
|
1155
|
+
try {
|
|
1156
|
+
handle = await (0, node_fs_promises.opendir)(dir);
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
if (err.code === "ENOENT") return false;
|
|
1159
|
+
throw err;
|
|
1160
|
+
}
|
|
1161
|
+
try {
|
|
1162
|
+
return await handle.read() !== null;
|
|
1163
|
+
} finally {
|
|
1164
|
+
await handle.close();
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Occupancy of `basePath` for a location of `type`.
|
|
1169
|
+
*
|
|
1170
|
+
* - missing root → `empty` (nothing this delete can strand)
|
|
1171
|
+
* - class-mapped type → occupied iff any `<camera>/<subtree>` has an entry
|
|
1172
|
+
* - other types → occupied iff the root has a non-dot entry
|
|
1173
|
+
*/
|
|
1174
|
+
async function probeLocalDirectoryOccupancy(input) {
|
|
1175
|
+
let names;
|
|
1176
|
+
try {
|
|
1177
|
+
names = await (0, node_fs_promises.readdir)(input.basePath);
|
|
1178
|
+
} catch (err) {
|
|
1179
|
+
if (err.code === "ENOENT") return "empty";
|
|
1180
|
+
return "unknown";
|
|
1181
|
+
}
|
|
1182
|
+
const visible = names.filter((name) => !isDotEntry(name));
|
|
1183
|
+
const subtrees = STORAGE_CLASS_SUBTREES[input.type];
|
|
1184
|
+
if (subtrees === void 0) return visible.length > 0 ? "occupied" : "empty";
|
|
1185
|
+
try {
|
|
1186
|
+
for (const camera of visible) for (const subtree of subtrees) if (await dirHasAnyEntry(node_path.default.join(input.basePath, camera, subtree))) return "occupied";
|
|
1187
|
+
} catch {
|
|
1188
|
+
return "unknown";
|
|
1189
|
+
}
|
|
1190
|
+
return "empty";
|
|
1191
|
+
}
|
|
1192
|
+
//#endregion
|
|
1078
1193
|
//#region src/builtins/storage-orchestrator/data-store-dispatch.ts
|
|
1079
1194
|
/**
|
|
1080
1195
|
* Pick the engine to serve a call.
|
|
@@ -1253,6 +1368,173 @@ async function collectProviderInfos(providers, onError) {
|
|
|
1253
1368
|
return out;
|
|
1254
1369
|
}
|
|
1255
1370
|
//#endregion
|
|
1371
|
+
//#region src/builtins/storage-orchestrator/storage-cleanup-coordinator.ts
|
|
1372
|
+
var FOOTAGE_TYPES = new Set(["recordings", "recordingsLow"]);
|
|
1373
|
+
function isTerminal(job) {
|
|
1374
|
+
return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
|
|
1375
|
+
}
|
|
1376
|
+
function footageFrozen(location) {
|
|
1377
|
+
return location.enabled === false || location.config["readOnly"] === true;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* One operator-facing cleanup of leftover analytics rows/blobs, optional
|
|
1381
|
+
* debug media, and ghost ledger rows on frozen footage locations.
|
|
1382
|
+
*
|
|
1383
|
+
* Each addon call either returns immediately (start+poll) or is a bounded
|
|
1384
|
+
* ledger walk. The HTTP caller never waits on the full pass.
|
|
1385
|
+
*/
|
|
1386
|
+
var StorageCleanupCoordinator = class {
|
|
1387
|
+
deps;
|
|
1388
|
+
active = null;
|
|
1389
|
+
startReserved = false;
|
|
1390
|
+
constructor(deps) {
|
|
1391
|
+
this.deps = deps;
|
|
1392
|
+
}
|
|
1393
|
+
async start(input) {
|
|
1394
|
+
if (this.startReserved) throw new Error("storage cleanup is already active");
|
|
1395
|
+
this.startReserved = true;
|
|
1396
|
+
try {
|
|
1397
|
+
const existing = await this.status();
|
|
1398
|
+
if (existing && !isTerminal(existing)) throw new Error(`storage cleanup is already active (${existing.jobId})`);
|
|
1399
|
+
const now = this.deps.now();
|
|
1400
|
+
const job = {
|
|
1401
|
+
jobId: this.deps.newId(),
|
|
1402
|
+
phase: "orphans",
|
|
1403
|
+
includeDebugMedia: input.includeDebugMedia === true,
|
|
1404
|
+
orphansReclaimed: 0,
|
|
1405
|
+
orphanBytesReclaimed: 0,
|
|
1406
|
+
debugMediaReclaimed: 0,
|
|
1407
|
+
debugMediaBytesReclaimed: 0,
|
|
1408
|
+
ghostsForgotten: 0,
|
|
1409
|
+
ghostBytesForgotten: 0,
|
|
1410
|
+
detail: "Starting orphan reclaim",
|
|
1411
|
+
cancelRequested: false,
|
|
1412
|
+
startedAt: now,
|
|
1413
|
+
updatedAt: now,
|
|
1414
|
+
finishedAt: null,
|
|
1415
|
+
error: null
|
|
1416
|
+
};
|
|
1417
|
+
await this.persist(job);
|
|
1418
|
+
this.active = job;
|
|
1419
|
+
this.run(job);
|
|
1420
|
+
return job.jobId;
|
|
1421
|
+
} finally {
|
|
1422
|
+
this.startReserved = false;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
async status(jobId) {
|
|
1426
|
+
const job = this.active ?? await this.deps.state.get();
|
|
1427
|
+
if (jobId !== void 0 && job?.jobId !== jobId) return null;
|
|
1428
|
+
return job;
|
|
1429
|
+
}
|
|
1430
|
+
async cancel(jobId) {
|
|
1431
|
+
const job = await this.status(jobId);
|
|
1432
|
+
if (!job || isTerminal(job)) return false;
|
|
1433
|
+
job.cancelRequested = true;
|
|
1434
|
+
job.detail = "Cancel requested — finishing the current step";
|
|
1435
|
+
await this.persist(job);
|
|
1436
|
+
return true;
|
|
1437
|
+
}
|
|
1438
|
+
async run(job) {
|
|
1439
|
+
try {
|
|
1440
|
+
await this.reclaimOrphans(job);
|
|
1441
|
+
if (await this.stopped(job)) return;
|
|
1442
|
+
if (job.includeDebugMedia) {
|
|
1443
|
+
await this.setPhase(job, "debug-media", "Reclaiming debug media");
|
|
1444
|
+
await this.reclaimDebugMedia(job);
|
|
1445
|
+
if (await this.stopped(job)) return;
|
|
1446
|
+
}
|
|
1447
|
+
await this.setPhase(job, "ghost-ledger", "Forgetting ghost footage rows");
|
|
1448
|
+
await this.forgetGhosts(job);
|
|
1449
|
+
if (await this.stopped(job)) return;
|
|
1450
|
+
await this.finish(job, "done", null);
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1453
|
+
await this.finish(job, "failed", message);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
async reclaimOrphans(job) {
|
|
1457
|
+
let restart = true;
|
|
1458
|
+
for (;;) {
|
|
1459
|
+
if (await this.stopped(job)) return;
|
|
1460
|
+
const start = await this.deps.participants.startOrphan(restart);
|
|
1461
|
+
restart = false;
|
|
1462
|
+
if (!start.started && !start.alreadyRunning) throw new Error("orphan reclaim did not start");
|
|
1463
|
+
const status = await this.waitUntilIdle(() => this.deps.participants.orphanStatus(), job, (s) => `Orphan reclaim: ${s.totalReclaimed} rows / ${String(s.totalBytesReclaimed)} bytes`);
|
|
1464
|
+
job.orphansReclaimed += status.totalReclaimed;
|
|
1465
|
+
job.orphanBytesReclaimed += status.totalBytesReclaimed;
|
|
1466
|
+
job.detail = `Orphans: ${job.orphansReclaimed} rows, ${String(job.orphanBytesReclaimed)} bytes`;
|
|
1467
|
+
await this.persist(job);
|
|
1468
|
+
if (status.error) throw new Error(status.error);
|
|
1469
|
+
if (status.complete === true) return;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
async reclaimDebugMedia(job) {
|
|
1473
|
+
const start = await this.deps.participants.startDebugMedia();
|
|
1474
|
+
if (!start.started && !start.alreadyRunning) throw new Error("debug-media reclaim did not start");
|
|
1475
|
+
const status = await this.waitUntilIdle(() => this.deps.participants.debugMediaStatus(), job, (s) => `Debug media: ${s.totalReclaimed} files`);
|
|
1476
|
+
job.debugMediaReclaimed = status.totalReclaimed;
|
|
1477
|
+
job.debugMediaBytesReclaimed = status.totalBytesReclaimed;
|
|
1478
|
+
await this.persist(job);
|
|
1479
|
+
if (status.error) throw new Error(status.error);
|
|
1480
|
+
}
|
|
1481
|
+
async forgetGhosts(job) {
|
|
1482
|
+
const frozen = this.deps.locations.listLocations().filter((l) => FOOTAGE_TYPES.has(l.type) && footageFrozen(l));
|
|
1483
|
+
if (frozen.length === 0) {
|
|
1484
|
+
job.detail = "No frozen footage locations";
|
|
1485
|
+
await this.persist(job);
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
for (const loc of frozen) {
|
|
1489
|
+
if (await this.stopped(job)) return;
|
|
1490
|
+
job.detail = `Ghost ledger: ${loc.id}`;
|
|
1491
|
+
await this.persist(job);
|
|
1492
|
+
const report = await this.deps.participants.walkLedger({
|
|
1493
|
+
locationId: loc.id,
|
|
1494
|
+
apply: true
|
|
1495
|
+
});
|
|
1496
|
+
job.ghostsForgotten += report.forgottenSegments;
|
|
1497
|
+
job.ghostBytesForgotten += report.forgottenBytes;
|
|
1498
|
+
await this.persist(job);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
async waitUntilIdle(read, job, detail) {
|
|
1502
|
+
const pollMs = this.deps.pollMs ?? 2e3;
|
|
1503
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1504
|
+
for (;;) {
|
|
1505
|
+
const status = await read();
|
|
1506
|
+
job.detail = detail(status);
|
|
1507
|
+
await this.persist(job);
|
|
1508
|
+
if (!status.running) return status;
|
|
1509
|
+
await sleep(pollMs);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
async stopped(job) {
|
|
1513
|
+
if ((await this.status(job.jobId))?.cancelRequested === true) {
|
|
1514
|
+
await this.finish(job, "cancelled", null);
|
|
1515
|
+
return true;
|
|
1516
|
+
}
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
async setPhase(job, phase, detail) {
|
|
1520
|
+
job.phase = phase;
|
|
1521
|
+
job.detail = detail;
|
|
1522
|
+
await this.persist(job);
|
|
1523
|
+
}
|
|
1524
|
+
async finish(job, phase, error) {
|
|
1525
|
+
job.phase = phase;
|
|
1526
|
+
job.error = error;
|
|
1527
|
+
job.finishedAt = this.deps.now();
|
|
1528
|
+
job.detail = phase === "done" ? "Cleanup finished" : job.detail;
|
|
1529
|
+
await this.persist(job);
|
|
1530
|
+
}
|
|
1531
|
+
async persist(job) {
|
|
1532
|
+
job.updatedAt = this.deps.now();
|
|
1533
|
+
this.active = job;
|
|
1534
|
+
await this.deps.state.set(job);
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
//#endregion
|
|
1256
1538
|
//#region src/builtins/storage-orchestrator/storage-orchestrator.service.ts
|
|
1257
1539
|
/**
|
|
1258
1540
|
* Routing core for the `storage-orchestrator` builtin.
|
|
@@ -1375,7 +1657,7 @@ var StorageOrchestratorService = class {
|
|
|
1375
1657
|
* Reconciles both directions so operator edits survive a reboot:
|
|
1376
1658
|
* 1. hydrate — DB rows win over any early in-memory seed (restores
|
|
1377
1659
|
* operator config like `minFreePercent` that the pre-store boot
|
|
1378
|
-
* can't see)
|
|
1660
|
+
* can't see);
|
|
1379
1661
|
* 2. backfill — in-memory locations the DB doesn't have yet (the
|
|
1380
1662
|
* pre-store seed defaults on a fresh install) are persisted, so the
|
|
1381
1663
|
* store becomes the durable source of truth from here on.
|
|
@@ -1397,14 +1679,7 @@ var StorageOrchestratorService = class {
|
|
|
1397
1679
|
const rows = await store.loadAll();
|
|
1398
1680
|
this.locationStore = store;
|
|
1399
1681
|
const dbIds = new Set(rows.map((r) => r.id));
|
|
1400
|
-
for (const loc of rows)
|
|
1401
|
-
const upgraded = !loc.isSystem && loc.id === `${loc.type}:default` ? {
|
|
1402
|
-
...loc,
|
|
1403
|
-
isSystem: true
|
|
1404
|
-
} : loc;
|
|
1405
|
-
this.locations.set(upgraded.id, upgraded);
|
|
1406
|
-
if (upgraded !== loc) store.upsert(upgraded).catch(() => {});
|
|
1407
|
-
}
|
|
1682
|
+
for (const loc of rows) this.locations.set(loc.id, loc);
|
|
1408
1683
|
const backfill = [...this.locations.values()].filter((l) => !dbIds.has(l.id));
|
|
1409
1684
|
for (const loc of backfill) store.upsert(loc).catch((err) => {
|
|
1410
1685
|
this.logger.warn("storage-orchestrator: attachStore backfill persist failed", { meta: {
|
|
@@ -1496,25 +1771,8 @@ var StorageOrchestratorService = class {
|
|
|
1496
1771
|
async initialize() {
|
|
1497
1772
|
if (!this.locationStore) return;
|
|
1498
1773
|
const rows = await this.locationStore.loadAll();
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
const upgradedLoc = {
|
|
1502
|
-
...loc,
|
|
1503
|
-
isSystem: true
|
|
1504
|
-
};
|
|
1505
|
-
this.locations.set(upgradedLoc.id, upgradedLoc);
|
|
1506
|
-
this.locationStore.upsert(upgradedLoc).catch((err) => {
|
|
1507
|
-
this.logger.warn("storage-orchestrator: isSystem upgrade persist failed", { meta: {
|
|
1508
|
-
id: upgradedLoc.id,
|
|
1509
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1510
|
-
} });
|
|
1511
|
-
});
|
|
1512
|
-
upgraded++;
|
|
1513
|
-
} else this.locations.set(loc.id, loc);
|
|
1514
|
-
this.logger.info("storage-orchestrator: hydrated locations from store", { meta: {
|
|
1515
|
-
loaded: this.locations.size,
|
|
1516
|
-
isSystemUpgraded: upgraded
|
|
1517
|
-
} });
|
|
1774
|
+
for (const loc of rows) this.locations.set(loc.id, loc);
|
|
1775
|
+
this.logger.info("storage-orchestrator: hydrated locations from store", { meta: { loaded: this.locations.size } });
|
|
1518
1776
|
this.reportDisabledDefaults();
|
|
1519
1777
|
}
|
|
1520
1778
|
/**
|
|
@@ -1699,10 +1957,6 @@ var StorageOrchestratorService = class {
|
|
|
1699
1957
|
...input,
|
|
1700
1958
|
nodeId: "hub"
|
|
1701
1959
|
};
|
|
1702
|
-
if (existing?.isSystem === true) input = {
|
|
1703
|
-
...input,
|
|
1704
|
-
isSystem: true
|
|
1705
|
-
};
|
|
1706
1960
|
input = {
|
|
1707
1961
|
...input,
|
|
1708
1962
|
...resolveEnabled(input, existing, this.hasAnyLocationOfType(input.type))
|
|
@@ -1749,21 +2003,22 @@ var StorageOrchestratorService = class {
|
|
|
1749
2003
|
return next;
|
|
1750
2004
|
}
|
|
1751
2005
|
/**
|
|
1752
|
-
* Remove a location. Refuses
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
1755
|
-
*
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
* routed to the logger — see `upsertLocation` for the rationale.
|
|
2006
|
+
* Remove a location. Refuses the last location of a type and the last
|
|
2007
|
+
* enabled location of a type. `isSystem` is informational — a frozen
|
|
2008
|
+
* recordings `:default` is deletable once a sibling is live. Deleting
|
|
2009
|
+
* the flagged default promotes an enabled sibling. Occupancy still
|
|
2010
|
+
* refuses unless `force` is set; force never overrides uniqueness /
|
|
2011
|
+
* last-enabled.
|
|
1759
2012
|
*/
|
|
1760
2013
|
async deleteLocation(id, options) {
|
|
1761
2014
|
const loc = this.locations.get(id);
|
|
1762
2015
|
if (!loc) throw new Error(`Storage location "${id}" not found`);
|
|
1763
|
-
|
|
1764
|
-
if (
|
|
1765
|
-
|
|
1766
|
-
}
|
|
2016
|
+
const ofType = [...this.locations.values()].filter((l) => l.type === loc.type);
|
|
2017
|
+
if (ofType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only location for type "${loc.type}"`);
|
|
2018
|
+
const enabledOfType = ofType.filter((l) => l.enabled !== false);
|
|
2019
|
+
if (loc.enabled !== false && enabledOfType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
|
|
2020
|
+
const successor = loc.isDefault === true ? ofType.find((l) => l.id !== id && l.enabled !== false) : void 0;
|
|
2021
|
+
if (loc.isDefault === true && successor === void 0) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
|
|
1767
2022
|
const occupancy = await this.probeOccupancy(loc);
|
|
1768
2023
|
if (occupancy !== "empty") {
|
|
1769
2024
|
if (options?.force !== true) throw new Error(occupancy === "occupied" ? `Storage location "${id}" still holds data — drain it first (storage migration / relocate), or pass force to delete the record anyway and strand what is on it` : `Storage location "${id}" could not be checked for remaining data (unreachable provider, another node, or an unmounted root) — pass force to delete the record anyway`);
|
|
@@ -1773,6 +2028,21 @@ var StorageOrchestratorService = class {
|
|
|
1773
2028
|
occupancy
|
|
1774
2029
|
} });
|
|
1775
2030
|
}
|
|
2031
|
+
if (successor !== void 0 && successor.isDefault !== true) {
|
|
2032
|
+
const now = Date.now();
|
|
2033
|
+
const promoted = {
|
|
2034
|
+
...successor,
|
|
2035
|
+
isDefault: true,
|
|
2036
|
+
updatedAt: now
|
|
2037
|
+
};
|
|
2038
|
+
this.locations.set(successor.id, promoted);
|
|
2039
|
+
if (this.locationStore) this.locationStore.upsert(promoted).catch((err) => {
|
|
2040
|
+
this.logger.error("storage-orchestrator: default promotion persist failed", { meta: {
|
|
2041
|
+
id: successor.id,
|
|
2042
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2043
|
+
} });
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
1776
2046
|
this.locations.delete(id);
|
|
1777
2047
|
if (this.locationStore) this.locationStore.delete(id).catch((err) => {
|
|
1778
2048
|
this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
|
|
@@ -1799,15 +2069,14 @@ var StorageOrchestratorService = class {
|
|
|
1799
2069
|
}
|
|
1800
2070
|
}
|
|
1801
2071
|
/**
|
|
1802
|
-
* Remove
|
|
1803
|
-
* addon (stale defaults from a removed location type).
|
|
1804
|
-
*
|
|
1805
|
-
* operator owns them. Requires the registry to be set first.
|
|
2072
|
+
* Remove seed-shaped locations (`<type>:default`) whose type is no longer
|
|
2073
|
+
* declared by any addon (stale defaults from a removed location type).
|
|
2074
|
+
* Operator-added locations of an undeclared type are KEPT but warned.
|
|
1806
2075
|
*
|
|
1807
2076
|
* FAIL-SAFE: if the registry is EMPTY (no addon declared any location), we
|
|
1808
2077
|
* refuse to prune anything. An empty registry almost always means
|
|
1809
2078
|
* declarations failed to load (boot ordering, a stale install) — pruning
|
|
1810
|
-
* "everything undeclared" in that state would wipe every
|
|
2079
|
+
* "everything undeclared" in that state would wipe every seeded location
|
|
1811
2080
|
* (data/logs/recordings/…). Better to keep stale rows than destroy live ones.
|
|
1812
2081
|
*/
|
|
1813
2082
|
pruneUndeclaredSystemLocations() {
|
|
@@ -1818,7 +2087,7 @@ var StorageOrchestratorService = class {
|
|
|
1818
2087
|
}
|
|
1819
2088
|
for (const [id, loc] of Array.from(this.locations)) {
|
|
1820
2089
|
if (this.registry.cardinalityOf(loc.type) !== null) continue;
|
|
1821
|
-
if (loc.
|
|
2090
|
+
if (loc.id === `${loc.type}:default`) {
|
|
1822
2091
|
this.locations.delete(id);
|
|
1823
2092
|
if (this.locationStore) this.locationStore.delete(id).catch((err) => {
|
|
1824
2093
|
this.logger.error("storage-orchestrator: prune persistence failed", { meta: {
|
|
@@ -1826,7 +2095,7 @@ var StorageOrchestratorService = class {
|
|
|
1826
2095
|
error: err instanceof Error ? err.message : String(err)
|
|
1827
2096
|
} });
|
|
1828
2097
|
});
|
|
1829
|
-
this.logger.info("storage-orchestrator: pruned stale
|
|
2098
|
+
this.logger.info("storage-orchestrator: pruned stale seeded location", { meta: {
|
|
1830
2099
|
id,
|
|
1831
2100
|
type: loc.type
|
|
1832
2101
|
} });
|
|
@@ -1986,7 +2255,7 @@ var StorageOrchestratorService = class {
|
|
|
1986
2255
|
providerId: input.providerId,
|
|
1987
2256
|
config: { basePath: base },
|
|
1988
2257
|
isDefault: true,
|
|
1989
|
-
isSystem:
|
|
2258
|
+
isSystem: false
|
|
1990
2259
|
});
|
|
1991
2260
|
added++;
|
|
1992
2261
|
}
|
|
@@ -2216,6 +2485,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
2216
2485
|
pressureTimer = null;
|
|
2217
2486
|
pressureSweepInFlight = false;
|
|
2218
2487
|
migration = null;
|
|
2488
|
+
cleanup = null;
|
|
2219
2489
|
/**
|
|
2220
2490
|
* Cached `providerId → nodeLocal` snapshot (SP1). Backs the orchestrator's
|
|
2221
2491
|
* synchronous `NodeLocalResolver` — `getProviderInfo()` is async, so we
|
|
@@ -2273,7 +2543,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
2273
2543
|
const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
|
|
2274
2544
|
return Promise.all(rows.map(async (loc) => ({
|
|
2275
2545
|
...this.redacted(loc),
|
|
2276
|
-
capacity: await this.localCapacityOf(loc)
|
|
2546
|
+
capacity: loc.enabled === false ? null : await this.localCapacityOf(loc)
|
|
2277
2547
|
})));
|
|
2278
2548
|
},
|
|
2279
2549
|
getDefaultLocation: async ({ type }) => {
|
|
@@ -2441,6 +2711,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
2441
2711
|
const migration = new StorageMigrationCoordinator({
|
|
2442
2712
|
locations: service,
|
|
2443
2713
|
state: this.state("storage-migration", require_dist.StorageMigrationJobSchema.nullable(), null),
|
|
2714
|
+
history: this.state("storage-migration-history", zod.z.array(require_dist.StorageMigrationJobSchema), []),
|
|
2444
2715
|
participants: {
|
|
2445
2716
|
pipeline: {
|
|
2446
2717
|
pause: async (leaseId) => {
|
|
@@ -2491,6 +2762,36 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
2491
2762
|
deviceKeyOf: (locationId) => this.locationDeviceKey(locationId)
|
|
2492
2763
|
});
|
|
2493
2764
|
this.migration = migration;
|
|
2765
|
+
const cleanup = new StorageCleanupCoordinator({
|
|
2766
|
+
locations: service,
|
|
2767
|
+
state: this.state("storage-cleanup", require_dist.StorageCleanupJobSchema.nullable(), null),
|
|
2768
|
+
participants: {
|
|
2769
|
+
startOrphan: async (restart) => {
|
|
2770
|
+
return parseStarted(await this.ctx.api.addons.custom.mutate({
|
|
2771
|
+
addonId: "pipeline-analytics",
|
|
2772
|
+
action: "retention.orphanAudit",
|
|
2773
|
+
input: {
|
|
2774
|
+
mode: "reclaim",
|
|
2775
|
+
restart,
|
|
2776
|
+
maxRowsPerScope: 2e4
|
|
2777
|
+
}
|
|
2778
|
+
}));
|
|
2779
|
+
},
|
|
2780
|
+
orphanStatus: async () => {
|
|
2781
|
+
return parseOrphanStatus(await this.ctx.api.addons.custom.mutate({
|
|
2782
|
+
addonId: "pipeline-analytics",
|
|
2783
|
+
action: "retention.orphanAuditStatus",
|
|
2784
|
+
input: {}
|
|
2785
|
+
}));
|
|
2786
|
+
},
|
|
2787
|
+
startDebugMedia: () => this.ctx.api.pipelineAnalytics.reclaimDebugMedia.mutate({ mode: "reclaim" }),
|
|
2788
|
+
debugMediaStatus: () => this.ctx.api.pipelineAnalytics.getMediaReclaimStatus.query({}),
|
|
2789
|
+
walkLedger: (input) => this.ctx.api.recording.reconcileLedgerAgainstDisk.mutate(input)
|
|
2790
|
+
},
|
|
2791
|
+
now: () => Date.now(),
|
|
2792
|
+
newId: () => (0, node_crypto.randomUUID)()
|
|
2793
|
+
});
|
|
2794
|
+
this.cleanup = cleanup;
|
|
2494
2795
|
const migrationProvider = {
|
|
2495
2796
|
plan: (input) => migration.plan(input),
|
|
2496
2797
|
start: async (input) => ({ jobId: await migration.start(input) }),
|
|
@@ -2498,7 +2799,11 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
2498
2799
|
cancel: async ({ jobId }) => ({ cancelled: await migration.cancel(jobId) }),
|
|
2499
2800
|
movers: () => migration.movers(),
|
|
2500
2801
|
residue: () => migration.residue(),
|
|
2501
|
-
drain: async (input) => ({ jobId: await migration.drain(input) })
|
|
2802
|
+
drain: async (input) => ({ jobId: await migration.drain(input) }),
|
|
2803
|
+
cleanupStart: async (input) => ({ jobId: await cleanup.start(input) }),
|
|
2804
|
+
cleanupStatus: ({ jobId }) => cleanup.status(jobId),
|
|
2805
|
+
cleanupCancel: async ({ jobId }) => ({ cancelled: await cleanup.cancel(jobId) }),
|
|
2806
|
+
history: () => migration.history()
|
|
2502
2807
|
};
|
|
2503
2808
|
await this.seedFromDeclarations();
|
|
2504
2809
|
const eventBus = this.ctx.eventBus;
|
|
@@ -2741,11 +3046,14 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
2741
3046
|
}
|
|
2742
3047
|
}
|
|
2743
3048
|
/**
|
|
2744
|
-
* Does this location still hold anything
|
|
2745
|
-
*
|
|
2746
|
-
*
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
3049
|
+
* Does this location still hold anything of ITS class?
|
|
3050
|
+
*
|
|
3051
|
+
* Shared roots (`/recordings` hosting high + low + events) are scored by
|
|
3052
|
+
* class subtree (`<camera>/high|mid`, `/low`, `/events`), not by a raw
|
|
3053
|
+
* `readdir` of the root — camera dirs and `.camstack-location` would
|
|
3054
|
+
* otherwise make every class look occupied. The walk stays shallow: one
|
|
3055
|
+
* dirent per camera, then first-entry of the named subtree. A `find` of
|
|
3056
|
+
* segments is precisely what must not happen here.
|
|
2749
3057
|
*
|
|
2750
3058
|
* **What it cannot see, stated rather than papered over:**
|
|
2751
3059
|
*
|
|
@@ -2754,9 +3062,10 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
2754
3062
|
* `unknown`, never `empty`.
|
|
2755
3063
|
* - A location with no `basePath` — same answer.
|
|
2756
3064
|
* - The difference between "holds live footage" and "holds one stray
|
|
2757
|
-
* lock-file". It measures
|
|
2758
|
-
* a location holding orphan files that no index
|
|
2759
|
-
* the safe one, and `force` is the escape
|
|
3065
|
+
* lock-file". It measures ON-DISK entries of this class, not durable
|
|
3066
|
+
* rows, so it refuses a location holding orphan files that no index
|
|
3067
|
+
* names. That direction is the safe one, and `force` is the escape
|
|
3068
|
+
* hatch for it.
|
|
2760
3069
|
*
|
|
2761
3070
|
* A missing root (`ENOENT`) is `empty`, not `unknown`: an unmounted disk and
|
|
2762
3071
|
* a deleted directory are indistinguishable here, and if the record's own
|
|
@@ -2774,16 +3083,16 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
2774
3083
|
if ((location.nodeId === void 0 || location.nodeId === "" ? HUB_NODE_ID : location.nodeId) !== (this.service?.getLocalNodeId() ?? HUB_NODE_ID)) return "unknown";
|
|
2775
3084
|
const basePath = this.locationBasePath(location.id);
|
|
2776
3085
|
if (basePath === null) return "unknown";
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
3086
|
+
const occupancy = await probeLocalDirectoryOccupancy({
|
|
3087
|
+
type: location.type,
|
|
3088
|
+
basePath
|
|
3089
|
+
});
|
|
3090
|
+
if (occupancy === "unknown") this.ctx.logger.warn("storage-orchestrator: could not read a location root to check it", { meta: {
|
|
3091
|
+
id: location.id,
|
|
3092
|
+
basePath,
|
|
3093
|
+
type: location.type
|
|
3094
|
+
} });
|
|
3095
|
+
return occupancy;
|
|
2787
3096
|
}
|
|
2788
3097
|
/** Free capacity (%) on a location's volume via `statfs`; 100 (guard inert) when unstattable. */
|
|
2789
3098
|
async locationFreePercent(locationId) {
|
|
@@ -2859,6 +3168,38 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
2859
3168
|
}
|
|
2860
3169
|
}
|
|
2861
3170
|
};
|
|
3171
|
+
function parseStarted(raw) {
|
|
3172
|
+
if (raw !== null && typeof raw === "object") {
|
|
3173
|
+
const o = raw;
|
|
3174
|
+
if (typeof o["started"] === "boolean" && typeof o["alreadyRunning"] === "boolean") return {
|
|
3175
|
+
started: o["started"],
|
|
3176
|
+
alreadyRunning: o["alreadyRunning"]
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
return {
|
|
3180
|
+
started: false,
|
|
3181
|
+
alreadyRunning: false
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
3184
|
+
function parseOrphanStatus(raw) {
|
|
3185
|
+
if (raw !== null && typeof raw === "object") {
|
|
3186
|
+
const o = raw;
|
|
3187
|
+
return {
|
|
3188
|
+
running: o["running"] === true,
|
|
3189
|
+
complete: typeof o["complete"] === "boolean" ? o["complete"] : null,
|
|
3190
|
+
totalReclaimed: typeof o["totalReclaimed"] === "number" ? o["totalReclaimed"] : 0,
|
|
3191
|
+
totalBytesReclaimed: typeof o["totalBytesReclaimed"] === "number" ? o["totalBytesReclaimed"] : 0,
|
|
3192
|
+
error: typeof o["error"] === "string" ? o["error"] : null
|
|
3193
|
+
};
|
|
3194
|
+
}
|
|
3195
|
+
return {
|
|
3196
|
+
running: false,
|
|
3197
|
+
complete: true,
|
|
3198
|
+
totalReclaimed: 0,
|
|
3199
|
+
totalBytesReclaimed: 0,
|
|
3200
|
+
error: "orphan audit status unreadable"
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
2862
3203
|
//#endregion
|
|
2863
3204
|
exports.SqliteLocationStore = SqliteLocationStore;
|
|
2864
3205
|
exports.StorageMigrationCoordinator = StorageMigrationCoordinator;
|