@camstack/addon-pipeline 1.2.203 → 1.2.204

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.
@@ -11498,11 +11498,13 @@ function weightOfUnmeasured(demands) {
11498
11498
  return measured.length % 2 === 1 ? measured[mid] : (measured[mid - 1] + measured[mid]) / 2;
11499
11499
  }
11500
11500
  /** Normalised weight per location: headroom share, or an even split when no
11501
- * location reports any headroom (nothing is known yet — never divide by 0). */
11501
+ * location reports any headroom (nothing is known yet — never divide by 0).
11502
+ * An UNMEASURABLE location (`headroomBytes: null`) contributes nothing and
11503
+ * weighs nothing: it keeps what it already holds and attracts no new work. */
11502
11504
  function weightsFor(pool) {
11503
- const total = pool.reduce((sum, c) => sum + Math.max(0, c.headroomBytes), 0);
11505
+ const total = pool.reduce((sum, c) => sum + Math.max(0, c.headroomBytes ?? 0), 0);
11504
11506
  const weights = /* @__PURE__ */ new Map();
11505
- for (const c of pool) weights.set(c.locationId, total > 0 ? Math.max(0, c.headroomBytes) / total : 1 / pool.length);
11507
+ for (const c of pool) weights.set(c.locationId, total > 0 ? Math.max(0, c.headroomBytes ?? 0) / total : 1 / pool.length);
11506
11508
  return weights;
11507
11509
  }
11508
11510
  /** The location with the largest unmet share of the target, ties broken by the
@@ -12036,6 +12038,9 @@ var PlacementService = class {
12036
12038
  verified = /* @__PURE__ */ new Set();
12037
12039
  /** Locations the probe declared unusable — re-probed after a change. */
12038
12040
  unusable = /* @__PURE__ */ new Set();
12041
+ /** Locations whose capacity could not be read, by reason. One line per
12042
+ * distinct fault (this is on the 30 s tick) and one when it clears. */
12043
+ capacityFaults = /* @__PURE__ */ new Map();
12039
12044
  /** Cameras that have already announced "every location is unusable". One line
12040
12045
  * per camera per episode: the fault is asked per-camera, and repeating it per
12041
12046
  * attach is the noise this state exists to end. */
