@camstack/system 1.2.165 → 1.2.167
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
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { $t as parseJsonObject, C as StorageMigrationJobSchema, Ct as settingsStoreCapability, Et as storageMigrationCapability, Nt as BaseAddon, P as collectSecretConfigKeys, S as StorageLocationTypeSchema, Tt as storageCapability, m as REDACTED_SECRET, x as StorageCleanupJobSchema } from "../../dist-Dw9cm1NS.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import * as fs from "node:fs/promises";
|
|
5
|
+
import { opendir, readdir } from "node:fs/promises";
|
|
4
6
|
import * as path$1 from "node:path";
|
|
7
|
+
import path from "node:path";
|
|
5
8
|
//#region src/builtins/storage-orchestrator/location-store.ts
|
|
6
9
|
/**
|
|
7
10
|
* @durable class=config owner=storage-orchestrator
|
|
@@ -285,17 +288,22 @@ var StorageMigrationCoordinator = class {
|
|
|
285
288
|
for (const storageClass of STORAGE_CLASSES) {
|
|
286
289
|
const targetId = input.destinations[storageClass];
|
|
287
290
|
if (targetId === void 0) continue;
|
|
288
|
-
const
|
|
289
|
-
|
|
291
|
+
const namedSourceId = input.sources?.[storageClass];
|
|
292
|
+
const defaultLoc = this.deps.locations.getDefaultLocation(storageClass);
|
|
293
|
+
const source = namedSourceId ? this.deps.locations.getLocationById(namedSourceId) : defaultLoc;
|
|
294
|
+
if (!source) throw new Error(namedSourceId ? `Storage location "${namedSourceId}" not found` : `No default storage location for "${storageClass}"`);
|
|
295
|
+
if (source.type !== storageClass) throw new Error(`Storage location "${source.id}" is type "${source.type}", expected "${storageClass}"`);
|
|
290
296
|
const target = this.deps.locations.getLocationById(targetId);
|
|
291
297
|
if (!target) throw new Error(`Storage location "${targetId}" not found`);
|
|
292
298
|
if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
|
|
293
|
-
if (source.id === target.id) throw new Error(`Storage location "${targetId}" is already the "${storageClass}" default`);
|
|
299
|
+
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`);
|
|
300
|
+
const freezeSource = defaultLoc !== void 0 && source.id !== defaultLoc.id;
|
|
294
301
|
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.`);
|
|
295
302
|
moves.push({
|
|
296
303
|
storageClass,
|
|
297
304
|
fromLocationId: source.id,
|
|
298
305
|
toLocationId: target.id,
|
|
306
|
+
freezeSource,
|
|
299
307
|
moverJobId: null,
|
|
300
308
|
state: null,
|
|
301
309
|
error: null,
|
|
@@ -326,11 +334,13 @@ var StorageMigrationCoordinator = class {
|
|
|
326
334
|
}
|
|
327
335
|
return {
|
|
328
336
|
destinations: input.destinations,
|
|
337
|
+
sources: input.sources,
|
|
329
338
|
mode,
|
|
330
|
-
moves: moves.map(({ storageClass, fromLocationId, toLocationId }) => ({
|
|
339
|
+
moves: moves.map(({ storageClass, fromLocationId, toLocationId, freezeSource }) => ({
|
|
331
340
|
storageClass,
|
|
332
341
|
fromLocationId,
|
|
333
|
-
toLocationId
|
|
342
|
+
toLocationId,
|
|
343
|
+
freezeSource
|
|
334
344
|
})),
|
|
335
345
|
findings
|
|
336
346
|
};
|
|
@@ -360,12 +370,12 @@ var StorageMigrationCoordinator = class {
|
|
|
360
370
|
this.startReserved = true;
|
|
361
371
|
try {
|
|
362
372
|
const existing = await this.status();
|
|
363
|
-
if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
|
|
373
|
+
if (existing && isTerminal$1(existing) && existing.pausedParticipants.length > 0) {
|
|
364
374
|
await this.releaseAfterTerminal(existing);
|
|
365
375
|
await this.persist(existing);
|
|
366
376
|
if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
|
|
367
377
|
}
|
|
368
|
-
if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
|
|
378
|
+
if (existing && !isTerminal$1(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
|
|
369
379
|
const plan = await this.plan(input);
|
|
370
380
|
const now = this.deps.now();
|
|
371
381
|
const job = {
|
|
@@ -373,6 +383,7 @@ var StorageMigrationCoordinator = class {
|
|
|
373
383
|
phase: "planning",
|
|
374
384
|
mode: plan.mode,
|
|
375
385
|
destinations: input.destinations,
|
|
386
|
+
sources: input.sources,
|
|
376
387
|
throttleMbps: input.throttleMbps ?? 40,
|
|
377
388
|
moves: plan.moves.map((move) => ({
|
|
378
389
|
...move,
|
|
@@ -404,10 +415,15 @@ var StorageMigrationCoordinator = class {
|
|
|
404
415
|
if (jobId !== void 0 && job?.jobId !== jobId) return null;
|
|
405
416
|
return job;
|
|
406
417
|
}
|
|
418
|
+
/** Finished jobs, newest first, excluding the one `status` currently shows. */
|
|
419
|
+
async history() {
|
|
420
|
+
const current = await this.status();
|
|
421
|
+
return (await this.deps.history?.get() ?? []).filter((job) => job.jobId !== current?.jobId);
|
|
422
|
+
}
|
|
407
423
|
async cancel(jobId) {
|
|
408
424
|
const job = await this.status(jobId);
|
|
409
425
|
const cutoverInFlight = job !== null && job.repointed && (job.phase === "refreshing" || job.phase === "resuming");
|
|
410
|
-
if (!job || isTerminal(job) || cutoverInFlight) return false;
|
|
426
|
+
if (!job || isTerminal$1(job) || cutoverInFlight) return false;
|
|
411
427
|
job.cancelRequested = true;
|
|
412
428
|
await this.persist(job);
|
|
413
429
|
await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
|
|
@@ -464,18 +480,22 @@ var StorageMigrationCoordinator = class {
|
|
|
464
480
|
const target = this.deps.locations.getDefaultLocation(storageClass);
|
|
465
481
|
if (!target) continue;
|
|
466
482
|
if (laneOf(storageClass) === "media") {
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
483
|
+
for (const source of this.deps.locations.listLocations({ type: storageClass })) {
|
|
484
|
+
if (source.id === target.id) continue;
|
|
485
|
+
const count = await unanswerable(this.deps.participants.analytics.residue({
|
|
486
|
+
toLocationId: target.id,
|
|
487
|
+
fromLocationId: source.id,
|
|
488
|
+
mode: "move"
|
|
489
|
+
}));
|
|
490
|
+
if (count !== null && count.rows === 0) continue;
|
|
491
|
+
out.push({
|
|
492
|
+
storageClass,
|
|
493
|
+
fromLocationId: source.id,
|
|
494
|
+
toLocationId: target.id,
|
|
495
|
+
items: count?.rows ?? null,
|
|
496
|
+
bytes: null
|
|
497
|
+
});
|
|
498
|
+
}
|
|
479
499
|
continue;
|
|
480
500
|
}
|
|
481
501
|
for (const source of this.deps.locations.listLocations({ type: storageClass })) {
|
|
@@ -529,12 +549,12 @@ var StorageMigrationCoordinator = class {
|
|
|
529
549
|
this.startReserved = true;
|
|
530
550
|
try {
|
|
531
551
|
const existing = await this.status();
|
|
532
|
-
if (existing && isTerminal(existing) && existing.pausedParticipants.length > 0) {
|
|
552
|
+
if (existing && isTerminal$1(existing) && existing.pausedParticipants.length > 0) {
|
|
533
553
|
await this.releaseAfterTerminal(existing);
|
|
534
554
|
await this.persist(existing);
|
|
535
555
|
if (existing.pausedParticipants.length > 0) throw new Error(`storage migration ${existing.jobId} still holds maintenance leases`);
|
|
536
556
|
}
|
|
537
|
-
if (existing && !isTerminal(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
|
|
557
|
+
if (existing && !isTerminal$1(existing)) throw new Error(`storage migration is already active (${existing.jobId})`);
|
|
538
558
|
for (const storageClass of input.classes) {
|
|
539
559
|
if (MOVER_CLASSES.includes(storageClass)) continue;
|
|
540
560
|
throw new Error(`No mover owns "${storageClass}" — there is nothing that can drain it. Move it by hand.`);
|
|
@@ -599,7 +619,7 @@ var StorageMigrationCoordinator = class {
|
|
|
599
619
|
await this.persist(job);
|
|
600
620
|
return;
|
|
601
621
|
}
|
|
602
|
-
if (isTerminal(job)) {
|
|
622
|
+
if (isTerminal$1(job)) {
|
|
603
623
|
if (job.pausedParticipants.length > 0) {
|
|
604
624
|
await this.releaseAfterTerminal(job);
|
|
605
625
|
await this.persist(job);
|
|
@@ -660,11 +680,12 @@ var StorageMigrationCoordinator = class {
|
|
|
660
680
|
if (job.phase === "verifying" && !nonBlocking) {
|
|
661
681
|
await this.verifyMoves(job);
|
|
662
682
|
if (job.cancelRequested) return this.finishCancelled(job);
|
|
683
|
+
await this.freezeDrainedSources(job);
|
|
663
684
|
await this.setPhase(job, "repointing");
|
|
664
685
|
}
|
|
665
686
|
if (job.phase === "repointing") {
|
|
666
|
-
const targets = new Map(job.moves.map((move) => [move.storageClass, move.toLocationId]));
|
|
667
|
-
await this.deps.locations.setDefaultLocations(targets);
|
|
687
|
+
const targets = new Map(job.moves.filter((move) => move.freezeSource !== true).map((move) => [move.storageClass, move.toLocationId]));
|
|
688
|
+
if (targets.size > 0) await this.deps.locations.setDefaultLocations(targets);
|
|
668
689
|
job.repointed = true;
|
|
669
690
|
await this.setPhase(job, "refreshing");
|
|
670
691
|
}
|
|
@@ -687,6 +708,7 @@ var StorageMigrationCoordinator = class {
|
|
|
687
708
|
if (job.phase === "verifying" && nonBlocking) {
|
|
688
709
|
await this.verifyMoves(job);
|
|
689
710
|
if (job.cancelRequested) return this.finishCancelled(job);
|
|
711
|
+
await this.freezeDrainedSources(job);
|
|
690
712
|
await this.setPhase(job, "done");
|
|
691
713
|
}
|
|
692
714
|
} catch (err) {
|
|
@@ -702,7 +724,7 @@ var StorageMigrationCoordinator = class {
|
|
|
702
724
|
await this.releaseAfterTerminal(job);
|
|
703
725
|
await this.persist(job);
|
|
704
726
|
} finally {
|
|
705
|
-
if (isTerminal(job)) {
|
|
727
|
+
if (isTerminal$1(job)) {
|
|
706
728
|
await this.releaseAfterTerminal(job);
|
|
707
729
|
this.active = job;
|
|
708
730
|
}
|
|
@@ -862,6 +884,24 @@ var StorageMigrationCoordinator = class {
|
|
|
862
884
|
for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
|
|
863
885
|
}
|
|
864
886
|
/**
|
|
887
|
+
* After a from→to copy whose source was NOT the class default: stop new
|
|
888
|
+
* writes to `from`. The default is unchanged (that is the whole point of
|
|
889
|
+
* naming a non-default source). A location that is already disabled is
|
|
890
|
+
* left alone.
|
|
891
|
+
*/
|
|
892
|
+
freezeDrainedSources(job) {
|
|
893
|
+
for (const move of job.moves) {
|
|
894
|
+
if (move.freezeSource !== true) continue;
|
|
895
|
+
const loc = this.deps.locations.getLocationById(move.fromLocationId);
|
|
896
|
+
if (!loc || loc.enabled === false) continue;
|
|
897
|
+
const { createdAt: _c, updatedAt: _u, capacity: _cap, ...rest } = loc;
|
|
898
|
+
this.deps.locations.upsertLocation({
|
|
899
|
+
...rest,
|
|
900
|
+
enabled: false
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
865
905
|
* Arm one class's mover.
|
|
866
906
|
*
|
|
867
907
|
* `draining` runs after every writer has been resumed, so there is no lease
|
|
@@ -874,6 +914,7 @@ var StorageMigrationCoordinator = class {
|
|
|
874
914
|
if (laneOf(move.storageClass) === "media") {
|
|
875
915
|
const input = {
|
|
876
916
|
toLocationId: move.toLocationId,
|
|
917
|
+
fromLocationId: move.fromLocationId,
|
|
877
918
|
throttleMbps: job.throttleMbps,
|
|
878
919
|
mode: "move"
|
|
879
920
|
};
|
|
@@ -907,7 +948,7 @@ var StorageMigrationCoordinator = class {
|
|
|
907
948
|
}
|
|
908
949
|
async setPhase(job, phase) {
|
|
909
950
|
job.phase = phase;
|
|
910
|
-
if (isTerminal(job)) job.finishedAt = this.deps.now();
|
|
951
|
+
if (isTerminal$1(job)) job.finishedAt = this.deps.now();
|
|
911
952
|
await this.persist(job);
|
|
912
953
|
}
|
|
913
954
|
async finishCancelled(job) {
|
|
@@ -919,6 +960,9 @@ var StorageMigrationCoordinator = class {
|
|
|
919
960
|
async persist(job) {
|
|
920
961
|
job.updatedAt = this.deps.now();
|
|
921
962
|
await this.deps.state.set(job);
|
|
963
|
+
if (!isTerminal$1(job) || this.deps.history === void 0) return;
|
|
964
|
+
const prev = [...await this.deps.history.get() ?? []];
|
|
965
|
+
await this.deps.history.set([job, ...prev.filter((row) => row.jobId !== job.jobId)].slice(0, HISTORY_CAP));
|
|
922
966
|
}
|
|
923
967
|
};
|
|
924
968
|
/**
|
|
@@ -944,9 +988,10 @@ function requireLease(job) {
|
|
|
944
988
|
if (job.pauseLeaseId === null) throw new Error("storage migration has no maintenance lease");
|
|
945
989
|
return job.pauseLeaseId;
|
|
946
990
|
}
|
|
947
|
-
function isTerminal(job) {
|
|
991
|
+
function isTerminal$1(job) {
|
|
948
992
|
return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
|
|
949
993
|
}
|
|
994
|
+
var HISTORY_CAP = 20;
|
|
950
995
|
/**
|
|
951
996
|
* Which single-flight engine owns a class.
|
|
952
997
|
*/
|
|
@@ -1068,6 +1113,78 @@ function canProbeOccupancyLocally(locality) {
|
|
|
1068
1113
|
return locality === true;
|
|
1069
1114
|
}
|
|
1070
1115
|
//#endregion
|
|
1116
|
+
//#region src/builtins/storage-orchestrator/location-occupancy.ts
|
|
1117
|
+
/**
|
|
1118
|
+
* Delete-time occupancy of a location's on-disk root.
|
|
1119
|
+
*
|
|
1120
|
+
* A recordings disk may host several classes at one `basePath`
|
|
1121
|
+
* (`<camera>/high|mid`, `<camera>/low`, `<camera>/events`). Occupancy for
|
|
1122
|
+
* a class is therefore the class subtree, not the shared root — otherwise a
|
|
1123
|
+
* drained `eventMedia:default` still looks full because high footage is
|
|
1124
|
+
* writing next to it (observed 2026-09-01).
|
|
1125
|
+
*
|
|
1126
|
+
* The walk is shallow: one `readdir` of the root (one dirent per camera)
|
|
1127
|
+
* plus `opendir`+first-entry of each named subtree. No recursive find of
|
|
1128
|
+
* segments — that is the walk that saturates a USB disk.
|
|
1129
|
+
*/
|
|
1130
|
+
/**
|
|
1131
|
+
* Class → directories under `<root>/<camera>/`. Same map as the discard
|
|
1132
|
+
* wipe (`DISCARD_SUBTREES` in the recorder / pipeline-analytics addons).
|
|
1133
|
+
* Types not listed keep the legacy "any non-dot entry at the root" rule
|
|
1134
|
+
* (backups, addon-data, …).
|
|
1135
|
+
*/
|
|
1136
|
+
var STORAGE_CLASS_SUBTREES = {
|
|
1137
|
+
eventMedia: ["events"],
|
|
1138
|
+
recordingsLow: ["low"],
|
|
1139
|
+
recordings: ["high", "mid"]
|
|
1140
|
+
};
|
|
1141
|
+
function isDotEntry(name) {
|
|
1142
|
+
return name.startsWith(".");
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* True when `dir` exists and contains at least one dirent. Missing → false.
|
|
1146
|
+
* Any other errno throws so the caller can answer `unknown` (D49).
|
|
1147
|
+
*/
|
|
1148
|
+
async function dirHasAnyEntry(dir) {
|
|
1149
|
+
let handle;
|
|
1150
|
+
try {
|
|
1151
|
+
handle = await opendir(dir);
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
if (err.code === "ENOENT") return false;
|
|
1154
|
+
throw err;
|
|
1155
|
+
}
|
|
1156
|
+
try {
|
|
1157
|
+
return await handle.read() !== null;
|
|
1158
|
+
} finally {
|
|
1159
|
+
await handle.close();
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Occupancy of `basePath` for a location of `type`.
|
|
1164
|
+
*
|
|
1165
|
+
* - missing root → `empty` (nothing this delete can strand)
|
|
1166
|
+
* - class-mapped type → occupied iff any `<camera>/<subtree>` has an entry
|
|
1167
|
+
* - other types → occupied iff the root has a non-dot entry
|
|
1168
|
+
*/
|
|
1169
|
+
async function probeLocalDirectoryOccupancy(input) {
|
|
1170
|
+
let names;
|
|
1171
|
+
try {
|
|
1172
|
+
names = await readdir(input.basePath);
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
if (err.code === "ENOENT") return "empty";
|
|
1175
|
+
return "unknown";
|
|
1176
|
+
}
|
|
1177
|
+
const visible = names.filter((name) => !isDotEntry(name));
|
|
1178
|
+
const subtrees = STORAGE_CLASS_SUBTREES[input.type];
|
|
1179
|
+
if (subtrees === void 0) return visible.length > 0 ? "occupied" : "empty";
|
|
1180
|
+
try {
|
|
1181
|
+
for (const camera of visible) for (const subtree of subtrees) if (await dirHasAnyEntry(path.join(input.basePath, camera, subtree))) return "occupied";
|
|
1182
|
+
} catch {
|
|
1183
|
+
return "unknown";
|
|
1184
|
+
}
|
|
1185
|
+
return "empty";
|
|
1186
|
+
}
|
|
1187
|
+
//#endregion
|
|
1071
1188
|
//#region src/builtins/storage-orchestrator/data-store-dispatch.ts
|
|
1072
1189
|
/**
|
|
1073
1190
|
* Pick the engine to serve a call.
|
|
@@ -1246,6 +1363,173 @@ async function collectProviderInfos(providers, onError) {
|
|
|
1246
1363
|
return out;
|
|
1247
1364
|
}
|
|
1248
1365
|
//#endregion
|
|
1366
|
+
//#region src/builtins/storage-orchestrator/storage-cleanup-coordinator.ts
|
|
1367
|
+
var FOOTAGE_TYPES = new Set(["recordings", "recordingsLow"]);
|
|
1368
|
+
function isTerminal(job) {
|
|
1369
|
+
return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
|
|
1370
|
+
}
|
|
1371
|
+
function footageFrozen(location) {
|
|
1372
|
+
return location.enabled === false || location.config["readOnly"] === true;
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* One operator-facing cleanup of leftover analytics rows/blobs, optional
|
|
1376
|
+
* debug media, and ghost ledger rows on frozen footage locations.
|
|
1377
|
+
*
|
|
1378
|
+
* Each addon call either returns immediately (start+poll) or is a bounded
|
|
1379
|
+
* ledger walk. The HTTP caller never waits on the full pass.
|
|
1380
|
+
*/
|
|
1381
|
+
var StorageCleanupCoordinator = class {
|
|
1382
|
+
deps;
|
|
1383
|
+
active = null;
|
|
1384
|
+
startReserved = false;
|
|
1385
|
+
constructor(deps) {
|
|
1386
|
+
this.deps = deps;
|
|
1387
|
+
}
|
|
1388
|
+
async start(input) {
|
|
1389
|
+
if (this.startReserved) throw new Error("storage cleanup is already active");
|
|
1390
|
+
this.startReserved = true;
|
|
1391
|
+
try {
|
|
1392
|
+
const existing = await this.status();
|
|
1393
|
+
if (existing && !isTerminal(existing)) throw new Error(`storage cleanup is already active (${existing.jobId})`);
|
|
1394
|
+
const now = this.deps.now();
|
|
1395
|
+
const job = {
|
|
1396
|
+
jobId: this.deps.newId(),
|
|
1397
|
+
phase: "orphans",
|
|
1398
|
+
includeDebugMedia: input.includeDebugMedia === true,
|
|
1399
|
+
orphansReclaimed: 0,
|
|
1400
|
+
orphanBytesReclaimed: 0,
|
|
1401
|
+
debugMediaReclaimed: 0,
|
|
1402
|
+
debugMediaBytesReclaimed: 0,
|
|
1403
|
+
ghostsForgotten: 0,
|
|
1404
|
+
ghostBytesForgotten: 0,
|
|
1405
|
+
detail: "Starting orphan reclaim",
|
|
1406
|
+
cancelRequested: false,
|
|
1407
|
+
startedAt: now,
|
|
1408
|
+
updatedAt: now,
|
|
1409
|
+
finishedAt: null,
|
|
1410
|
+
error: null
|
|
1411
|
+
};
|
|
1412
|
+
await this.persist(job);
|
|
1413
|
+
this.active = job;
|
|
1414
|
+
this.run(job);
|
|
1415
|
+
return job.jobId;
|
|
1416
|
+
} finally {
|
|
1417
|
+
this.startReserved = false;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
async status(jobId) {
|
|
1421
|
+
const job = this.active ?? await this.deps.state.get();
|
|
1422
|
+
if (jobId !== void 0 && job?.jobId !== jobId) return null;
|
|
1423
|
+
return job;
|
|
1424
|
+
}
|
|
1425
|
+
async cancel(jobId) {
|
|
1426
|
+
const job = await this.status(jobId);
|
|
1427
|
+
if (!job || isTerminal(job)) return false;
|
|
1428
|
+
job.cancelRequested = true;
|
|
1429
|
+
job.detail = "Cancel requested — finishing the current step";
|
|
1430
|
+
await this.persist(job);
|
|
1431
|
+
return true;
|
|
1432
|
+
}
|
|
1433
|
+
async run(job) {
|
|
1434
|
+
try {
|
|
1435
|
+
await this.reclaimOrphans(job);
|
|
1436
|
+
if (await this.stopped(job)) return;
|
|
1437
|
+
if (job.includeDebugMedia) {
|
|
1438
|
+
await this.setPhase(job, "debug-media", "Reclaiming debug media");
|
|
1439
|
+
await this.reclaimDebugMedia(job);
|
|
1440
|
+
if (await this.stopped(job)) return;
|
|
1441
|
+
}
|
|
1442
|
+
await this.setPhase(job, "ghost-ledger", "Forgetting ghost footage rows");
|
|
1443
|
+
await this.forgetGhosts(job);
|
|
1444
|
+
if (await this.stopped(job)) return;
|
|
1445
|
+
await this.finish(job, "done", null);
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1448
|
+
await this.finish(job, "failed", message);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
async reclaimOrphans(job) {
|
|
1452
|
+
let restart = true;
|
|
1453
|
+
for (;;) {
|
|
1454
|
+
if (await this.stopped(job)) return;
|
|
1455
|
+
const start = await this.deps.participants.startOrphan(restart);
|
|
1456
|
+
restart = false;
|
|
1457
|
+
if (!start.started && !start.alreadyRunning) throw new Error("orphan reclaim did not start");
|
|
1458
|
+
const status = await this.waitUntilIdle(() => this.deps.participants.orphanStatus(), job, (s) => `Orphan reclaim: ${s.totalReclaimed} rows / ${String(s.totalBytesReclaimed)} bytes`);
|
|
1459
|
+
job.orphansReclaimed += status.totalReclaimed;
|
|
1460
|
+
job.orphanBytesReclaimed += status.totalBytesReclaimed;
|
|
1461
|
+
job.detail = `Orphans: ${job.orphansReclaimed} rows, ${String(job.orphanBytesReclaimed)} bytes`;
|
|
1462
|
+
await this.persist(job);
|
|
1463
|
+
if (status.error) throw new Error(status.error);
|
|
1464
|
+
if (status.complete === true) return;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
async reclaimDebugMedia(job) {
|
|
1468
|
+
const start = await this.deps.participants.startDebugMedia();
|
|
1469
|
+
if (!start.started && !start.alreadyRunning) throw new Error("debug-media reclaim did not start");
|
|
1470
|
+
const status = await this.waitUntilIdle(() => this.deps.participants.debugMediaStatus(), job, (s) => `Debug media: ${s.totalReclaimed} files`);
|
|
1471
|
+
job.debugMediaReclaimed = status.totalReclaimed;
|
|
1472
|
+
job.debugMediaBytesReclaimed = status.totalBytesReclaimed;
|
|
1473
|
+
await this.persist(job);
|
|
1474
|
+
if (status.error) throw new Error(status.error);
|
|
1475
|
+
}
|
|
1476
|
+
async forgetGhosts(job) {
|
|
1477
|
+
const frozen = this.deps.locations.listLocations().filter((l) => FOOTAGE_TYPES.has(l.type) && footageFrozen(l));
|
|
1478
|
+
if (frozen.length === 0) {
|
|
1479
|
+
job.detail = "No frozen footage locations";
|
|
1480
|
+
await this.persist(job);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
for (const loc of frozen) {
|
|
1484
|
+
if (await this.stopped(job)) return;
|
|
1485
|
+
job.detail = `Ghost ledger: ${loc.id}`;
|
|
1486
|
+
await this.persist(job);
|
|
1487
|
+
const report = await this.deps.participants.walkLedger({
|
|
1488
|
+
locationId: loc.id,
|
|
1489
|
+
apply: true
|
|
1490
|
+
});
|
|
1491
|
+
job.ghostsForgotten += report.forgottenSegments;
|
|
1492
|
+
job.ghostBytesForgotten += report.forgottenBytes;
|
|
1493
|
+
await this.persist(job);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
async waitUntilIdle(read, job, detail) {
|
|
1497
|
+
const pollMs = this.deps.pollMs ?? 2e3;
|
|
1498
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1499
|
+
for (;;) {
|
|
1500
|
+
const status = await read();
|
|
1501
|
+
job.detail = detail(status);
|
|
1502
|
+
await this.persist(job);
|
|
1503
|
+
if (!status.running) return status;
|
|
1504
|
+
await sleep(pollMs);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
async stopped(job) {
|
|
1508
|
+
if ((await this.status(job.jobId))?.cancelRequested === true) {
|
|
1509
|
+
await this.finish(job, "cancelled", null);
|
|
1510
|
+
return true;
|
|
1511
|
+
}
|
|
1512
|
+
return false;
|
|
1513
|
+
}
|
|
1514
|
+
async setPhase(job, phase, detail) {
|
|
1515
|
+
job.phase = phase;
|
|
1516
|
+
job.detail = detail;
|
|
1517
|
+
await this.persist(job);
|
|
1518
|
+
}
|
|
1519
|
+
async finish(job, phase, error) {
|
|
1520
|
+
job.phase = phase;
|
|
1521
|
+
job.error = error;
|
|
1522
|
+
job.finishedAt = this.deps.now();
|
|
1523
|
+
job.detail = phase === "done" ? "Cleanup finished" : job.detail;
|
|
1524
|
+
await this.persist(job);
|
|
1525
|
+
}
|
|
1526
|
+
async persist(job) {
|
|
1527
|
+
job.updatedAt = this.deps.now();
|
|
1528
|
+
this.active = job;
|
|
1529
|
+
await this.deps.state.set(job);
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
//#endregion
|
|
1249
1533
|
//#region src/builtins/storage-orchestrator/storage-orchestrator.service.ts
|
|
1250
1534
|
/**
|
|
1251
1535
|
* Routing core for the `storage-orchestrator` builtin.
|
|
@@ -1368,7 +1652,7 @@ var StorageOrchestratorService = class {
|
|
|
1368
1652
|
* Reconciles both directions so operator edits survive a reboot:
|
|
1369
1653
|
* 1. hydrate — DB rows win over any early in-memory seed (restores
|
|
1370
1654
|
* operator config like `minFreePercent` that the pre-store boot
|
|
1371
|
-
* can't see)
|
|
1655
|
+
* can't see);
|
|
1372
1656
|
* 2. backfill — in-memory locations the DB doesn't have yet (the
|
|
1373
1657
|
* pre-store seed defaults on a fresh install) are persisted, so the
|
|
1374
1658
|
* store becomes the durable source of truth from here on.
|
|
@@ -1390,14 +1674,7 @@ var StorageOrchestratorService = class {
|
|
|
1390
1674
|
const rows = await store.loadAll();
|
|
1391
1675
|
this.locationStore = store;
|
|
1392
1676
|
const dbIds = new Set(rows.map((r) => r.id));
|
|
1393
|
-
for (const loc of rows)
|
|
1394
|
-
const upgraded = !loc.isSystem && loc.id === `${loc.type}:default` ? {
|
|
1395
|
-
...loc,
|
|
1396
|
-
isSystem: true
|
|
1397
|
-
} : loc;
|
|
1398
|
-
this.locations.set(upgraded.id, upgraded);
|
|
1399
|
-
if (upgraded !== loc) store.upsert(upgraded).catch(() => {});
|
|
1400
|
-
}
|
|
1677
|
+
for (const loc of rows) this.locations.set(loc.id, loc);
|
|
1401
1678
|
const backfill = [...this.locations.values()].filter((l) => !dbIds.has(l.id));
|
|
1402
1679
|
for (const loc of backfill) store.upsert(loc).catch((err) => {
|
|
1403
1680
|
this.logger.warn("storage-orchestrator: attachStore backfill persist failed", { meta: {
|
|
@@ -1489,25 +1766,8 @@ var StorageOrchestratorService = class {
|
|
|
1489
1766
|
async initialize() {
|
|
1490
1767
|
if (!this.locationStore) return;
|
|
1491
1768
|
const rows = await this.locationStore.loadAll();
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
const upgradedLoc = {
|
|
1495
|
-
...loc,
|
|
1496
|
-
isSystem: true
|
|
1497
|
-
};
|
|
1498
|
-
this.locations.set(upgradedLoc.id, upgradedLoc);
|
|
1499
|
-
this.locationStore.upsert(upgradedLoc).catch((err) => {
|
|
1500
|
-
this.logger.warn("storage-orchestrator: isSystem upgrade persist failed", { meta: {
|
|
1501
|
-
id: upgradedLoc.id,
|
|
1502
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1503
|
-
} });
|
|
1504
|
-
});
|
|
1505
|
-
upgraded++;
|
|
1506
|
-
} else this.locations.set(loc.id, loc);
|
|
1507
|
-
this.logger.info("storage-orchestrator: hydrated locations from store", { meta: {
|
|
1508
|
-
loaded: this.locations.size,
|
|
1509
|
-
isSystemUpgraded: upgraded
|
|
1510
|
-
} });
|
|
1769
|
+
for (const loc of rows) this.locations.set(loc.id, loc);
|
|
1770
|
+
this.logger.info("storage-orchestrator: hydrated locations from store", { meta: { loaded: this.locations.size } });
|
|
1511
1771
|
this.reportDisabledDefaults();
|
|
1512
1772
|
}
|
|
1513
1773
|
/**
|
|
@@ -1692,10 +1952,6 @@ var StorageOrchestratorService = class {
|
|
|
1692
1952
|
...input,
|
|
1693
1953
|
nodeId: "hub"
|
|
1694
1954
|
};
|
|
1695
|
-
if (existing?.isSystem === true) input = {
|
|
1696
|
-
...input,
|
|
1697
|
-
isSystem: true
|
|
1698
|
-
};
|
|
1699
1955
|
input = {
|
|
1700
1956
|
...input,
|
|
1701
1957
|
...resolveEnabled(input, existing, this.hasAnyLocationOfType(input.type))
|
|
@@ -1742,21 +1998,22 @@ var StorageOrchestratorService = class {
|
|
|
1742
1998
|
return next;
|
|
1743
1999
|
}
|
|
1744
2000
|
/**
|
|
1745
|
-
* Remove a location. Refuses
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
*
|
|
1751
|
-
* routed to the logger — see `upsertLocation` for the rationale.
|
|
2001
|
+
* Remove a location. Refuses the last location of a type and the last
|
|
2002
|
+
* enabled location of a type. `isSystem` is informational — a frozen
|
|
2003
|
+
* recordings `:default` is deletable once a sibling is live. Deleting
|
|
2004
|
+
* the flagged default promotes an enabled sibling. Occupancy still
|
|
2005
|
+
* refuses unless `force` is set; force never overrides uniqueness /
|
|
2006
|
+
* last-enabled.
|
|
1752
2007
|
*/
|
|
1753
2008
|
async deleteLocation(id, options) {
|
|
1754
2009
|
const loc = this.locations.get(id);
|
|
1755
2010
|
if (!loc) throw new Error(`Storage location "${id}" not found`);
|
|
1756
|
-
|
|
1757
|
-
if (
|
|
1758
|
-
|
|
1759
|
-
}
|
|
2011
|
+
const ofType = [...this.locations.values()].filter((l) => l.type === loc.type);
|
|
2012
|
+
if (ofType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only location for type "${loc.type}"`);
|
|
2013
|
+
const enabledOfType = ofType.filter((l) => l.enabled !== false);
|
|
2014
|
+
if (loc.enabled !== false && enabledOfType.length <= 1) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
|
|
2015
|
+
const successor = loc.isDefault === true ? ofType.find((l) => l.id !== id && l.enabled !== false) : void 0;
|
|
2016
|
+
if (loc.isDefault === true && successor === void 0) throw new Error(`Cannot delete "${id}" — it is the only enabled location for type "${loc.type}"`);
|
|
1760
2017
|
const occupancy = await this.probeOccupancy(loc);
|
|
1761
2018
|
if (occupancy !== "empty") {
|
|
1762
2019
|
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`);
|
|
@@ -1766,6 +2023,21 @@ var StorageOrchestratorService = class {
|
|
|
1766
2023
|
occupancy
|
|
1767
2024
|
} });
|
|
1768
2025
|
}
|
|
2026
|
+
if (successor !== void 0 && successor.isDefault !== true) {
|
|
2027
|
+
const now = Date.now();
|
|
2028
|
+
const promoted = {
|
|
2029
|
+
...successor,
|
|
2030
|
+
isDefault: true,
|
|
2031
|
+
updatedAt: now
|
|
2032
|
+
};
|
|
2033
|
+
this.locations.set(successor.id, promoted);
|
|
2034
|
+
if (this.locationStore) this.locationStore.upsert(promoted).catch((err) => {
|
|
2035
|
+
this.logger.error("storage-orchestrator: default promotion persist failed", { meta: {
|
|
2036
|
+
id: successor.id,
|
|
2037
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2038
|
+
} });
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
1769
2041
|
this.locations.delete(id);
|
|
1770
2042
|
if (this.locationStore) this.locationStore.delete(id).catch((err) => {
|
|
1771
2043
|
this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
|
|
@@ -1792,15 +2064,14 @@ var StorageOrchestratorService = class {
|
|
|
1792
2064
|
}
|
|
1793
2065
|
}
|
|
1794
2066
|
/**
|
|
1795
|
-
* Remove
|
|
1796
|
-
* addon (stale defaults from a removed location type).
|
|
1797
|
-
*
|
|
1798
|
-
* operator owns them. Requires the registry to be set first.
|
|
2067
|
+
* Remove seed-shaped locations (`<type>:default`) whose type is no longer
|
|
2068
|
+
* declared by any addon (stale defaults from a removed location type).
|
|
2069
|
+
* Operator-added locations of an undeclared type are KEPT but warned.
|
|
1799
2070
|
*
|
|
1800
2071
|
* FAIL-SAFE: if the registry is EMPTY (no addon declared any location), we
|
|
1801
2072
|
* refuse to prune anything. An empty registry almost always means
|
|
1802
2073
|
* declarations failed to load (boot ordering, a stale install) — pruning
|
|
1803
|
-
* "everything undeclared" in that state would wipe every
|
|
2074
|
+
* "everything undeclared" in that state would wipe every seeded location
|
|
1804
2075
|
* (data/logs/recordings/…). Better to keep stale rows than destroy live ones.
|
|
1805
2076
|
*/
|
|
1806
2077
|
pruneUndeclaredSystemLocations() {
|
|
@@ -1811,7 +2082,7 @@ var StorageOrchestratorService = class {
|
|
|
1811
2082
|
}
|
|
1812
2083
|
for (const [id, loc] of Array.from(this.locations)) {
|
|
1813
2084
|
if (this.registry.cardinalityOf(loc.type) !== null) continue;
|
|
1814
|
-
if (loc.
|
|
2085
|
+
if (loc.id === `${loc.type}:default`) {
|
|
1815
2086
|
this.locations.delete(id);
|
|
1816
2087
|
if (this.locationStore) this.locationStore.delete(id).catch((err) => {
|
|
1817
2088
|
this.logger.error("storage-orchestrator: prune persistence failed", { meta: {
|
|
@@ -1819,7 +2090,7 @@ var StorageOrchestratorService = class {
|
|
|
1819
2090
|
error: err instanceof Error ? err.message : String(err)
|
|
1820
2091
|
} });
|
|
1821
2092
|
});
|
|
1822
|
-
this.logger.info("storage-orchestrator: pruned stale
|
|
2093
|
+
this.logger.info("storage-orchestrator: pruned stale seeded location", { meta: {
|
|
1823
2094
|
id,
|
|
1824
2095
|
type: loc.type
|
|
1825
2096
|
} });
|
|
@@ -1979,7 +2250,7 @@ var StorageOrchestratorService = class {
|
|
|
1979
2250
|
providerId: input.providerId,
|
|
1980
2251
|
config: { basePath: base },
|
|
1981
2252
|
isDefault: true,
|
|
1982
|
-
isSystem:
|
|
2253
|
+
isSystem: false
|
|
1983
2254
|
});
|
|
1984
2255
|
added++;
|
|
1985
2256
|
}
|
|
@@ -2209,6 +2480,7 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
2209
2480
|
pressureTimer = null;
|
|
2210
2481
|
pressureSweepInFlight = false;
|
|
2211
2482
|
migration = null;
|
|
2483
|
+
cleanup = null;
|
|
2212
2484
|
/**
|
|
2213
2485
|
* Cached `providerId → nodeLocal` snapshot (SP1). Backs the orchestrator's
|
|
2214
2486
|
* synchronous `NodeLocalResolver` — `getProviderInfo()` is async, so we
|
|
@@ -2266,7 +2538,7 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
2266
2538
|
const rows = type !== void 0 ? service.listLocations({ type }) : service.listLocations();
|
|
2267
2539
|
return Promise.all(rows.map(async (loc) => ({
|
|
2268
2540
|
...this.redacted(loc),
|
|
2269
|
-
capacity: await this.localCapacityOf(loc)
|
|
2541
|
+
capacity: loc.enabled === false ? null : await this.localCapacityOf(loc)
|
|
2270
2542
|
})));
|
|
2271
2543
|
},
|
|
2272
2544
|
getDefaultLocation: async ({ type }) => {
|
|
@@ -2434,6 +2706,7 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
2434
2706
|
const migration = new StorageMigrationCoordinator({
|
|
2435
2707
|
locations: service,
|
|
2436
2708
|
state: this.state("storage-migration", StorageMigrationJobSchema.nullable(), null),
|
|
2709
|
+
history: this.state("storage-migration-history", z.array(StorageMigrationJobSchema), []),
|
|
2437
2710
|
participants: {
|
|
2438
2711
|
pipeline: {
|
|
2439
2712
|
pause: async (leaseId) => {
|
|
@@ -2484,6 +2757,36 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
2484
2757
|
deviceKeyOf: (locationId) => this.locationDeviceKey(locationId)
|
|
2485
2758
|
});
|
|
2486
2759
|
this.migration = migration;
|
|
2760
|
+
const cleanup = new StorageCleanupCoordinator({
|
|
2761
|
+
locations: service,
|
|
2762
|
+
state: this.state("storage-cleanup", StorageCleanupJobSchema.nullable(), null),
|
|
2763
|
+
participants: {
|
|
2764
|
+
startOrphan: async (restart) => {
|
|
2765
|
+
return parseStarted(await this.ctx.api.addons.custom.mutate({
|
|
2766
|
+
addonId: "pipeline-analytics",
|
|
2767
|
+
action: "retention.orphanAudit",
|
|
2768
|
+
input: {
|
|
2769
|
+
mode: "reclaim",
|
|
2770
|
+
restart,
|
|
2771
|
+
maxRowsPerScope: 2e4
|
|
2772
|
+
}
|
|
2773
|
+
}));
|
|
2774
|
+
},
|
|
2775
|
+
orphanStatus: async () => {
|
|
2776
|
+
return parseOrphanStatus(await this.ctx.api.addons.custom.mutate({
|
|
2777
|
+
addonId: "pipeline-analytics",
|
|
2778
|
+
action: "retention.orphanAuditStatus",
|
|
2779
|
+
input: {}
|
|
2780
|
+
}));
|
|
2781
|
+
},
|
|
2782
|
+
startDebugMedia: () => this.ctx.api.pipelineAnalytics.reclaimDebugMedia.mutate({ mode: "reclaim" }),
|
|
2783
|
+
debugMediaStatus: () => this.ctx.api.pipelineAnalytics.getMediaReclaimStatus.query({}),
|
|
2784
|
+
walkLedger: (input) => this.ctx.api.recording.reconcileLedgerAgainstDisk.mutate(input)
|
|
2785
|
+
},
|
|
2786
|
+
now: () => Date.now(),
|
|
2787
|
+
newId: () => randomUUID()
|
|
2788
|
+
});
|
|
2789
|
+
this.cleanup = cleanup;
|
|
2487
2790
|
const migrationProvider = {
|
|
2488
2791
|
plan: (input) => migration.plan(input),
|
|
2489
2792
|
start: async (input) => ({ jobId: await migration.start(input) }),
|
|
@@ -2491,7 +2794,11 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
2491
2794
|
cancel: async ({ jobId }) => ({ cancelled: await migration.cancel(jobId) }),
|
|
2492
2795
|
movers: () => migration.movers(),
|
|
2493
2796
|
residue: () => migration.residue(),
|
|
2494
|
-
drain: async (input) => ({ jobId: await migration.drain(input) })
|
|
2797
|
+
drain: async (input) => ({ jobId: await migration.drain(input) }),
|
|
2798
|
+
cleanupStart: async (input) => ({ jobId: await cleanup.start(input) }),
|
|
2799
|
+
cleanupStatus: ({ jobId }) => cleanup.status(jobId),
|
|
2800
|
+
cleanupCancel: async ({ jobId }) => ({ cancelled: await cleanup.cancel(jobId) }),
|
|
2801
|
+
history: () => migration.history()
|
|
2495
2802
|
};
|
|
2496
2803
|
await this.seedFromDeclarations();
|
|
2497
2804
|
const eventBus = this.ctx.eventBus;
|
|
@@ -2734,11 +3041,14 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
2734
3041
|
}
|
|
2735
3042
|
}
|
|
2736
3043
|
/**
|
|
2737
|
-
* Does this location still hold anything
|
|
2738
|
-
*
|
|
2739
|
-
*
|
|
2740
|
-
*
|
|
2741
|
-
*
|
|
3044
|
+
* Does this location still hold anything of ITS class?
|
|
3045
|
+
*
|
|
3046
|
+
* Shared roots (`/recordings` hosting high + low + events) are scored by
|
|
3047
|
+
* class subtree (`<camera>/high|mid`, `/low`, `/events`), not by a raw
|
|
3048
|
+
* `readdir` of the root — camera dirs and `.camstack-location` would
|
|
3049
|
+
* otherwise make every class look occupied. The walk stays shallow: one
|
|
3050
|
+
* dirent per camera, then first-entry of the named subtree. A `find` of
|
|
3051
|
+
* segments is precisely what must not happen here.
|
|
2742
3052
|
*
|
|
2743
3053
|
* **What it cannot see, stated rather than papered over:**
|
|
2744
3054
|
*
|
|
@@ -2747,9 +3057,10 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
2747
3057
|
* `unknown`, never `empty`.
|
|
2748
3058
|
* - A location with no `basePath` — same answer.
|
|
2749
3059
|
* - The difference between "holds live footage" and "holds one stray
|
|
2750
|
-
* lock-file". It measures
|
|
2751
|
-
* a location holding orphan files that no index
|
|
2752
|
-
* the safe one, and `force` is the escape
|
|
3060
|
+
* lock-file". It measures ON-DISK entries of this class, not durable
|
|
3061
|
+
* rows, so it refuses a location holding orphan files that no index
|
|
3062
|
+
* names. That direction is the safe one, and `force` is the escape
|
|
3063
|
+
* hatch for it.
|
|
2753
3064
|
*
|
|
2754
3065
|
* A missing root (`ENOENT`) is `empty`, not `unknown`: an unmounted disk and
|
|
2755
3066
|
* a deleted directory are indistinguishable here, and if the record's own
|
|
@@ -2767,16 +3078,16 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
2767
3078
|
if ((location.nodeId === void 0 || location.nodeId === "" ? HUB_NODE_ID : location.nodeId) !== (this.service?.getLocalNodeId() ?? HUB_NODE_ID)) return "unknown";
|
|
2768
3079
|
const basePath = this.locationBasePath(location.id);
|
|
2769
3080
|
if (basePath === null) return "unknown";
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
3081
|
+
const occupancy = await probeLocalDirectoryOccupancy({
|
|
3082
|
+
type: location.type,
|
|
3083
|
+
basePath
|
|
3084
|
+
});
|
|
3085
|
+
if (occupancy === "unknown") this.ctx.logger.warn("storage-orchestrator: could not read a location root to check it", { meta: {
|
|
3086
|
+
id: location.id,
|
|
3087
|
+
basePath,
|
|
3088
|
+
type: location.type
|
|
3089
|
+
} });
|
|
3090
|
+
return occupancy;
|
|
2780
3091
|
}
|
|
2781
3092
|
/** Free capacity (%) on a location's volume via `statfs`; 100 (guard inert) when unstattable. */
|
|
2782
3093
|
async locationFreePercent(locationId) {
|
|
@@ -2852,5 +3163,37 @@ var StorageOrchestratorAddon = class extends BaseAddon {
|
|
|
2852
3163
|
}
|
|
2853
3164
|
}
|
|
2854
3165
|
};
|
|
3166
|
+
function parseStarted(raw) {
|
|
3167
|
+
if (raw !== null && typeof raw === "object") {
|
|
3168
|
+
const o = raw;
|
|
3169
|
+
if (typeof o["started"] === "boolean" && typeof o["alreadyRunning"] === "boolean") return {
|
|
3170
|
+
started: o["started"],
|
|
3171
|
+
alreadyRunning: o["alreadyRunning"]
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
return {
|
|
3175
|
+
started: false,
|
|
3176
|
+
alreadyRunning: false
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
function parseOrphanStatus(raw) {
|
|
3180
|
+
if (raw !== null && typeof raw === "object") {
|
|
3181
|
+
const o = raw;
|
|
3182
|
+
return {
|
|
3183
|
+
running: o["running"] === true,
|
|
3184
|
+
complete: typeof o["complete"] === "boolean" ? o["complete"] : null,
|
|
3185
|
+
totalReclaimed: typeof o["totalReclaimed"] === "number" ? o["totalReclaimed"] : 0,
|
|
3186
|
+
totalBytesReclaimed: typeof o["totalBytesReclaimed"] === "number" ? o["totalBytesReclaimed"] : 0,
|
|
3187
|
+
error: typeof o["error"] === "string" ? o["error"] : null
|
|
3188
|
+
};
|
|
3189
|
+
}
|
|
3190
|
+
return {
|
|
3191
|
+
running: false,
|
|
3192
|
+
complete: true,
|
|
3193
|
+
totalReclaimed: 0,
|
|
3194
|
+
totalBytesReclaimed: 0,
|
|
3195
|
+
error: "orphan audit status unreadable"
|
|
3196
|
+
};
|
|
3197
|
+
}
|
|
2855
3198
|
//#endregion
|
|
2856
3199
|
export { StorageOrchestratorAddon, StorageOrchestratorAddon as default, SqliteLocationStore as i, buildStorageLocationRegistry as n, StorageMigrationCoordinator as r, StorageOrchestratorService as t };
|