@camstack/addon-pipeline 1.2.219 → 1.2.220

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.
Files changed (36) hide show
  1. package/dist/audio-analyzer/index.js +49 -5
  2. package/dist/audio-analyzer/index.mjs +46 -3
  3. package/dist/chunk-emK7D4bc.js +35 -0
  4. package/dist/{default-detection-model-Dsk1a5pY.mjs → default-detection-model-DBNEc3iw.mjs} +1 -1
  5. package/dist/{default-detection-model-d7-Q_LMo.js → default-detection-model-x6oNMBDH.js} +1 -1
  6. package/dist/detection-pipeline/index.js +10 -9
  7. package/dist/detection-pipeline/index.mjs +4 -4
  8. package/dist/{dist-wtNhtVVo.js → dist-ByPsWlTa.js} +2 -37
  9. package/dist/{dist-CKK0CBUW.mjs → dist-ClihWGOZ.mjs} +2 -2
  10. package/dist/{lazy-sharp-zoQ07I43.js → lazy-sharp-BuwKlQLR.js} +2 -2
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-BduNi21Z.js → node-CNPnaux4.js} +4 -3
  14. package/dist/{node-CrNBWFbA.mjs → node-DiaTmz84.mjs} +1 -1
  15. package/dist/pipeline-runner/index.js +222 -157
  16. package/dist/pipeline-runner/index.mjs +218 -154
  17. package/dist/{process-memory-Dwl0MFPH.js → process-memory-9KqRtUHt.js} +3 -2
  18. package/dist/{process-memory-BRQOtDN1.mjs → process-memory-D4t8hpJd.mjs} +1 -1
  19. package/dist/recorder/index.js +334 -123
  20. package/dist/recorder/index.mjs +332 -122
  21. package/dist/{segment-demux-js-D2o9ShNL.mjs → segment-demux-js-CFFXVae4.mjs} +1 -1
  22. package/dist/{segment-demux-js-CFOU3biG.js → segment-demux-js-CZefXDLx.js} +4 -3
  23. package/dist/session-decode/decode-worker-child.js +155 -74
  24. package/dist/session-decode/decode-worker-child.mjs +154 -73
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DN_IoShh.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CtdpN7Sz.mjs} +3 -3
  26. package/dist/stream-broker/demux-worker-child.js +1 -1
  27. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  28. package/dist/stream-broker/{hostInit-n7c9pNlt.mjs → hostInit-McoUiljf.mjs} +3 -3
  29. package/dist/stream-broker/index.js +24 -25
  30. package/dist/stream-broker/index.mjs +17 -19
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/dist/worker-protocol-B2MfQLlu.js +184 -0
  33. package/dist/worker-protocol-C_W-P_g-.mjs +161 -0
  34. package/package.json +1 -1
  35. package/dist/worker-protocol-Cx3Nqreq.mjs +0 -264
  36. package/dist/worker-protocol-iWTFnnUM.js +0 -299
@@ -2,15 +2,16 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-wtNhtVVo.js");
6
- const require_node = require("../node-BduNi21Z.js");
5
+ const require_chunk = require("../chunk-emK7D4bc.js");
6
+ const require_dist = require("../dist-ByPsWlTa.js");
7
+ const require_node = require("../node-CNPnaux4.js");
7
8
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
9
  const require_restream_intent = require("../restream-intent-B1Difcnq.js");
9
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
10
11
  let node_crypto = require("node:crypto");
11
12
  let node_fs = require("node:fs");
12
13
  let node_path = require("node:path");
13
- node_path = require_dist.__toESM(node_path);
14
+ node_path = require_chunk.__toESM(node_path);
14
15
  let node_child_process = require("node:child_process");
15
16
  let node_fs_promises = require("node:fs/promises");
16
17
  let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
@@ -1773,51 +1774,20 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
1773
1774
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
1774
1775
  }
1775
1776
  var HOUR_MS$7 = 36e5;