@@ -12238,7 +12243,7 @@ var PlacementService = class {
12238
12243
  const byId = new Map(candidates.map((c) => [c.locationId, c.headroomBytes]));
12239
12244
  return pool.map((location) => {
12240
12245
  const headroomBytes = byId.get(location.id);
12241
- return headroomBytes === void 0 ? location : {
12246
+ return headroomBytes === void 0 || headroomBytes === null ? location : {
12242
12247
  ...location,
12243
12248
  headroomBytes
12244
12249
  };
@@ -12281,7 +12286,7 @@ var PlacementService = class {
12281
12286
  return NO_PLAN;
12282
12287
  }
12283
12288
  const current = new Map(Object.entries(blob.assignments));
12284
- const evacuate = new Set(candidates.filter((c) => c.headroomBytes <= 0).map((c) => c.locationId));
12289
+ const evacuate = new Set(candidates.filter((c) => c.headroomBytes !== null && c.headroomBytes <= 0).map((c) => c.locationId));
12285
12290
  for (const location of pool) if (location.readOnly) evacuate.add(location.id);
12286
12291
  const plan = planPlacement({
12287
12292
  candidates,
@@ -12441,8 +12446,20 @@ var PlacementService = class {
12441
12446
  * `min(free − minFree floor, maxUsedGb − used)`, clamped at zero: the first
12442
12447
  * term is the per-location free-space guard the storage pressure manager also
12443
12448
  * enforces, the second an optional operator cap on what recordings may take.
12444
- * Nothing measurable → zero headroom everywhere, which the planner reads as
12445
- * "no signal" and splits evenly rather than pretending to know.
12449
+ *
12450
+ * **Nothing measurable is `null`, not zero.** `readLocationCapacity` models
12451
+ * its failure — a NAS that stops answering `statfs` returns nulls AND the
12452
+ * reason. This method used to discard the reason and fold the whole case into
12453
+ * `headroomBytes: 0`, which `recompute` reads as "at its limit" and puts in
12454
+ * `evacuate`: one failed `statfs` moved every camera off a healthy disk, and
12455
+ * not one line was written about it. (The comment that used to sit here
12456
+ * claimed the opposite — "no signal → the planner splits evenly" — which is
12457
+ * true of the PLANNER's weights and was never true of the evacuation set.)
12458
+ *
12459
+ * So an unmeasurable location gets `null`, which is not evacuated and wins no
12460
+ * new work, and the reason is logged against the location — once per distinct
12461
+ * fault, since this runs on the 30 s placement tick, plus one line when it
12462
+ * recovers.
12446
12463
  */
12447
12464
  async candidatesFor(pool) {
12448
12465
  const out = [];
@@ -12450,7 +12467,8 @@ var PlacementService = class {
12450
12467
  if (location.readOnly) continue;
12451
12468
  const capacity = await this.deps.capacityFor?.(location) ?? {
12452
12469
  availableBytes: null,
12453
- totalBytes: null
12470
+ totalBytes: null,
12471
+ error: "no capacity source is wired on this node"
12454
12472
  };
12455
12473
  const limits = [];
12456
12474
  if (capacity.availableBytes !== null && capacity.totalBytes !== null) {
@@ -12461,15 +12479,45 @@ var PlacementService = class {
12461
12479
  const used = await this.deps.usedOnLocation?.(location.id) ?? 0;
12462
12480
  limits.push(require_dist.gbToBytes(location.maxUsedGb) - used);
12463
12481
  }
12482
+ const headroomBytes = limits.length === 0 ? null : Math.max(0, Math.min(...limits));
12483
+ this.reportCapacitySignal(location, headroomBytes, capacity.error);
12464
12484
  out.push({
12465
12485
  locationId: location.id,
12466
12486
  type: location.type,
12467
- headroomBytes: limits.length === 0 ? 0 : Math.max(0, Math.min(...limits))
12487
+ headroomBytes
12468
12488
  });
12469
12489
  }
12470
12490
  return out;
12471
12491
  }
12472
12492
  /**
12493
+ * Say it out loud when a location's capacity cannot be read, and say it again
12494
+ * when it comes back. Deduplicated by reason because `candidatesFor` runs on
12495
+ * every placement tick — a line per tick is a line nobody reads, and no line
12496
+ * at all is what let a dead NAS look like a full one.
12497
+ */
12498
+ reportCapacitySignal(location, headroomBytes, error) {
12499
+ const previous = this.capacityFaults.get(location.id);
12500
+ if (headroomBytes !== null) {
12501
+ if (previous !== void 0) {
12502
+ this.capacityFaults.delete(location.id);
12503
+ this.deps.logger.info("recorder placement: capacity readable again — this location is a write target once more", { meta: {
12504
+ locationId: location.id,
12505
+ root: location.root,
12506
+ previousError: previous
12507
+ } });
12508
+ }
12509
+ return;
12510
+ }
12511
+ const reason = error ?? "the capacity read returned no bytes and no reason";
12512
+ if (previous === reason) return;
12513
+ this.capacityFaults.set(location.id, reason);
12514
+ this.deps.logger.warn("recorder placement: capacity UNMEASURABLE — headroom is unknown, so this location is neither evacuated nor given new cameras", { meta: {
12515
+ locationId: location.id,
12516
+ root: location.root,
12517
+ error: reason
12518
+ } });
12519
+ }
12520
+ /**
12473
12521
  * Volume-check the chosen location, failing the assignment over to a sibling
12474
12522
  * when the bytes under its root are not the bytes we wrote. The probe runs
12475
12523
  * ONCE per location per location-set generation — it is a claim, not a poll.
@@ -18304,10 +18352,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18304
18352
  const [assignments, candidates] = await Promise.all([placement.currentAssignments(), placement.candidates()]);
18305
18353
  return {
18306
18354
  assignments,
18307
- headroom: candidates.map((candidate) => ({
18355
+ headroom: candidates.flatMap((candidate) => candidate.headroomBytes === null ? [] : [{
18308
18356
  locationId: candidate.locationId,
18309
18357
  headroomBytes: candidate.headroomBytes
18310
- }))
18358
+ }])
18311
18359
  };
18312
18360
  }
18313
18361
  /**
@@ -11492,11 +11492,13 @@ function weightOfUnmeasured(demands) {
11492
11492
  return measured.length % 2 === 1 ? measured[mid] : (measured[mid - 1] + measured[mid]) / 2;
11493
11493
  }
11494
11494
  /** Normalised weight per location: headroom share, or an even split when no
11495
- * location reports any headroom (nothing is known yet — never divide by 0). */
11495
+ * location reports any headroom (nothing is known yet — never divide by 0).
11496
+ * An UNMEASURABLE location (`headroomBytes: null`) contributes nothing and
11497
+ * weighs nothing: it keeps what it already holds and attracts no new work. */
11496
11498
  function weightsFor(pool) {
11497
- const total = pool.reduce((sum, c) => sum + Math.max(0, c.headroomBytes), 0);
11499
+ const total = pool.reduce((sum, c) => sum + Math.max(0, c.headroomBytes ?? 0), 0);
11498
11500
  const weights = /* @__PURE__ */ new Map();
11499
- for (const c of pool) weights.set(c.locationId, total > 0 ? Math.max(0, c.headroomBytes) / total : 1 / pool.length);
11501
+ for (const c of pool) weights.set(c.locationId, total > 0 ? Math.max(0, c.headroomBytes ?? 0) / total : 1 / pool.length);
11500
11502
  return weights;
11501
11503
  }
11502
11504
  /** The location with the largest unmet share of the target, ties broken by the
@@ -12030,6 +12032,9 @@ var PlacementService = class {
12030
12032
  verified = /* @__PURE__ */ new Set();
12031
12033
  /** Locations the probe declared unusable — re-probed after a change. */
12032
12034
  unusable = /* @__PURE__ */ new Set();
12035
+ /** Locations whose capacity could not be read, by reason. One line per
12036
+ * distinct fault (this is on the 30 s tick) and one when it clears. */
12037
+ capacityFaults = /* @__PURE__ */ new Map();
12033
12038
  /** Cameras that have already announced "every location is unusable". One line
12034
12039
  * per camera per episode: the fault is asked per-camera, and repeating it per
12035
12040
  * attach is the noise this state exists to end. */
@@ -12232,7 +12237,7 @@ var PlacementService = class {
12232
12237
  const byId = new Map(candidates.map((c) => [c.locationId, c.headroomBytes]));
12233
12238
  return pool.map((location) => {
12234
12239
  const headroomBytes = byId.get(location.id);
12235
- return headroomBytes === void 0 ? location : {
12240
+ return headroomBytes === void 0 || headroomBytes === null ? location : {
12236
12241
  ...location,
12237
12242
  headroomBytes
12238
12243
  };
@@ -12275,7 +12280,7 @@ var PlacementService = class {
12275
12280
  return NO_PLAN;
12276
12281
  }
12277
12282
  const current = new Map(Object.entries(blob.assignments));
12278
- const evacuate = new Set(candidates.filter((c) => c.headroomBytes <= 0).map((c) => c.locationId));
12283
+ const evacuate = new Set(candidates.filter((c) => c.headroomBytes !== null && c.headroomBytes <= 0).map((c) => c.locationId));
12279
12284
  for (const location of pool) if (location.readOnly) evacuate.add(location.id);
12280
12285
  const plan = planPlacement({
12281
12286
  candidates,
@@ -12435,8 +12440,20 @@ var PlacementService = class {
12435
12440
  * `min(free − minFree floor, maxUsedGb − used)`, clamped at zero: the first
12436
12441
  * term is the per-location free-space guard the storage pressure manager also
12437
12442
  * enforces, the second an optional operator cap on what recordings may take.
12438
- * Nothing measurable → zero headroom everywhere, which the planner reads as
12439
- * "no signal" and splits evenly rather than pretending to know.
12443
+ *
12444
+ * **Nothing measurable is `null`, not zero.** `readLocationCapacity` models
12445
+ * its failure — a NAS that stops answering `statfs` returns nulls AND the
12446
+ * reason. This method used to discard the reason and fold the whole case into
12447
+ * `headroomBytes: 0`, which `recompute` reads as "at its limit" and puts in
12448
+ * `evacuate`: one failed `statfs` moved every camera off a healthy disk, and
12449
+ * not one line was written about it. (The comment that used to sit here
12450
+ * claimed the opposite — "no signal → the planner splits evenly" — which is
12451
+ * true of the PLANNER's weights and was never true of the evacuation set.)
12452
+ *
12453
+ * So an unmeasurable location gets `null`, which is not evacuated and wins no
12454
+ * new work, and the reason is logged against the location — once per distinct
12455
+ * fault, since this runs on the 30 s placement tick, plus one line when it
12456
+ * recovers.
12440
12457
  */
12441
12458
  async candidatesFor(pool) {
12442
12459
  const out = [];
@@ -12444,7 +12461,8 @@ var PlacementService = class {
12444
12461
  if (location.readOnly) continue;
12445
12462
  const capacity = await this.deps.capacityFor?.(location) ?? {
12446
12463
  availableBytes: null,
12447
- totalBytes: null
12464
+ totalBytes: null,
12465
+ error: "no capacity source is wired on this node"
12448
12466
  };
12449
12467
  const limits = [];
12450
12468
  if (capacity.availableBytes !== null && capacity.totalBytes !== null) {
@@ -12455,15 +12473,45 @@ var PlacementService = class {
12455
12473
  const used = await this.deps.usedOnLocation?.(location.id) ?? 0;
12456
12474
  limits.push(gbToBytes(location.maxUsedGb) - used);
12457
12475
  }
12476
+ const headroomBytes = limits.length === 0 ? null : Math.max(0, Math.min(...limits));
12477
+ this.reportCapacitySignal(location, headroomBytes, capacity.error);
12458
12478
  out.push({
12459
12479
  locationId: location.id,
12460
12480
  type: location.type,
12461
- headroomBytes: limits.length === 0 ? 0 : Math.max(0, Math.min(...limits))
12481
+ headroomBytes
12462
12482
  });
12463
12483
  }
12464
12484
  return out;
12465
12485
  }
12466
12486
  /**
12487
+ * Say it out loud when a location's capacity cannot be read, and say it again
12488
+ * when it comes back. Deduplicated by reason because `candidatesFor` runs on
12489
+ * every placement tick — a line per tick is a line nobody reads, and no line
12490
+ * at all is what let a dead NAS look like a full one.
12491
+ */
12492
+ reportCapacitySignal(location, headroomBytes, error) {
12493
+ const previous = this.capacityFaults.get(location.id);
12494
+ if (headroomBytes !== null) {
12495
+ if (previous !== void 0) {
12496
+ this.capacityFaults.delete(location.id);
12497
+ this.deps.logger.info("recorder placement: capacity readable again — this location is a write target once more", { meta: {
12498
+ locationId: location.id,
12499
+ root: location.root,
12500
+ previousError: previous
12501
+ } });
12502
+ }
12503
+ return;
12504
+ }
12505
+ const reason = error ?? "the capacity read returned no bytes and no reason";
12506
+ if (previous === reason) return;
12507
+ this.capacityFaults.set(location.id, reason);
12508
+ this.deps.logger.warn("recorder placement: capacity UNMEASURABLE — headroom is unknown, so this location is neither evacuated nor given new cameras", { meta: {
12509
+ locationId: location.id,
12510
+ root: location.root,
12511
+ error: reason
12512
+ } });
12513
+ }
12514
+ /**
12467
12515
  * Volume-check the chosen location, failing the assignment over to a sibling
12468
12516
  * when the bytes under its root are not the bytes we wrote. The probe runs
12469
12517
  * ONCE per location per location-set generation — it is a claim, not a poll.
@@ -18298,10 +18346,10 @@ var RecorderV2Addon = class extends BaseAddon {
18298
18346
  const [assignments, candidates] = await Promise.all([placement.currentAssignments(), placement.candidates()]);
18299
18347
  return {
18300
18348
  assignments,
18301
- headroom: candidates.map((candidate) => ({
18349
+ headroom: candidates.flatMap((candidate) => candidate.headroomBytes === null ? [] : [{
18302
18350
  locationId: candidate.locationId,
18303
18351
  headroomBytes: candidate.headroomBytes
18304
- }))
18352
+ }])
18305
18353
  };
18306
18354
  }
18307
18355
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.203",
3
+ "version": "1.2.204",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",