@camstack/system 1.2.154 → 1.2.156
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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/storage-migration-coordinator.d.ts +101 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +24 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +348 -39
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +348 -39
- package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +37 -1
- 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-BPlfW-CG.js → dist-CDgIzo82.js} +169 -12
- package/dist/{dist-D_NyUV6Y.mjs → dist-LvKus-mT.mjs} +169 -12
- package/dist/index.d.ts +2 -0
- package/dist/index.js +14 -2
- package/dist/index.mjs +3 -3
- package/dist/kernel/heap-watch.d.ts +8 -0
- package/dist/kernel/socket-plane-report.d.ts +99 -0
- package/dist/kernel/transport/frame-codec.d.ts +25 -0
- package/dist/kernel/transport/index.d.ts +2 -0
- package/dist/kernel/transport/local-child-registry.d.ts +13 -0
- package/dist/kernel/transport/local-transport.d.ts +11 -0
- package/dist/kernel/transport/socket-channel.d.ts +23 -0
- package/dist/kernel/transport/socket-traffic.d.ts +118 -0
- package/dist/{manifest-python-deps-BILJm2mI.mjs → manifest-python-deps-CD14Pv0-.mjs} +360 -7
- package/dist/{manifest-python-deps-COeSr7el.js → manifest-python-deps-DVODn-qc.js} +431 -6
- package/dist/{retired-settings-keys-DBY6ebwV.js → retired-settings-keys-BfAzWvPC.js} +1 -1
- package/dist/{retired-settings-keys-Bq1Tb84I.mjs → retired-settings-keys-CV4QVdXj.mjs} +1 -1
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ 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-CDgIzo82.js");
|
|
7
7
|
let node_crypto = require("node:crypto");
|
|
8
8
|
let node_fs_promises = require("node:fs/promises");
|
|
9
9
|
node_fs_promises = require_chunk.__toESM(node_fs_promises);
|
|
@@ -236,8 +236,41 @@ function mapRowToLocation(row) {
|
|
|
236
236
|
var STORAGE_CLASSES = [
|
|
237
237
|
"recordings",
|
|
238
238
|
"recordingsLow",
|
|
239
|
-
"eventMedia"
|
|
239
|
+
"eventMedia",
|
|
240
|
+
"backups",
|
|
241
|
+
"galleryMedia"
|
|
240
242
|
];
|
|
243
|
+
/** The classes footage/media movers actually own. Everything else is planned,
|
|
244
|
+
* reported and refused rather than silently accepted. */
|
|
245
|
+
var MOVER_CLASSES = [
|
|
246
|
+
"recordings",
|
|
247
|
+
"recordingsLow",
|
|
248
|
+
"eventMedia",
|
|
249
|
+
"galleryMedia"
|
|
250
|
+
];
|
|
251
|
+
/**
|
|
252
|
+
* Classes that can never span two locations at once.
|
|
253
|
+
*
|
|
254
|
+
* `galleryMedia` is `cardinality: 'single'` and `upsertLocation` refuses a
|
|
255
|
+
* second instance of a single type, so the "both locations are first-class for
|
|
256
|
+
* reads while the past drains" transient the non-blocking mode rests on is not
|
|
257
|
+
* merely undesirable there — it is not constructible. Its move is therefore
|
|
258
|
+
* stop-the-world, always. That is acceptable only because the enrolled gallery
|
|
259
|
+
* is a few KB per sample, and it is said here rather than discovered.
|
|
260
|
+
*/
|
|
261
|
+
var BLOCKING_ONLY_CLASSES = ["galleryMedia"];
|
|
262
|
+
/**
|
|
263
|
+
* Location types whose volume `galleryMedia` must NEVER land on.
|
|
264
|
+
*
|
|
265
|
+
* Verbatim from its own declaration: *"Deliberately NOT on the recordings
|
|
266
|
+
* volume: these images survive every retention sweep, so they must also survive
|
|
267
|
+
* a footage wipe, a quota eviction and a recordings disk swap."* A planner that
|
|
268
|
+
* accepted `galleryMedia` → the recordings volume would let an operator destroy
|
|
269
|
+
* the enrolment gallery on the next disk swap, months later, with no visible
|
|
270
|
+
* link back to this decision.
|
|
271
|
+
*/
|
|
272
|
+
var GALLERY_FORBIDDEN_NEIGHBOUR_TYPES = ["recordings", "recordingsLow"];
|
|
273
|
+
var MOVE_LANES = ["media", "footage"];
|
|
241
274
|
var PARTICIPANTS = [
|
|
242
275
|
"pipeline",
|
|
243
276
|
"recorder",
|
|
@@ -262,7 +295,9 @@ var StorageMigrationCoordinator = class {
|
|
|
262
295
|
this.deps = deps;
|
|
263
296
|
}
|
|
264
297
|
async plan(input) {
|
|
298
|
+
const mode = input.mode ?? "blocking";
|
|
265
299
|
const moves = [];
|
|
300
|
+
const findings = [];
|
|
266
301
|
for (const storageClass of STORAGE_CLASSES) {
|
|
267
302
|
const targetId = input.destinations[storageClass];
|
|
268
303
|
if (targetId === void 0) continue;
|
|
@@ -272,6 +307,9 @@ var StorageMigrationCoordinator = class {
|
|
|
272
307
|
if (!target) throw new Error(`Storage location "${targetId}" not found`);
|
|
273
308
|
if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
|
|
274
309
|
if (source.id === target.id) throw new Error(`Storage location "${targetId}" is already the "${storageClass}" default`);
|
|
310
|
+
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.`);
|
|
311
|
+
if (mode === "nonBlocking" && BLOCKING_ONLY_CLASSES.includes(storageClass)) throw new Error(`"${storageClass}" is a single-cardinality storage class: it can never exist at two locations at once, so it cannot be repointed first and drained behind. Use mode "blocking".`);
|
|
312
|
+
await this.refuseGalleryOntoRecordingsVolume(storageClass, targetId);
|
|
275
313
|
moves.push({
|
|
276
314
|
storageClass,
|
|
277
315
|
fromLocationId: source.id,
|
|
@@ -280,17 +318,79 @@ var StorageMigrationCoordinator = class {
|
|
|
280
318
|
state: null,
|
|
281
319
|
error: null
|
|
282
320
|
});
|
|
321
|
+
if (BLOCKING_ONLY_CLASSES.includes(storageClass)) findings.push({
|
|
322
|
+
code: "blockingOnly",
|
|
323
|
+
storageClass,
|
|
324
|
+
message: `"${storageClass}" is single-cardinality: this move is stop-the-world for its whole duration, in every mode. It is a few KB per enrolled sample, which is why that is acceptable.`
|
|
325
|
+
});
|
|
326
|
+
findings.push(...await this.deviceFindings(storageClass, source.id, target.id));
|
|
283
327
|
}
|
|
284
328
|
if (moves.length === 0) throw new Error("select at least one storage class");
|
|
329
|
+
if (moves.some((move) => move.storageClass === "eventMedia")) {
|
|
330
|
+
const unstamped = await this.deps.participants.analytics.countUnstamped();
|
|
331
|
+
if (unstamped.total > 0) {
|
|
332
|
+
if (mode === "nonBlocking") throw new Error(`${unstamped.total.toString()} event-media row(s) still carry no locationId (${unstamped.media.toString()} media, ${unstamped.retrainFrames.toString()} retrain frames). A non-blocking cutover would silently orphan them. Run the seal first: pipelineAnalytics.relocateMedia({ mode: "seal", toLocationId: "${this.deps.locations.getDefaultLocation("eventMedia")?.id ?? "eventMedia"}" }).`);
|
|
333
|
+
findings.push({
|
|
334
|
+
code: "unstampedEventMediaRows",
|
|
335
|
+
storageClass: "eventMedia",
|
|
336
|
+
message: `${unstamped.total.toString()} event-media row(s) carry no locationId. This blocking migration stamps them as it moves them, but a non-blocking one would be refused until a seal pass drives the count to zero.`
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
285
340
|
return {
|
|
286
341
|
destinations: input.destinations,
|
|
342
|
+
mode,
|
|
287
343
|
moves: moves.map(({ storageClass, fromLocationId, toLocationId }) => ({
|
|
288
344
|
storageClass,
|
|
289
345
|
fromLocationId,
|
|
290
346
|
toLocationId
|
|
291
|
-
}))
|
|
347
|
+
})),
|
|
348
|
+
findings
|
|
292
349
|
};
|
|
293
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* HARD refusal: `galleryMedia` onto a volume a recordings class lives on.
|
|
353
|
+
*
|
|
354
|
+
* This is the one same-device case that is a refusal rather than a finding,
|
|
355
|
+
* because its consequence is silent and arrives months later — the gallery is
|
|
356
|
+
* wiped by the next footage wipe, quota eviction or recordings disk swap, and
|
|
357
|
+
* nothing at that moment points back at this migration.
|
|
358
|
+
*
|
|
359
|
+
* It can only refuse what it can SEE. `deviceKeyOf` is a realpath, so it
|
|
360
|
+
* catches a symlink, a bind mount and a literally-shared directory — and
|
|
361
|
+
* misses two distinct directories on one filesystem, and any location pinned
|
|
362
|
+
* to another node. Those come back `null`/distinct and produce a
|
|
363
|
+
* `deviceIdentityUnknown` finding instead.
|
|
364
|
+
*/
|
|
365
|
+
async refuseGalleryOntoRecordingsVolume(storageClass, targetId) {
|
|
366
|
+
if (storageClass !== "galleryMedia") return;
|
|
367
|
+
const targetKey = await this.deviceKey(targetId);
|
|
368
|
+
if (targetKey === null) return;
|
|
369
|
+
for (const type of GALLERY_FORBIDDEN_NEIGHBOUR_TYPES) for (const neighbour of this.deps.locations.listLocations({ type })) {
|
|
370
|
+
if (await this.deviceKey(neighbour.id) !== targetKey) continue;
|
|
371
|
+
throw new Error(`Storage location "${targetId}" shares a volume with "${neighbour.id}" (${type}). galleryMedia is deliberately NOT on the recordings volume: enrolment images survive every retention sweep, so they must also survive a footage wipe, a quota eviction and a recordings disk swap. Choose a destination on another volume.`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** Same-device observations that are reported, not refused. */
|
|
375
|
+
async deviceFindings(storageClass, sourceId, targetId) {
|
|
376
|
+
if (this.deps.deviceKeyOf === void 0) return [];
|
|
377
|
+
const [sourceKey, targetKey] = await Promise.all([this.deviceKey(sourceId), this.deviceKey(targetId)]);
|
|
378
|
+
if (sourceKey === null || targetKey === null) return [{
|
|
379
|
+
code: "deviceIdentityUnknown",
|
|
380
|
+
storageClass,
|
|
381
|
+
message: `Could not establish whether "${sourceId}" and "${targetId}" are on the same device (a location with no basePath, or pinned to another node — the realpath would have to be taken there). Note also that this check is PATH identity, not st_dev: two distinct directories on one filesystem are never detected as one device.`
|
|
382
|
+
}];
|
|
383
|
+
if (sourceKey !== targetKey) return [];
|
|
384
|
+
return [{
|
|
385
|
+
code: "sharesDeviceWithSource",
|
|
386
|
+
storageClass,
|
|
387
|
+
message: `"${targetId}" resolves to the same path as "${sourceId}". The move will re-stamp rows rather than copy bytes, and it buys no redundancy — both "locations" are one disk.`
|
|
388
|
+
}];
|
|
389
|
+
}
|
|
390
|
+
async deviceKey(locationId) {
|
|
391
|
+
if (this.deps.deviceKeyOf === void 0) return null;
|
|
392
|
+
return this.deps.deviceKeyOf(locationId);
|
|
393
|
+
}
|
|
294
394
|
async start(input) {
|
|
295
395
|
if (this.startReserved) throw new Error("storage migration is already active");
|
|
296
396
|
this.startReserved = true;
|
|
@@ -307,6 +407,7 @@ var StorageMigrationCoordinator = class {
|
|
|
307
407
|
const job = {
|
|
308
408
|
jobId: this.deps.newId(),
|
|
309
409
|
phase: "planning",
|
|
410
|
+
mode: plan.mode,
|
|
310
411
|
destinations: input.destinations,
|
|
311
412
|
throttleMbps: input.throttleMbps ?? 40,
|
|
312
413
|
moves: plan.moves.map((move) => ({
|
|
@@ -340,7 +441,8 @@ var StorageMigrationCoordinator = class {
|
|
|
340
441
|
}
|
|
341
442
|
async cancel(jobId) {
|
|
342
443
|
const job = await this.status(jobId);
|
|
343
|
-
|
|
444
|
+
const cutoverInFlight = job !== null && job.repointed && (job.phase === "refreshing" || job.phase === "resuming");
|
|
445
|
+
if (!job || isTerminal(job) || cutoverInFlight) return false;
|
|
344
446
|
job.cancelRequested = true;
|
|
345
447
|
await this.persist(job);
|
|
346
448
|
await Promise.all(job.moves.filter((move) => move.moverJobId !== null).map((move) => this.cancelMove(move)));
|
|
@@ -371,7 +473,7 @@ var StorageMigrationCoordinator = class {
|
|
|
371
473
|
job.pauseLeaseId ??= this.deps.newId();
|
|
372
474
|
if (job.repointed && job.phase === "resuming") job.phase = "refreshing";
|
|
373
475
|
await this.persist(job);
|
|
374
|
-
try {
|
|
476
|
+
if (!runsUnleased(job)) try {
|
|
375
477
|
await this.pauseParticipants(job, true);
|
|
376
478
|
} catch (err) {
|
|
377
479
|
job.error = err instanceof Error ? err.message : String(err);
|
|
@@ -383,14 +485,34 @@ var StorageMigrationCoordinator = class {
|
|
|
383
485
|
this.runPromise;
|
|
384
486
|
}
|
|
385
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* The state machine. Both modes run the SAME phase handlers; only the order
|
|
490
|
+
* differs, which is the whole of the non-blocking design — no second mover,
|
|
491
|
+
* no parallel code path, nothing that can drift between the two.
|
|
492
|
+
*
|
|
493
|
+
* blocking: planning → pausing → moving → verifying → repointing →
|
|
494
|
+
* refreshing → resuming → done
|
|
495
|
+
* nonBlocking: planning → sealing → pausing → repointing → refreshing →
|
|
496
|
+
* resuming → draining → verifying → done
|
|
497
|
+
*
|
|
498
|
+
* Written as a straight-line sequence of `if (phase === X)` blocks so boot
|
|
499
|
+
* recovery re-enters at whatever phase the durable record says, exactly as
|
|
500
|
+
* before.
|
|
501
|
+
*/
|
|
386
502
|
async run(job) {
|
|
503
|
+
const nonBlocking = job.mode === "nonBlocking";
|
|
387
504
|
try {
|
|
388
|
-
if (job.phase === "planning") await this.setPhase(job, "pausing");
|
|
505
|
+
if (job.phase === "planning") await this.setPhase(job, nonBlocking ? "sealing" : "pausing");
|
|
506
|
+
if (job.phase === "sealing") {
|
|
507
|
+
await this.sealEventMedia(job);
|
|
508
|
+
if (job.cancelRequested) return this.finishCancelled(job);
|
|
509
|
+
await this.setPhase(job, "pausing");
|
|
510
|
+
}
|
|
389
511
|
if (job.phase === "pausing") {
|
|
390
512
|
job.pauseLeaseId ??= job.jobId;
|
|
391
513
|
await this.pauseParticipants(job);
|
|
392
514
|
if (job.cancelRequested) return this.finishCancelled(job);
|
|
393
|
-
await this.setPhase(job, "moving");
|
|
515
|
+
await this.setPhase(job, nonBlocking ? "repointing" : "moving");
|
|
394
516
|
}
|
|
395
517
|
if (job.phase === "moving") {
|
|
396
518
|
await this.startOrResumeMoves(job);
|
|
@@ -398,7 +520,7 @@ var StorageMigrationCoordinator = class {
|
|
|
398
520
|
if (job.cancelRequested) return this.finishCancelled(job);
|
|
399
521
|
await this.setPhase(job, "verifying");
|
|
400
522
|
}
|
|
401
|
-
if (job.phase === "verifying") {
|
|
523
|
+
if (job.phase === "verifying" && !nonBlocking) {
|
|
402
524
|
await this.verifyMoves(job);
|
|
403
525
|
if (job.cancelRequested) return this.finishCancelled(job);
|
|
404
526
|
await this.setPhase(job, "repointing");
|
|
@@ -411,17 +533,28 @@ var StorageMigrationCoordinator = class {
|
|
|
411
533
|
}
|
|
412
534
|
if (job.phase === "refreshing") {
|
|
413
535
|
const leaseId = requireLease(job);
|
|
414
|
-
if (job.moves.some((move) => move.storageClass
|
|
415
|
-
if (job.moves.some((move) => move.storageClass === "
|
|
536
|
+
if (job.moves.some((move) => laneOf(move.storageClass) === "footage")) await this.deps.participants.recorder.refresh(leaseId);
|
|
537
|
+
if (job.moves.some((move) => laneOf(move.storageClass) === "media")) await this.deps.participants.analytics.refresh(leaseId);
|
|
416
538
|
await this.setPhase(job, "resuming");
|
|
417
539
|
}
|
|
418
540
|
if (job.phase === "resuming") {
|
|
419
541
|
await this.resumeParticipants(job);
|
|
542
|
+
await this.setPhase(job, nonBlocking ? "draining" : "done");
|
|
543
|
+
}
|
|
544
|
+
if (job.phase === "draining") {
|
|
545
|
+
await this.startOrResumeMoves(job);
|
|
546
|
+
await this.waitForMoves(job);
|
|
547
|
+
if (job.cancelRequested) return this.finishCancelled(job);
|
|
548
|
+
await this.setPhase(job, "verifying");
|
|
549
|
+
}
|
|
550
|
+
if (job.phase === "verifying" && nonBlocking) {
|
|
551
|
+
await this.verifyMoves(job);
|
|
552
|
+
if (job.cancelRequested) return this.finishCancelled(job);
|
|
420
553
|
await this.setPhase(job, "done");
|
|
421
554
|
}
|
|
422
555
|
} catch (err) {
|
|
423
556
|
job.error = err instanceof Error ? err.message : String(err);
|
|
424
|
-
if (job.repointed) {
|
|
557
|
+
if (job.repointed && job.pausedParticipants.length > 0) {
|
|
425
558
|
job.phase = "refreshing";
|
|
426
559
|
job.finishedAt = null;
|
|
427
560
|
await this.persist(job);
|
|
@@ -469,21 +602,28 @@ var StorageMigrationCoordinator = class {
|
|
|
469
602
|
} catch {}
|
|
470
603
|
}
|
|
471
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* Start whatever is startable, honouring the ONE constraint the engines
|
|
607
|
+
* impose: each is single-flight.
|
|
608
|
+
*
|
|
609
|
+
* Two lanes — the recorder's footage engine and post-analysis's media engine
|
|
610
|
+
* — run in parallel with each other and strictly FIFO within themselves.
|
|
611
|
+
* `galleryMedia` shares the media lane with `eventMedia` because it shares
|
|
612
|
+
* the same `MediaRelocateEngine`, which refuses a second running job; starting
|
|
613
|
+
* both would fail the second one instantly.
|
|
614
|
+
*/
|
|
472
615
|
async startOrResumeMoves(job) {
|
|
473
616
|
if (job.cancelRequested) return;
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
move.moverJobId = await this.startMove(job, move);
|
|
485
|
-
await this.persist(job);
|
|
486
|
-
return;
|
|
617
|
+
for (const lane of MOVE_LANES) {
|
|
618
|
+
const laneMoves = job.moves.filter((move) => laneOf(move.storageClass) === lane);
|
|
619
|
+
for (let index = 0; index < laneMoves.length; index++) {
|
|
620
|
+
const move = laneMoves[index];
|
|
621
|
+
if (move === void 0 || move.moverJobId !== null) continue;
|
|
622
|
+
if (!laneMoves.slice(0, index).every((predecessor) => predecessor.state === "done")) break;
|
|
623
|
+
move.moverJobId = await this.startMove(job, move);
|
|
624
|
+
await this.persist(job);
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
487
627
|
}
|
|
488
628
|
}
|
|
489
629
|
async waitForMoves(job) {
|
|
@@ -494,7 +634,7 @@ var StorageMigrationCoordinator = class {
|
|
|
494
634
|
let complete = true;
|
|
495
635
|
for (const [index, move] of job.moves.entries()) {
|
|
496
636
|
if (job.cancelRequested && move.moverJobId === null) continue;
|
|
497
|
-
if (
|
|
637
|
+
if (job.moves.slice(0, index).filter((predecessor) => laneOf(predecessor.storageClass) === laneOf(move.storageClass)).some((predecessor) => predecessor.state !== "done")) {
|
|
498
638
|
complete = false;
|
|
499
639
|
continue;
|
|
500
640
|
}
|
|
@@ -528,30 +668,92 @@ var StorageMigrationCoordinator = class {
|
|
|
528
668
|
await sleep(pollMs);
|
|
529
669
|
}
|
|
530
670
|
}
|
|
671
|
+
/**
|
|
672
|
+
* Nail every unstamped `eventMedia`/retrain row to the location its bytes are
|
|
673
|
+
* ALREADY on, before anything repoints. Runs with every writer up — it moves
|
|
674
|
+
* no bytes at all.
|
|
675
|
+
*
|
|
676
|
+
* The gate is the RE-COUNT, not the seal job's terminal state: a seal that
|
|
677
|
+
* failed some rows still finishes, and "finished" is not "there are none
|
|
678
|
+
* left". A non-zero count here fails the job while nothing has been paused
|
|
679
|
+
* and nothing has been repointed, which is the cheapest possible place to
|
|
680
|
+
* discover it.
|
|
681
|
+
*
|
|
682
|
+
* The seal's own mover job id is deliberately NOT durable. It is idempotent
|
|
683
|
+
* and cheap, so a coordinator restart mid-seal simply re-runs the whole
|
|
684
|
+
* phase — and the count that follows is what actually decides, so a lost job
|
|
685
|
+
* id cannot make the gate pass.
|
|
686
|
+
*/
|
|
687
|
+
async sealEventMedia(job) {
|
|
688
|
+
const move = job.moves.find((candidate) => candidate.storageClass === "eventMedia");
|
|
689
|
+
if (move === void 0) return;
|
|
690
|
+
if ((await this.deps.participants.analytics.countUnstamped()).total > 0) {
|
|
691
|
+
const started = await this.deps.participants.analytics.startDrain({
|
|
692
|
+
toLocationId: move.fromLocationId,
|
|
693
|
+
mode: "seal"
|
|
694
|
+
});
|
|
695
|
+
await this.waitForSeal(job, started.jobId);
|
|
696
|
+
if (job.cancelRequested) return;
|
|
697
|
+
}
|
|
698
|
+
const after = await this.deps.participants.analytics.countUnstamped();
|
|
699
|
+
if (after.total > 0) throw new Error(`event-media seal left ${after.total.toString()} row(s) without a locationId (${after.media.toString()} media, ${after.retrainFrames.toString()} retrain frames). A non-blocking cutover would silently orphan them; nothing has been paused or repointed.`);
|
|
700
|
+
}
|
|
701
|
+
async waitForSeal(job, moverJobId) {
|
|
702
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
703
|
+
const pollMs = this.deps.pollMs ?? 250;
|
|
704
|
+
for (;;) {
|
|
705
|
+
if (job.cancelRequested) {
|
|
706
|
+
await this.deps.participants.analytics.cancelMove(moverJobId);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const status = await this.deps.participants.analytics.getMove(moverJobId);
|
|
710
|
+
if (status === null || status.state === "done" || status.state === "cancelled") return;
|
|
711
|
+
if (status.state === "failed") throw new Error(status.error ?? "event-media seal failed");
|
|
712
|
+
await sleep(pollMs);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
531
715
|
async verifyMoves(job) {
|
|
532
716
|
for (const move of job.moves) if (move.state !== "done") throw new Error(`${move.storageClass} move did not complete verification`);
|
|
533
717
|
}
|
|
718
|
+
/**
|
|
719
|
+
* Arm one class's mover.
|
|
720
|
+
*
|
|
721
|
+
* `draining` runs after every writer has been resumed, so there is no lease
|
|
722
|
+
* left to present — it goes through the participants' UNLEASED entry points
|
|
723
|
+
* (`recording.relocateFootage` / `pipelineAnalytics.relocateMedia`), which
|
|
724
|
+
* are the same engines, not a second mover.
|
|
725
|
+
*/
|
|
534
726
|
async startMove(job, move) {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
727
|
+
const unleased = runsUnleased(job);
|
|
728
|
+
if (laneOf(move.storageClass) === "media") {
|
|
729
|
+
const input = {
|
|
730
|
+
toLocationId: move.toLocationId,
|
|
731
|
+
throttleMbps: job.throttleMbps,
|
|
732
|
+
mode: move.storageClass === "galleryMedia" ? "gallery" : "move"
|
|
733
|
+
};
|
|
734
|
+
return (unleased ? await this.deps.participants.analytics.startDrain(input) : await this.deps.participants.analytics.startMove({
|
|
735
|
+
...input,
|
|
736
|
+
leaseId: requireLease(job)
|
|
737
|
+
})).jobId;
|
|
738
|
+
}
|
|
739
|
+
const input = {
|
|
541
740
|
fromLocationId: move.fromLocationId,
|
|
542
741
|
toLocationId: move.toLocationId,
|
|
543
|
-
footageClass: move.storageClass,
|
|
544
|
-
throttleMbps: job.throttleMbps
|
|
742
|
+
footageClass: move.storageClass === "recordingsLow" ? "recordingsLow" : "recordings",
|
|
743
|
+
throttleMbps: job.throttleMbps
|
|
744
|
+
};
|
|
745
|
+
return (unleased ? await this.deps.participants.recorder.startDrain(input) : await this.deps.participants.recorder.startMove({
|
|
746
|
+
...input,
|
|
545
747
|
leaseId: requireLease(job)
|
|
546
748
|
})).jobId;
|
|
547
749
|
}
|
|
548
750
|
async moveStatus(move) {
|
|
549
751
|
if (move.moverJobId === null) return null;
|
|
550
|
-
return move.storageClass === "
|
|
752
|
+
return laneOf(move.storageClass) === "media" ? this.deps.participants.analytics.getMove(move.moverJobId) : this.deps.participants.recorder.getMove(move.moverJobId);
|
|
551
753
|
}
|
|
552
754
|
async cancelMove(move) {
|
|
553
755
|
if (move.moverJobId === null) return;
|
|
554
|
-
if (move.storageClass === "
|
|
756
|
+
if (laneOf(move.storageClass) === "media") await this.deps.participants.analytics.cancelMove(move.moverJobId);
|
|
555
757
|
else await this.deps.participants.recorder.cancelMove(move.moverJobId);
|
|
556
758
|
}
|
|
557
759
|
participant(name) {
|
|
@@ -580,6 +782,27 @@ function requireLease(job) {
|
|
|
580
782
|
function isTerminal(job) {
|
|
581
783
|
return job.phase === "done" || job.phase === "failed" || job.phase === "cancelled";
|
|
582
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* Which single-flight engine owns a class.
|
|
787
|
+
*
|
|
788
|
+
* `galleryMedia` is in the media lane not because it is event media — it
|
|
789
|
+
* explicitly is not (D197) — but because it is moved by the same
|
|
790
|
+
* `MediaRelocateEngine`, which refuses a second running job.
|
|
791
|
+
*/
|
|
792
|
+
function laneOf(storageClass) {
|
|
793
|
+
return storageClass === "eventMedia" || storageClass === "galleryMedia" ? "media" : "footage";
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* True when this phase runs with every writer already back up, so no
|
|
797
|
+
* maintenance lease exists to present to a participant.
|
|
798
|
+
*
|
|
799
|
+
* `verifying` is in both camps: it precedes the repoint (paused) under
|
|
800
|
+
* `blocking` and follows the drain (running) under `nonBlocking`.
|
|
801
|
+
*/
|
|
802
|
+
function runsUnleased(job) {
|
|
803
|
+
if (job.phase === "sealing" || job.phase === "draining") return true;
|
|
804
|
+
return job.phase === "verifying" && job.mode === "nonBlocking";
|
|
805
|
+
}
|
|
583
806
|
//#endregion
|
|
584
807
|
//#region src/kernel/storage-location-registry.ts
|
|
585
808
|
/**
|
|
@@ -765,6 +988,14 @@ var StorageOrchestratorService = class {
|
|
|
765
988
|
* early-boot path that predates the resolver wiring.
|
|
766
989
|
*/
|
|
767
990
|
nodeLocalResolver = null;
|
|
991
|
+
/**
|
|
992
|
+
* Injected occupancy probe (see {@link LocationOccupancyProbe}). `null` until
|
|
993
|
+
* {@link setOccupancyProbe} runs, and a `null` probe answers `unknown` — so a
|
|
994
|
+
* service with no probe REFUSES an unforced delete rather than performing one
|
|
995
|
+
* it could not check. The check is the point; a check that defaults to
|
|
996
|
+
* "permit" is a paragraph, not a guard.
|
|
997
|
+
*/
|
|
998
|
+
occupancyProbe = null;
|
|
768
999
|
/** `localNodeId` with any forked-child `/addon` suffix stripped. */
|
|
769
1000
|
localNode;
|
|
770
1001
|
/**
|
|
@@ -863,6 +1094,15 @@ var StorageOrchestratorService = class {
|
|
|
863
1094
|
this.nodeLocalResolver = resolver;
|
|
864
1095
|
}
|
|
865
1096
|
/**
|
|
1097
|
+
* Inject the "does this location still hold anything" probe read by
|
|
1098
|
+
* {@link deleteLocation}. Absent → every delete sees `unknown` and therefore
|
|
1099
|
+
* REFUSES without `force`, which is the safe direction for a service wired
|
|
1100
|
+
* without one.
|
|
1101
|
+
*/
|
|
1102
|
+
setOccupancyProbe(probe) {
|
|
1103
|
+
this.occupancyProbe = probe;
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
866
1106
|
* The full set of addon-declared storage locations, as aggregated by
|
|
867
1107
|
* the kernel and injected via {@link setRegistry}. Powers the admin-UI
|
|
868
1108
|
* Data screen's per-declaration grouping. Empty until the registry is
|
|
@@ -1085,13 +1325,22 @@ var StorageOrchestratorService = class {
|
|
|
1085
1325
|
* Persistence (Task 6) mirrors the delete asynchronously, with errors
|
|
1086
1326
|
* routed to the logger — see `upsertLocation` for the rationale.
|
|
1087
1327
|
*/
|
|
1088
|
-
deleteLocation(id) {
|
|
1328
|
+
async deleteLocation(id, options) {
|
|
1089
1329
|
const loc = this.locations.get(id);
|
|
1090
1330
|
if (!loc) throw new Error(`Storage location "${id}" not found`);
|
|
1091
1331
|
if (loc.isSystem) throw new Error(`Storage location "${id}" is system-managed and cannot be deleted. Edit its config (path / providerId) instead.`);
|
|
1092
1332
|
if (loc.isDefault) {
|
|
1093
1333
|
if (![...this.locations.values()].find((l) => l.type === loc.type && l.id !== id && l.isDefault)) throw new Error(`Cannot delete default location "${id}" for type "${loc.type}" — promote another location to default first`);
|
|
1094
1334
|
}
|
|
1335
|
+
const occupancy = await this.probeOccupancy(loc);
|
|
1336
|
+
if (occupancy !== "empty") {
|
|
1337
|
+
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`);
|
|
1338
|
+
this.logger.warn("storage-orchestrator: FORCED delete of a location that was not verified empty", { meta: {
|
|
1339
|
+
id,
|
|
1340
|
+
type: loc.type,
|
|
1341
|
+
occupancy
|
|
1342
|
+
} });
|
|
1343
|
+
}
|
|
1095
1344
|
this.locations.delete(id);
|
|
1096
1345
|
if (this.locationStore) this.locationStore.delete(id).catch((err) => {
|
|
1097
1346
|
this.logger.error("storage-orchestrator: delete persistence failed", { meta: {
|
|
@@ -1101,6 +1350,23 @@ var StorageOrchestratorService = class {
|
|
|
1101
1350
|
});
|
|
1102
1351
|
}
|
|
1103
1352
|
/**
|
|
1353
|
+
* Run the injected occupancy probe, converting "no probe" and "the probe
|
|
1354
|
+
* threw" into `unknown` rather than into a permit.
|
|
1355
|
+
*/
|
|
1356
|
+
async probeOccupancy(location) {
|
|
1357
|
+
const probe = this.occupancyProbe;
|
|
1358
|
+
if (probe === null) return "unknown";
|
|
1359
|
+
try {
|
|
1360
|
+
return await probe(location);
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
this.logger.warn("storage-orchestrator: occupancy probe failed", { meta: {
|
|
1363
|
+
id: location.id,
|
|
1364
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1365
|
+
} });
|
|
1366
|
+
return "unknown";
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1104
1370
|
* Remove system-seeded locations whose type is no longer declared by any
|
|
1105
1371
|
* addon (stale defaults from a removed location type). Operator-added
|
|
1106
1372
|
* (non-system) locations of an undeclared type are KEPT but warned — the
|
|
@@ -1537,6 +1803,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
1537
1803
|
});
|
|
1538
1804
|
this.service = service;
|
|
1539
1805
|
service.setNodeLocalResolver((providerId) => this.nodeLocalByProvider.get(providerId));
|
|
1806
|
+
service.setOccupancyProbe((location) => this.locationOccupancy(location));
|
|
1540
1807
|
await service.initialize();
|
|
1541
1808
|
const provider = {
|
|
1542
1809
|
listLocations: async ({ type }) => {
|
|
@@ -1549,8 +1816,8 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
1549
1816
|
getDefaultLocation: async ({ type }) => service.getDefaultLocation(type),
|
|
1550
1817
|
listLocationDeclarations: async () => service.listDeclarations(),
|
|
1551
1818
|
upsertLocation: async (input) => service.upsertLocation(input),
|
|
1552
|
-
deleteLocation: async ({ id }) => {
|
|
1553
|
-
service.deleteLocation(id);
|
|
1819
|
+
deleteLocation: async ({ id, force }) => {
|
|
1820
|
+
await service.deleteLocation(id, { force: force === true });
|
|
1554
1821
|
},
|
|
1555
1822
|
testLocation: async ({ id }) => {
|
|
1556
1823
|
const loc = service.getLocationById(id);
|
|
@@ -1713,6 +1980,7 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
1713
1980
|
await this.ctx.api.recording.resumeForStorageMigration.mutate({ leaseId });
|
|
1714
1981
|
},
|
|
1715
1982
|
startMove: (input) => this.ctx.api.recording.startStorageMigrationMove.mutate(input),
|
|
1983
|
+
startDrain: (input) => this.ctx.api.recording.relocateFootage.mutate(input),
|
|
1716
1984
|
getMove: (jobId) => this.ctx.api.recording.getStorageMigrationMoveStatus.query({ jobId }),
|
|
1717
1985
|
cancelMove: async (jobId) => (await this.ctx.api.recording.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
|
|
1718
1986
|
refresh: async (leaseId) => {
|
|
@@ -1727,6 +1995,8 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
1727
1995
|
await this.ctx.api.pipelineAnalytics.resumeForStorageMigration.mutate({ leaseId });
|
|
1728
1996
|
},
|
|
1729
1997
|
startMove: (input) => this.ctx.api.pipelineAnalytics.startStorageMigrationMove.mutate(input),
|
|
1998
|
+
startDrain: (input) => this.ctx.api.pipelineAnalytics.relocateMedia.mutate(input),
|
|
1999
|
+
countUnstamped: () => this.ctx.api.pipelineAnalytics.countUnstampedEventMedia.query({}),
|
|
1730
2000
|
getMove: (jobId) => this.ctx.api.pipelineAnalytics.getStorageMigrationMoveStatus.query({ jobId }),
|
|
1731
2001
|
cancelMove: async (jobId) => (await this.ctx.api.pipelineAnalytics.cancelStorageMigrationMove.mutate({ jobId })).cancelled,
|
|
1732
2002
|
refresh: async (leaseId) => {
|
|
@@ -1735,7 +2005,8 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
1735
2005
|
}
|
|
1736
2006
|
},
|
|
1737
2007
|
now: () => Date.now(),
|
|
1738
|
-
newId: () => (0, node_crypto.randomUUID)()
|
|
2008
|
+
newId: () => (0, node_crypto.randomUUID)(),
|
|
2009
|
+
deviceKeyOf: (locationId) => this.locationDeviceKey(locationId)
|
|
1739
2010
|
});
|
|
1740
2011
|
this.migration = migration;
|
|
1741
2012
|
const migrationProvider = {
|
|
@@ -1948,6 +2219,44 @@ var StorageOrchestratorAddon = class extends require_dist.BaseAddon {
|
|
|
1948
2219
|
return node_path.resolve(bp);
|
|
1949
2220
|
}
|
|
1950
2221
|
}
|
|
2222
|
+
/**
|
|
2223
|
+
* Does this location still hold anything? A SHALLOW `readdir` of its
|
|
2224
|
+
* `basePath` — one syscall, never a walk. `/recordings` holds one entry per
|
|
2225
|
+
* camera, so this is cheap on a volume with millions of segments, and a
|
|
2226
|
+
* `find`-style walk of a saturated recordings disk is precisely what must
|
|
2227
|
+
* not happen here.
|
|
2228
|
+
*
|
|
2229
|
+
* **What it cannot see, stated rather than papered over:**
|
|
2230
|
+
*
|
|
2231
|
+
* - A location on ANOTHER node, or behind a remote provider (S3 / SFTP /
|
|
2232
|
+
* WebDAV): its bytes are not on this filesystem and this probe answers
|
|
2233
|
+
* `unknown`, never `empty`.
|
|
2234
|
+
* - A location with no `basePath` — same answer.
|
|
2235
|
+
* - The difference between "holds live footage" and "holds one stray
|
|
2236
|
+
* lock-file". It measures BYTES ON DISK, not durable rows, so it refuses
|
|
2237
|
+
* a location holding orphan files that no index names. That direction is
|
|
2238
|
+
* the safe one, and `force` is the escape hatch for it.
|
|
2239
|
+
*
|
|
2240
|
+
* A missing root (`ENOENT`) is `empty`, not `unknown`: an unmounted disk and
|
|
2241
|
+
* a deleted directory are indistinguishable here, and if the record's own
|
|
2242
|
+
* path does not exist there is nothing this delete can strand. Every OTHER
|
|
2243
|
+
* errno — `EACCES`, `EIO`, a stale NFS handle — is `unknown` and refuses.
|
|
2244
|
+
*/
|
|
2245
|
+
async locationOccupancy(location) {
|
|
2246
|
+
if ((location.nodeId === void 0 || location.nodeId === "" ? HUB_NODE_ID : location.nodeId) !== (this.service?.getLocalNodeId() ?? HUB_NODE_ID)) return "unknown";
|
|
2247
|
+
const basePath = this.locationBasePath(location.id);
|
|
2248
|
+
if (basePath === null) return "unknown";
|
|
2249
|
+
try {
|
|
2250
|
+
return (await node_fs_promises.readdir(basePath)).length > 0 ? "occupied" : "empty";
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") return "empty";
|
|
2253
|
+
this.ctx.logger.warn("storage-orchestrator: could not read a location root to check it", { meta: {
|
|
2254
|
+
id: location.id,
|
|
2255
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2256
|
+
} });
|
|
2257
|
+
return "unknown";
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
1951
2260
|
/** Free capacity (%) on a location's volume via `statfs`; 100 (guard inert) when unstattable. */
|
|
1952
2261
|
async locationFreePercent(locationId) {
|
|
1953
2262
|
const bp = this.locationBasePath(locationId);
|