1776
- /**
1777
- * Hydrate ONLY the hours a window spans, instead of walking the archive.
1778
- *
1779
- * `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
1780
- * subtree — ">1M files and takes minutes" per its own comment. Until it lands,
1781
- * every read of past footage sees an empty index, which the server reports as
1782
- * "no footage": measured after a deploy, a camera-day held 7 segments and grew
1783
- * only at the rate of NEW writes for ~9 minutes.
1784
- *
1785
- * The path convention makes the window addressable directly: one SHALLOW
1786
- * `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
1787
- * background full walk still runs, for the consumers that need the whole
1788
- * archive (footprint, eviction domains, retention).
1789
- *
1790
- * Hours already walked are skipped, so a drag across an hour costs nothing the
1791
- * second time.
1792
- *
1793
- * ## The ledger is the first stop for a CLOSED hour (D287)
1794
- *
1795
- * Boot no longer replays the archive into the index, so a ledger-known hour
1796
- * arrives here `unknown` and pays ONE point read of its hour row instead of a
1797
- * `readdir` — measured at ~650 ms per hour directory on the live share under
1798
- * load, against the array the scrub feeder is competing for. Hours the ledger
1799
- * does not know, and every hour if the read fails, fall through to the walk
1800
- * exactly as before: the failure direction is toward the disk, never toward an
1801
- * hour that renders empty because nobody read it.
1802
- *
1803
- * **The live hour is never served from the ledger.** It is the one hour whose
1804
- * durable row is knowably behind the disk: a segment finalized after the last
1805
- * persist and before a crash is on the array and not in the row. Boot used to
1806
- * mark every ledger hour `hydrated`, which made that tail invisible to
1807
- * playback and the timeline FOREVER, not merely under-reported. Excluding the
1808
- * live hour costs exactly one hour-directory read per process — the walk marks
1809
- * it, so the second reader pays nothing — and it is what turns a lost tail
1810
- * back into a recoverable one.
1811
- *
1812
- * A CLOSED hour already walked is skipped, which is what makes that exclusion
1813
- * affordable: without it, every window touching the live hour would re-walk
1814
- * all of its hours. The past is immutable, so re-reading a closed hour
1815
- * directory can only ever return what the index already holds; eviction
1816
- * removes rows through the index directly.
1817
- */
1818
- async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
1819
- if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1777
+ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
1778
+ const verify = options?.verifyClosedHours === true;
1779
+ if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1820
1780
  const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
1781
+ /** Per (locationId, profile, hour): what the LEDGER claimed, for the diff. */
1782
+ const claimed = /* @__PURE__ */ new Map();
1783
+ const claimKey = (locationId, hour) => `${locationId}:${hour}`;
1784
+ /**
1785
+ * Closed hours the ledger answered for with a row nobody SEALED (D451): what
1786
+ * a writer had persisted when it was last heard from, which is a legitimate
1787
+ * answer and not a complete one. Each is read from the array once and the
1788
+ * row is unioned and sealed, so the second reader pays a point read.
1789
+ */
1790
+ const unsealed = /* @__PURE__ */ new Set();
1821
1791
  if (ledger !== void 0) {
1822
1792
  const startedMs = Date.now();
1823
1793
  const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
@@ -1826,6 +1796,8 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1826
1796
  for (const hour of hours) {
1827
1797
  if (hour.hourStartMs >= liveHour) continue;
1828
1798
  index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
1799
+ claimed.set(claimKey(hour.locationId, hour.hourStartMs), hour.paths.length);
1800
+ if (!hour.sealed) unsealed.add(hour.hourStartMs);
1829
1801
  segments += hour.paths.length;
1830
1802
  served += 1;
1831
1803
  }
@@ -1834,10 +1806,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1834
1806
  meta: {
1835
1807
  hours: served,
1836
1808
  segments,
1809
+ unsealedHours: unsealed.size,
1837
1810
  ms: Date.now() - startedMs
1838
1811
  }
1839
1812
  });
1840
- if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1813
+ if (!verify && unsealed.size === 0 && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1841
1814
  }
1842
1815
  for (const [root, aliases] of aliasesByRoot(locations)) {
1843
1816
  const deviceDir = node_path.default.join(root, String(deviceId));
@@ -1858,8 +1831,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1858
1831
  const reuseBefore = index.hydrateReuseStats();
1859
1832
  let hoursRead = 0;
1860
1833
  let repaired = 0;
1834
+ let forgottenSegments = 0;
1835
+ let forgottenHours = 0;
1836
+ let sealed = 0;
1861
1837
  for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
1862
- if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
1838
+ if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
1863
1839
  for (const profile of profiles) {
1864
1840
  const rel = hourDirRelPath(deviceId, profile, hour);
1865
1841
  let files;
@@ -1872,13 +1848,35 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1872
1848
  const locationId = locationForHydratedProfile(aliases, profile).id;
1873
1849
  const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
1874
1850
  index.hydrateHour(deviceId, locationId, hour, relPaths);
1875
- if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$7 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
1876
- repaired += 1;
1877
- await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
1851
+ if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$7 > Date.now()) continue;
1852
+ const claimedHere = claimed.get(claimKey(locationId, hour));
1853
+ const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
1854
+ if (outcome === null) continue;
1855
+ if (outcome.created) repaired += 1;
1856
+ else if (claimedHere !== void 0 && outcome.added > 0) {
1857
+ forgottenSegments += outcome.added;
1858
+ forgottenHours += 1;
1878
1859
  }
1860
+ if (outcome.sealedNow) sealed += 1;
1879
1861
  }
1880
1862
  }
1881
1863
  index.markHydrated(deviceId, fromMs, toMs);
1864
+ if (verify || forgottenSegments > 0) {
1865
+ const message = "recorder: verified the hour ledger against the array";
1866
+ const entry = {
1867
+ tags: { deviceId },
1868
+ meta: {
1869
+ root,
1870
+ hoursRead,
1871
+ forgottenSegments,
1872
+ forgottenHours,
1873
+ sealed,
1874
+ verify
1875
+ }
1876
+ };
1877
+ if (forgottenSegments > 0) logger.warn(message, entry);
1878
+ else logger.info(message, entry);
1879
+ }
1882
1880
  if (hoursRead === 0) continue;
1883
1881
  const reuseAfter = index.hydrateReuseStats();
1884
1882
  const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
@@ -1889,6 +1887,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1889
1887
  root,
1890
1888
  hoursRead,
1891
1889
  repaired,
1890
+ sealed,
1892
1891
  reused,
1893
1892
  allocated
1894
1893
  }
@@ -7154,7 +7153,8 @@ async function probeVariantCodec(readPrefix, cache, deviceId, profile, firstPres
7154
7153
  * checks every one, playback does not. See the comment at the branch.
7155
7154
  */
7156
7155
  async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMode) {
7157
- if (deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs);
7156
+ const verifyClosedHours = uriMode === "absolute";
7157
+ if (verifyClosedHours || deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs, verifyClosedHours);
7158
7158
  const hydration = deps.index.hydrationOf(deviceId, fromMs, toMs);
7159
7159
  const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
7160
7160
  const candidates = deps.index.segmentsOverlapping(deviceId, profile, fromMs, toMs).flatMap((s) => {
@@ -8534,7 +8534,7 @@ var macrotask$1 = () => new Promise((resolve) => {
8534
8534
  });
8535
8535
  /**
8536
8536
  * @durable class=mirror owner=recorder
8537
- * write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
8537
+ * write="write-behind on every finalized segment (hour-row upsert, serialised per hour); the writer SEALS the hour it leaves (one write per hour); a disk walk of a closed hour unions what it found and seals; eviction drops paths"
8538
8538
  * retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
8539
8539
  */
8540
8540
  var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
@@ -8603,7 +8603,17 @@ var RECORDING_SEGMENT_HOURS_COLUMNS = [
8603
8603
  type: "INTEGER",
8604
8604
  notNull: true,
8605
8605
  defaultValue: 0
8606
- }
8606
+ },
8607
+ (
8608
+ /** {@link SegmentHourRow.sealed}, as 0/1. A row written before the column
8609
+ * existed reads 0 — unsealed — which is the direction that costs one
8610
+ * directory read, never a missing hour. */
8611
+ {
8612
+ name: "sealed",
8613
+ type: "INTEGER",
8614
+ notNull: true,
8615
+ defaultValue: 0
8616
+ })
8607
8617
  ];
8608
8618
  var RECORDING_SEGMENT_HOURS_INDEXES = [
8609
8619
  {
@@ -8621,6 +8631,32 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
8621
8631
  {
8622
8632
  name: "idx_recorder_segment_hours_loc_hour",
8623
8633
  columns: ["locationId", "hourStartMs"]
8634
+ }),
8635
+ (
8636
+ /**
8637
+ * The accounting read: `SUM(bytes)`, `SUM(segments)`, `MIN(minStartMs)`,
8638
+ * `MAX(maxStartMs)` over `locationId IN (…)` — one row back, and with this
8639
+ * index not one table row read to produce it (D447).
8640
+ *
8641
+ * `idx_recorder_segment_hours_loc_hour` finds the same range, but the four
8642
+ * aggregates live in the table row, next to `paths` — up to 360 segment
8643
+ * paths of ~80 characters per hour — so the eviction index served the
8644
+ * accounting read at the price of ~30 KB of table page per hour row. On the
8645
+ * live hub that was `durationMs=1001…2902` once a MINUTE on
8646
+ * `hub/sqlite-settings` (174 slow-call WARNs in ten hours on 2026-09-10/11),
8647
+ * every one of them holding the thread every configuration read in the
8648
+ * cluster waits on. The index is ~50 bytes a row over ~19 k rows — nothing —
8649
+ * and the first declare after it ships builds it in well under a second.
8650
+ */
8651
+ {
8652
+ name: "idx_recorder_segment_hours_loc_accounting",
8653
+ columns: [
8654
+ "locationId",
8655
+ "bytes",
8656
+ "segments",
8657
+ "minStartMs",
8658
+ "maxStartMs"
8659
+ ]
8624
8660
  })
8625
8661
  ];
8626
8662
  /** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
@@ -8679,6 +8715,7 @@ function hourAggregate(paths) {
8679
8715
  function segmentHourRow(base) {
8680
8716
  return {
8681
8717
  ...base,
8718
+ sealed: base.sealed ?? false,
8682
8719
  pathsResident: true,
8683
8720
  ...hourAggregate(base.paths)
8684
8721
  };
@@ -8791,7 +8828,8 @@ var SEGMENT_HOUR_SUMMARY_COLUMNS = [
8791
8828
  "bytes",
8792
8829
  "segments",
8793
8830
  "minStartMs",
8794
- "maxStartMs"
8831
+ "maxStartMs",
8832
+ "sealed"
8795
8833
  ];
8796
8834
  /** Summary rows folded into the mirror between yields. */
8797
8835
  var LOAD_CHUNK = 5e3;
@@ -8853,6 +8891,19 @@ function hourOf(row) {
8853
8891
  key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
8854
8892
  };
8855
8893
  }
8894
+ /** The identity of one writer: a camera's profile. Location is not part of it —
8895
+ * a relocated writer is the same writer on a new root. */
8896
+ function writerKey(deviceId, profile) {
8897
+ return `${deviceId}|${profile}`;
8898
+ }
8899
+ function detailOf(row) {
8900
+ return {
8901
+ locationId: row.locationId,
8902
+ hourStartMs: row.hourStartMs,
8903
+ paths: row.paths,
8904
+ sealed: row.sealed
8905
+ };
8906
+ }
8856
8907
  /**
8857
8908
  * The `hourStartMs` of one probe record.
8858
8909
  *
@@ -8890,9 +8941,15 @@ function rowToValue(row) {
8890
8941
  bytes: row.bytes,
8891
8942
  segments: row.segments,
8892
8943
  minStartMs: row.minStartMs,
8893
- maxStartMs: row.maxStartMs
8944
+ maxStartMs: row.maxStartMs,
8945
+ sealed: row.sealed ? 1 : 0
8894
8946
  };
8895
8947
  }
8948
+ /** The stored 0/1 (or a boolean from a fake) as the flag. Anything else —
8949
+ * including the absence a pre-D451 row shows — is unsealed. */
8950
+ function sealedOf(raw) {
8951
+ return raw === 1 || raw === true;
8952
+ }
8896
8953
  function recordToRow(key, data) {
8897
8954
  const deviceId = Number(data["deviceId"]);
8898
8955
  const profile = data["profile"];
@@ -8911,7 +8968,8 @@ function recordToRow(key, data) {
8911
8968
  profile,
8912
8969
  locationId,
8913
8970
  hourStartMs: hour,
8914
- paths: raw
8971
+ paths: raw,
8972
+ sealed: sealedOf(data["sealed"])
8915
8973
  });
8916
8974
  }
8917
8975
  /** The paths of a summary row: one shared empty array, never a per-row
@@ -8946,6 +9004,7 @@ function summaryToRow(key, data) {
8946
9004
  hourStartMs: hour,
8947
9005
  paths: NO_PATHS,
8948
9006
  pathsResident: false,
9007
+ sealed: sealedOf(data["sealed"]),
8949
9008
  bytes: finiteOr0(data["bytes"]),
8950
9009
  segments: finiteOr0(data["segments"]),
8951
9010
  minStartMs: finiteOr0(data["minStartMs"]),
@@ -8976,6 +9035,16 @@ var SegmentHourLedger = class SegmentHourLedger {
8976
9035
  loadChunk;
8977
9036
  yieldBetween;
8978
9037
  rowCap;
9038
+ /**
9039
+ * One lane per hour key: every mutation of a row runs after the previous one
9040
+ * on the same key has finished. Two appends that both read the row and both
9041
+ * wrote it back used to keep only the second — the sparse rows D451 found.
9042
+ */
9043
+ lanes = /* @__PURE__ */ new Map();
9044
+ /** Per (device, profile): the hour its writer last appended to. */
9045
+ writerHours = /* @__PURE__ */ new Map();
9046
+ /** Hour keys a write was REFUSED for in this process — never sealed by it. */
9047
+ refused = /* @__PURE__ */ new Set();
8979
9048
  constructor(deps) {
8980
9049
  this.logger = deps.logger;
8981
9050
  this.store = deps.store;
@@ -9158,59 +9227,172 @@ var SegmentHourLedger = class SegmentHourLedger {
9158
9227
  if (row.deviceId !== deviceId) continue;
9159
9228
  if (row.hourStartMs < first || row.hourStartMs >= toMs) continue;
9160
9229
  if (row.pathsResident) {
9161
- out.push({
9162
- locationId: row.locationId,
9163
- hourStartMs: row.hourStartMs,
9164
- paths: row.paths
9165
- });
9230
+ out.push(detailOf(row));
9166
9231
  continue;
9167
9232
  }
9168
9233
  wanted.push(row.key);
9169
9234
  }
9170
- for (const row of await this.materialize(wanted, deviceId)) out.push({
9171
- locationId: row.locationId,
9172
- hourStartMs: row.hourStartMs,
9173
- paths: row.paths
9174
- });
9235
+ for (const row of await this.materialize(wanted, deviceId)) out.push(detailOf(row));
9175
9236
  return out;
9176
9237
  }
9177
9238
  /**
9178
- * Adopt an hour the storage walk rebuilt and the ledger never held.
9239
+ * Union a CLOSED hour the storage walk read into its row, and SEAL it.
9179
9240
  *
9180
9241
  * The reader repairs the durable index it just reconstructed, so the next
9181
9242
  * cold read of this hour is a point read instead of another `readdir`
9182
- * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2). Two
9183
- * constraints, both enforced by the caller and re-stated here because
9184
- * getting either wrong turns a READ into durable damage: the hour must be
9185
- * CLOSED (a live hour's directory races the writer, so a walk of it is a
9186
- * lower bound, not the truth), and the ledger must hold no row for it (an
9187
- * existing row is the authority; replacing it with a walk's view is the
9188
- * D148 prune this ledger refuses to do on its own evidence).
9243
+ * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2), and it
9244
+ * seals the row because a directory read of a closed hour is the one
9245
+ * complete answer there is. The caller guarantees the hour is CLOSED — a
9246
+ * live hour's directory races its writer, so a walk of it is a lower bound.
9247
+ *
9248
+ * A row the ledger already holds is UNIONED, never replaced: the walk can
9249
+ * only add (D148) — a path the row names and the directory lacks stays,
9250
+ * because "the reader did not see it" is not evidence it is gone. This is
9251
+ * the repair D445 described and did not perform: its predecessor returned
9252
+ * on any known key, so a row known INCOMPLETELY — the only kind that needs
9253
+ * repairing — was exactly the row it refused to touch, and the export's
9254
+ * `forgottenSegments=9758` was counted again the next morning.
9255
+ *
9256
+ * `null` when the row is known and its detail could not be read: nothing
9257
+ * is written from a row whose paths are not resident.
9189
9258
  */
9190
- async adoptRebuiltHour(deviceId, profile, locationId, hour, paths) {
9259
+ async adoptWalkedHour(deviceId, profile, locationId, hour, paths) {
9191
9260
  const key = segmentHourKey(deviceId, profile, locationId, hour);
9192
- if (paths.length === 0 || this.ledger.has(key)) return;
9193
- const row = segmentHourRow({
9194
- key,
9195
- deviceId,
9196
- profile,
9197
- locationId,
9198
- hourStartMs: hour,
9199
- paths: uniquePaths(paths)
9261
+ return this.serialized(key, async () => {
9262
+ if (!this.ledger.has(key)) {
9263
+ if (paths.length === 0) return {
9264
+ created: false,
9265
+ added: 0,
9266
+ sealedNow: false
9267
+ };
9268
+ const row = segmentHourRow({
9269
+ key,
9270
+ deviceId,
9271
+ profile,
9272
+ locationId,
9273
+ hourStartMs: hour,
9274
+ paths: uniquePaths(paths),
9275
+ sealed: true
9276
+ });
9277
+ await this.ledger.put(row);
9278
+ this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
9279
+ tags: { deviceId },
9280
+ meta: {
9281
+ profile,
9282
+ locationId,
9283
+ hourStartMs: hour,
9284
+ segments: row.segments,
9285
+ bytes: row.bytes
9286
+ }
9287
+ });
9288
+ return {
9289
+ created: true,
9290
+ added: row.paths.length,
9291
+ sealedNow: true
9292
+ };
9293
+ }
9294
+ const held = await this.resident(key, deviceId);
9295
+ if (held === null) return null;
9296
+ const union = uniquePaths([...held.paths, ...paths]);
9297
+ const added = union.length - held.paths.length;
9298
+ if (added === 0 && held.sealed) return {
9299
+ created: false,
9300
+ added: 0,
9301
+ sealedNow: false
9302
+ };
9303
+ await this.ledger.put(segmentHourRow({
9304
+ ...held,
9305
+ paths: union,
9306
+ sealed: true
9307
+ }));
9308
+ return {
9309
+ created: false,
9310
+ added,
9311
+ sealedNow: !held.sealed
9312
+ };
9200
9313
  });
9201
- await this.ledger.put(row);
9202
- this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
9203
- tags: { deviceId },
9204
- meta: {
9205
- profile,
9206
- locationId,
9207
- hourStartMs: hour,
9208
- segments: row.segments,
9209
- bytes: row.bytes
9314
+ }
9315
+ /**
9316
+ * The writer of `(deviceId, profile)` stopped gracefully: seal the hour it
9317
+ * was appending to. One write, and only if the row has every finalize this
9318
+ * ledger was handed for it.
9319
+ */
9320
+ async sealWriter(deviceId, profile) {
9321
+ const last = this.writerHours.get(writerKey(deviceId, profile));
9322
+ if (last === void 0) return;
9323
+ await this.sealHour(last);
9324
+ }
9325
+ /**
9326
+ * Every mutation queued so far has landed — including the seals a rollover
9327
+ * fires without waiting. Awaited at shutdown so the hour a writer just
9328
+ * closed is sealed before the process is gone.
9329
+ */
9330
+ async flush() {
9331
+ while (this.lanes.size > 0) await Promise.all([...this.lanes.values()]);
9332
+ }
9333
+ /**
9334
+ * A finalize this ledger was NEVER handed — the fire-and-forget lane refused
9335
+ * it. The hour's row is short by that path and this process is the only
9336
+ * party that knows, so the hour must not be sealed by it; a reader walks it
9337
+ * once instead, which is exactly what an unsealed row is for.
9338
+ */
9339
+ markRefused(row) {
9340
+ this.refused.add(hourOf(row).key);
9341
+ }
9342
+ /** One write: the row as held, flagged sealed. Serialised on its key so it
9343
+ * cannot land over an append that read the row before it. */
9344
+ async sealHour(target) {
9345
+ await this.serialized(target.key, async () => {
9346
+ if (this.refused.has(target.key)) {
9347
+ this.logger.warn("recorder: hour left unsealed — a ledger write for it was refused, so its row is short and a reader must walk it", {
9348
+ tags: { deviceId: target.deviceId },
9349
+ meta: {
9350
+ profile: target.profile,
9351
+ locationId: target.locationId,
9352
+ hourStartMs: target.hourStartMs
9353
+ }
9354
+ });
9355
+ return;
9210
9356
  }
9357
+ const held = await this.resident(target.key, target.deviceId);
9358
+ if (held === null || held.sealed) return;
9359
+ await this.ledger.put({
9360
+ ...held,
9361
+ sealed: true
9362
+ });
9211
9363
  });
9212
9364
  }
9213
9365
  /**
9366
+ * Remember which hour this writer is on; when it MOVES FORWARD, seal the
9367
+ * hour it left. Backwards is a late finalize (a recovered orphan, a replay)
9368
+ * into an hour already accounted for, and seals nothing.
9369
+ */
9370
+ noteWriter(next) {
9371
+ const id = writerKey(next.deviceId, next.profile);
9372
+ const last = this.writerHours.get(id);
9373
+ if (last !== void 0 && last.key === next.key) return;
9374
+ if (last === void 0 || next.hourStartMs > last.hourStartMs) this.writerHours.set(id, next);
9375
+ if (last !== void 0 && next.hourStartMs > last.hourStartMs) this.sealHour(last);
9376
+ }
9377
+ /**
9378
+ * Run `fn` after every mutation already queued on `key`.
9379
+ *
9380
+ * A row is read, appended to and written back with awaits in between; two
9381
+ * such runs on one key interleave, and the second write carries a paths
9382
+ * array that never saw the first — the write-behind's own lost update. The
9383
+ * lane makes "read, append, write" one step per key. A rejected step must
9384
+ * not poison the lane: the next step still runs.
9385
+ */
9386
+ serialized(key, fn) {
9387
+ const run = (this.lanes.get(key) ?? Promise.resolve()).then(fn, fn);
9388
+ const settled = run.then(() => void 0, () => void 0);
9389
+ this.lanes.set(key, settled);
9390
+ settled.then(() => {
9391
+ if (this.lanes.get(key) === settled) this.lanes.delete(key);
9392
+ });
9393
+ return run;
9394
+ }
9395
+ /**
9214
9396
  * Fetch whole rows for `keys` and promote them in the mirror.
9215
9397
  *
9216
9398
  * Paged and yielding: an export window can name thousands of hours, and one
@@ -9803,30 +9985,40 @@ var SegmentHourLedger = class SegmentHourLedger {
9803
9985
  */
9804
9986
  async recordSegment(row) {
9805
9987
  const { key, hourStartMs: hour } = hourOf(row);
9806
- if (!this.ledger.has(key)) {
9807
- await this.ledger.put(segmentHourRow({
9808
- key,
9809
- deviceId: row.deviceId,
9810
- profile: row.profile,
9811
- locationId: row.locationId,
9812
- hourStartMs: hour,
9813
- paths: [row.path]
9814
- }));
9815
- return;
9816
- }
9817
- const held = await this.resident(key, row.deviceId);
9818
- if (held === null) {
9819
- this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
9820
- tags: { deviceId: row.deviceId },
9821
- meta: {
9988
+ this.noteWriter({
9989
+ key,
9990
+ deviceId: row.deviceId,
9991
+ profile: row.profile,
9992
+ locationId: row.locationId,
9993
+ hourStartMs: hour
9994
+ });
9995
+ await this.serialized(key, async () => {
9996
+ if (!this.ledger.has(key)) {
9997
+ await this.ledger.put(segmentHourRow({
9822
9998
  key,
9823
- path: row.path
9824
- }
9825
- });
9826
- return;
9827
- }
9828
- if (held.paths.includes(row.path)) return;
9829
- await this.ledger.put(appendSegmentPath(held, row.path));
9999
+ deviceId: row.deviceId,
10000
+ profile: row.profile,
10001
+ locationId: row.locationId,
10002
+ hourStartMs: hour,
10003
+ paths: [row.path]
10004
+ }));
10005
+ return;
10006
+ }
10007
+ const held = await this.resident(key, row.deviceId);
10008
+ if (held === null) {
10009
+ this.refused.add(key);
10010
+ this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
10011
+ tags: { deviceId: row.deviceId },
10012
+ meta: {
10013
+ key,
10014
+ path: row.path
10015
+ }
10016
+ });
10017
+ return;
10018
+ }
10019
+ if (held.paths.includes(row.path)) return;
10020
+ await this.ledger.put(appendSegmentPath(held, row.path));
10021
+ });
9830
10022
  }
9831
10023
  /**
9832
10024
  * Eviction confirmed these paths are gone from disk. Drop them; forget the
@@ -10002,7 +10194,8 @@ var SegmentHourLedger = class SegmentHourLedger {
10002
10194
  profile: seg.profile,
10003
10195
  locationId: seg.locationId,
10004
10196
  hourStartMs: hour,
10005
- paths: [seg.path]
10197
+ paths: [seg.path],
10198
+ sealed: hour !== currentHour
10006
10199
  }));
10007
10200
  else fromIndex.set(key, appendSegmentPath(existing, seg.path));
10008
10201
  }
@@ -15927,6 +16120,17 @@ var RecordingController = class {
15927
16120
  flushMs: Date.now() - teardownStartMs - writerStopMs
15928
16121
  }
15929
16122
  });
16123
+ try {
16124
+ await this.deps.onWriterClosed?.(deviceId, r.profile);
16125
+ } catch (err) {
16126
+ this.deps.logger.warn("recorder: sealing the closed writer’s hour failed — it stays unsealed", {
16127
+ tags: { deviceId },
16128
+ meta: {
16129
+ profile: r.profile,
16130
+ error: require_dist.errMsg(err)
16131
+ }
16132
+ });
16133
+ }
15930
16134
  try {
15931
16135
  r.watcher.stop();
15932
16136
  } catch {}
@@ -17443,10 +17647,13 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17443
17647
  },
17444
17648
  onIndexed: (row, absPath) => {
17445
17649
  const ledger = this.segmentHours;
17446
- if (ledger && !this.ledgerWriteGate.run(() => ledger.recordSegment(row))) this.ctx.logger.debug("recorder: hour-ledger write dropped — lane saturated; the archive walk reconciles this hour", {
17447
- tags: { deviceId: row.deviceId },
17448
- meta: { path: row.path }
17449
- });
17650
+ if (ledger && !this.ledgerWriteGate.run(() => ledger.recordSegment(row))) {
17651
+ ledger.markRefused(row);
17652
+ this.ctx.logger.debug("recorder: hour-ledger write dropped — lane saturated; the hour stays unsealed and a reader walks it", {
17653
+ tags: { deviceId: row.deviceId },
17654
+ meta: { path: row.path }
17655
+ });
17656
+ }
17450
17657
  if (!isMfraTableServable(row.startMs, Date.now())) return;
17451
17658
  if (!this.mfraCaptureGate.run(async () => {
17452
17659
  try {
@@ -17574,6 +17781,9 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17574
17781
  locations: () => this.resolvedLocations,
17575
17782
  placeProfile: (deviceId, profile) => this.placeProfile(deviceId, profile),
17576
17783
  onPlacementTick: (deviceIds) => this.recomputePlacement(deviceIds),
17784
+ onWriterClosed: async (deviceId, profile) => {
17785
+ await this.segmentHours?.sealWriter(deviceId, profile);
17786
+ },
17577
17787
  spawn: node_child_process.spawn,
17578
17788
  costRegistry: this.costRegistry,
17579
17789
  segmentSeconds: this.config.segmentSeconds,
@@ -17643,7 +17853,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17643
17853
  },
17644
17854
  calendar: this.calendar,
17645
17855
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
17646
- hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0),
17856
+ hydrateWindow: (deviceId, fromMs, toMs, verifyClosedHours) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0, { verifyClosedHours: verifyClosedHours === true }),
17647
17857
  onConfigChanged: async (deviceId) => {
17648
17858
  await this.refreshSensorTriggerBindings();
17649
17859
  await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());
@@ -17923,6 +18133,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17923
18133
  this.locationRefresher?.dispose();
17924
18134
  this.locationRefresher = null;
17925
18135
  await this.controller?.stop();
18136
+ await this.segmentHours?.flush();
17926
18137
  this.controller = null;
17927
18138
  this.segmentStore = null;
17928
18139
  if (this.exportSweepTimer !== null) {