@camstack/addon-pipeline 1.2.140 → 1.2.142

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.
@@ -847,6 +847,23 @@ function upperBoundByStart(rows, startMs) {
847
847
  }
848
848
  return lo;
849
849
  }
850
+ /**
851
+ * Index of the FIRST element whose `startMs` is at-or-after `startMs` (lower
852
+ * bound). On a start-sorted view this is exactly where a linear
853
+ * `find(s => s.startMs === x)` and a linear `find(s => s.startMs >= x)` would
854
+ * have stopped — which is what makes the binary-search replacements of those
855
+ * two scans row-for-row identical, duplicate starts included.
856
+ */
857
+ function lowerBoundByStart(rows, startMs) {
858
+ let lo = 0;
859
+ let hi = rows.length;
860
+ while (lo < hi) {
861
+ const mid = lo + hi >> 1;
862
+ if (rows[mid].startMs < startMs) lo = mid + 1;
863
+ else hi = mid;
864
+ }
865
+ return lo;
866
+ }
850
867
  var RecordingIndex = class {
851
868
  byDevice = /* @__PURE__ */ new Map();
852
869
  /** Start-sorted views. Built lazily on first read (one copy+sort), then kept
@@ -865,6 +882,27 @@ var RecordingIndex = class {
865
882
  * hydrate. Empty = nothing has been looked at, which is NOT the same as
866
883
  * "there is nothing" — see {@link hydrationOf}. */
867
884
  walked = /* @__PURE__ */ new Map();
885
+ /**
886
+ * Upper bound on the `durMs` of any row the index holds — the ONLY thing that
887
+ * turns "walk the whole sorted view" into "walk backwards a bounded number of
888
+ * rows".
889
+ *
890
+ * A start-sorted view answers "which row STARTS at/before X" by binary
891
+ * search, but every backward question here ("which row still COVERS X",
892
+ * "which footage ENDS at/before X") depends on `startMs + durMs`, which is
893
+ * not sorted. This scalar closes that: a row at `startMs` can never end later
894
+ * than `startMs + maxDurMs`, so once a candidate's ceiling can no longer beat
895
+ * the best answer found, no earlier row can either — and the walk stops.
896
+ *
897
+ * Monotonically non-decreasing on purpose. It is only ever an OVER-estimate
898
+ * (a longer walk, never a wrong answer); shrinking it on eviction would buy
899
+ * nothing and could only make it wrong.
900
+ */
901
+ maxDurMs = 0;
902
+ /** Widen {@link maxDurMs} for a row entering the index. */
903
+ noteDuration(durMs) {
904
+ if (durMs > this.maxDurMs) this.maxDurMs = durMs;
905
+ }
868
906
  mapFor(deviceId) {
869
907
  let m = this.byDevice.get(deviceId);
870
908
  if (!m) {
@@ -953,11 +991,13 @@ var RecordingIndex = class {
953
991
  const prev = held.get(path);
954
992
  if (prev !== void 0 && prev.locationId === locationId) {
955
993
  this.rowsReused += 1;
994
+ this.noteDuration(prev.durMs);
956
995
  return prev;
957
996
  }
958
997
  const p = parseSegmentPath(path);
959
998
  if (!p || p.deviceId !== deviceId) return null;
960
999
  this.rowsAllocated += 1;
1000
+ this.noteDuration(p.durMs);
961
1001
  return {
962
1002
  deviceId: p.deviceId,
963
1003
  profile: p.profile,
@@ -1055,6 +1095,7 @@ var RecordingIndex = class {
1055
1095
  */
1056
1096
  addSegment(s) {
1057
1097
  const m = this.mapFor(s.deviceId);
1098
+ this.noteDuration(s.durMs);
1058
1099
  const replacing = m.has(s.path);
1059
1100
  m.set(s.path, s);
1060
1101
  if (replacing) {
@@ -1092,6 +1133,55 @@ var RecordingIndex = class {
1092
1133
  while (end < segs.length && segs[end].startMs < toMs) end += 1;
1093
1134
  return lo === 0 && end === segs.length ? segs : segs.slice(lo, end);
1094
1135
  }
1136
+ /**
1137
+ * The row whose `startMs` is EXACTLY `startMs`, or null.
1138
+ *
1139
+ * The point lookup every byte read is: `readSegmentBytes` / `readGopBytes` /
1140
+ * `readWindowBytes` all address a segment by its exact start, which is what
1141
+ * the playback manifest and the locate answer handed the client. It used to
1142
+ * be `segments(deviceId, profile).find(...)` — a full COPY of the
1143
+ * device-profile archive plus a linear scan, per scrub tick and per playback
1144
+ * fetch (~10 k rows copied to reach one of them on the live hub).
1145
+ *
1146
+ * Returns the FIRST row with that start, which is what `.find` returned on
1147
+ * the same start-sorted view: a restart can leave two rows sharing a start,
1148
+ * and the byte reader must keep resolving to the same one it always did.
1149
+ */
1150
+ segmentStartingAt(deviceId, profile, startMs) {
1151
+ const segs = this.sortedSegments(deviceId, profile);
1152
+ const at = segs[lowerBoundByStart(segs, startMs)];
1153
+ return at !== void 0 && at.startMs === startMs ? at : null;
1154
+ }
1155
+ /**
1156
+ * Start-sorted rows whose SPAN `[startMs, startMs+durMs)` overlaps
1157
+ * `[fromMs, toMs)` — i.e. `startMs < toMs && startMs + durMs > fromMs`.
1158
+ *
1159
+ * The overlap-inclusive twin of {@link segmentsStartingIn}, and the reason it
1160
+ * cannot just slice from the `fromMs` lower bound: the segment an operator is
1161
+ * actually watching at `fromMs` STARTED before it. Dropping it truncates the
1162
+ * first segment of every playback window — silently, because the window still
1163
+ * renders, just short. The boundary convention is the caller's, preserved
1164
+ * from the whole-archive filter this replaces: a segment ending EXACTLY at
1165
+ * `fromMs` is excluded (`end > fromMs`), a segment starting exactly at `toMs`
1166
+ * is excluded (`start < toMs`), and a segment straddling `fromMs` is IN.
1167
+ *
1168
+ * The backward reach is {@link maxDurMs}, not "one element": with overlapping
1169
+ * rows (a restart re-recording an hour) more than one predecessor can still
1170
+ * cover `fromMs`, and the ceiling is the only bound that cannot miss one.
1171
+ */
1172
+ segmentsOverlapping(deviceId, profile, fromMs, toMs) {
1173
+ const segs = this.sortedSegments(deviceId, profile);
1174
+ const end = lowerBoundByStart(segs, toMs);
1175
+ let head = lowerBoundByStart(segs, fromMs);
1176
+ const reach = fromMs - this.maxDurMs;
1177
+ while (head > 0 && segs[head - 1].startMs > reach) head -= 1;
1178
+ const out = [];
1179
+ for (let i = head; i < end; i++) {
1180
+ const s = segs[i];
1181
+ if (s.startMs + s.durMs > fromMs) out.push(s);
1182
+ }
1183
+ return out;
1184
+ }
1095
1185
  /** True when `relPath` is currently indexed for `deviceId`. O(1). */
1096
1186
  hasSegment(deviceId, relPath) {
1097
1187
  return this.byDevice.get(deviceId)?.has(relPath) === true;
@@ -1210,6 +1300,26 @@ var RecordingIndex = class {
1210
1300
  }
1211
1301
  return [...out.values()];
1212
1302
  }
1303
+ /**
1304
+ * WHICH devices hold footage on any of `locationIds` — the census, without
1305
+ * the archive.
1306
+ *
1307
+ * `getStorageUsage` used to answer this with `segmentsOnLocation(id)` per
1308
+ * location: a full multi-device COPY of every row on the disk plus a sort of
1309
+ * it (294 k rows on the live hub), to read one number off each row and throw
1310
+ * the array away. This visits each device only until its first matching row.
1311
+ * Same membership rule as before — a device is listed when at least one of
1312
+ * its rows sits on one of the given locations, so footage stranded on a
1313
+ * location id that no longer resolves stays invisible here exactly as it was.
1314
+ */
1315
+ deviceIdsOnLocations(locationIds) {
1316
+ const out = /* @__PURE__ */ new Set();
1317
+ for (const [deviceId, m] of this.byDevice) for (const s of m.values()) if (locationIds.has(s.locationId)) {
1318
+ out.add(deviceId);
1319
+ break;
1320
+ }
1321
+ return out;
1322
+ }
1213
1323
  /** All segments on a storage location across every device, oldest-first. */
1214
1324
  segmentsOnLocation(locationId) {
1215
1325
  const out = [];
@@ -1246,20 +1356,59 @@ var RecordingIndex = class {
1246
1356
  const segs = this.sortedSegments(deviceId, profile);
1247
1357
  if (segs.length === 0) return null;
1248
1358
  if (this.segmentAtIn(segs, epochMs) !== null) return epochMs;
1249
- const next = segs.find((s) => s.startMs >= epochMs);
1359
+ const next = segs[lowerBoundByStart(segs, epochMs)];
1250
1360
  return next ? next.startMs : null;
1251
1361
  }
1252
1362
  /**
1363
+ * The end of the newest footage for a (device, profile), or null when there
1364
+ * is none — "how far can an export reach?", asked once per render.
1365
+ *
1366
+ * NOT simply the last row's end: the view is sorted by START, and an earlier,
1367
+ * longer segment can end later. Walks backwards from the newest row only
1368
+ * while an earlier start could still, at {@link maxDurMs}, beat the best end
1369
+ * seen — which on a uniform-length archive is one comparison.
1370
+ */
1371
+ latestEnd(deviceId, profile) {
1372
+ const segs = this.sortedSegments(deviceId, profile);
1373
+ let best = null;
1374
+ for (let i = segs.length - 1; i >= 0; i--) {
1375
+ const s = segs[i];
1376
+ if (best !== null && s.startMs + this.maxDurMs <= best) break;
1377
+ const end = s.startMs + s.durMs;
1378
+ if (best === null || end > best) best = end;
1379
+ }
1380
+ return best;
1381
+ }
1382
+ /**
1253
1383
  * The exclusive END of the nearest footage ending at-or-before `epochMs`
1254
1384
  * (the backward covered edge), or null when no footage ends at or before it.
1255
1385
  * Backward counterpart of `nearestCoveredEdge`: consecutive segments leave
1256
1386
  * small cracks (`startMs + durMs` < next `startMs` by ~11-17 ms), so a
1257
1387
  * backward frame-step probing `startMs − 1` lands in a crack — this edge
1258
1388
  * lets the caller hop to the true previous segment.
1389
+ *
1390
+ * ## Why this is a search and not a scan
1391
+ *
1392
+ * Called on EVERY locate that lands in a gap — during a drag across sparse
1393
+ * footage, every tick — and it used to walk the device-profile's whole sorted
1394
+ * view to do it. Two bounds replace the walk, and the second is the one that
1395
+ * has to be right:
1396
+ *
1397
+ * 1. A row starting AFTER `epochMs` ends after it too (`durMs >= 0`), so
1398
+ * nothing at-or-past the upper bound of `epochMs` can qualify.
1399
+ * 2. Below that bound the answer is NOT the predecessor. A row starting
1400
+ * before the cursor can still COVER it (disqualified: its end is past
1401
+ * `epochMs`), while an even earlier row ends cleanly before it and is the
1402
+ * true edge. So the walk continues backwards, and only {@link maxDurMs}
1403
+ * says when to stop: earlier rows start no later than this one, so none of
1404
+ * them can end past `startMs + maxDurMs`.
1259
1405
  */
1260
1406
  coveredEdgeBefore(deviceId, profile, epochMs) {
1407
+ const segs = this.sortedSegments(deviceId, profile);
1261
1408
  let best = null;
1262
- for (const s of this.sortedSegments(deviceId, profile)) {
1409
+ for (let i = upperBoundByStart(segs, epochMs) - 1; i >= 0; i--) {
1410
+ const s = segs[i];
1411
+ if (best !== null && s.startMs + this.maxDurMs <= best) break;
1263
1412
  const end = s.startMs + s.durMs;
1264
1413
  if (end <= epochMs && (best === null || end > best)) best = end;
1265
1414
  }
@@ -1901,6 +2050,11 @@ var macrotask$2 = () => new Promise((resolve) => {
1901
2050
  * segment's start. Returns the LAST segment whose half-open span
1902
2051
  * `[startMs, startMs+durMs)` contains the instant (segments can overlap after a
1903
2052
  * restart; the newest wins). Null when no segment covers the instant.
2053
+ *
2054
+ * `segments` may be a CANDIDATE WINDOW rather than the device's whole archive —
2055
+ * the caller narrows it with an index search. The membership test below stays
2056
+ * regardless: narrowing may only remove rows that could not have won, never
2057
+ * decide which of the survivors does.
1904
2058
  */
1905
2059
  function resolveStillSource(segments, epochMs) {
1906
2060
  let match = null;
@@ -1968,16 +2122,22 @@ var StillFrameService = class {
1968
2122
  * caller replies 204 → the dialog shows its placeholder).
1969
2123
  */
1970
2124
  async getStill(deviceId, epochMs) {
1971
- const source = resolveStillSource(this.deps.listLowSegments(deviceId), epochMs);
1972
- if (source === null) return null;
2125
+ const source = resolveStillSource(this.deps.lowSegmentsAt(deviceId, epochMs), epochMs);
2126
+ if (source === null) {
2127
+ this.deps.logger.debug("still: no low segment covers the instant", {
2128
+ tags: { deviceId },
2129
+ meta: { epochMs }
2130
+ });
2131
+ return null;
2132
+ }
1973
2133
  const abs = this.deps.resolveAbsPath(source.row);
1974
2134
  const now = (this.deps.now ?? Date.now)();
1975
2135
  const immutable = source.row.startMs + source.row.durMs < now;
1976
2136
  if (!await this.acquireSlot()) {
1977
- this.deps.logger.debug("still: decode queue saturated, dropping request", { meta: {
1978
- deviceId,
1979
- epochMs
1980
- } });
2137
+ this.deps.logger.debug("still: decode queue saturated, dropping request", {
2138
+ tags: { deviceId },
2139
+ meta: { epochMs }
2140
+ });
1981
2141
  return null;
1982
2142
  }
1983
2143
  try {
@@ -5077,7 +5237,7 @@ async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMo
5077
5237
  if (deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs);
5078
5238
  const hydration = deps.index.hydrationOf(deviceId, fromMs, toMs);
5079
5239
  const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
5080
- const candidates = deps.index.segments(deviceId, profile).filter((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs).toSorted((a, b) => a.startMs - b.startMs).flatMap((s) => {
5240
+ const candidates = deps.index.segmentsOverlapping(deviceId, profile, fromMs, toMs).flatMap((s) => {
5081
5241
  const root = rootById.get(s.locationId);
5082
5242
  if (root === void 0) return [];
5083
5243
  const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
@@ -5243,7 +5403,7 @@ async function evictVictims(deps, deviceId, victims) {
5243
5403
  * and GOP byte-range reads so the two paths cannot drift on resolution.
5244
5404
  */
5245
5405
  function resolveSegmentFile(deps, deviceId, profile, startMs) {
5246
- const seg = deps.index.segments(deviceId, profile).find((s) => s.startMs === startMs);
5406
+ const seg = deps.index.segmentStartingAt(deviceId, profile, startMs);
5247
5407
  if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
5248
5408
  const loc = deps.locations().find((l) => l.id === seg.locationId);
5249
5409
  if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
@@ -5412,8 +5572,7 @@ function buildRecordingProvider(deps) {
5412
5572
  getStorageUsage: async () => {
5413
5573
  const locations = deps.locations();
5414
5574
  const devices = [];
5415
- const seenDevices = /* @__PURE__ */ new Set();
5416
- for (const loc of locations) for (const row of deps.index.segmentsOnLocation(loc.id)) seenDevices.add(row.deviceId);
5575
+ const seenDevices = deps.index.deviceIdsOnLocations(new Set(locations.map((l) => l.id)));
5417
5576
  let totalUsedBytes = 0;
5418
5577
  for (const deviceId of seenDevices) {
5419
5578
  const accounting = deps.index.accounting(deviceId);
@@ -5917,15 +6076,14 @@ async function renderFootage(renderDeps, input, ext, buildArgs) {
5917
6076
  await promises.rm(outPath, { force: true }).catch(() => {});
5918
6077
  }
5919
6078
  }
5920
- /** The end of the newest FINALIZED `low` segment, or null when none exists. */
6079
+ /**
6080
+ * The end of the newest FINALIZED `low` segment, or null when none exists.
6081
+ *
6082
+ * One scalar off the tail of the live sorted view — it used to COPY the
6083
+ * device's whole `low` archive to fold a maximum out of it, per render.
6084
+ */
5921
6085
  function latestFinalizedEnd(renderDeps, deviceId) {
5922
- const rows = renderDeps.deps.index.segments(deviceId, "low");
5923
- let end = null;
5924
- for (const s of rows) {
5925
- const candidate = s.startMs + s.durMs;
5926
- if (end === null || candidate > end) end = candidate;
5927
- }
5928
- return end;
6086
+ return renderDeps.deps.index.latestEnd(deviceId, "low");
5929
6087
  }
5930
6088
  /**
5931
6089
  * Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
@@ -11232,10 +11390,12 @@ var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
11232
11390
  /**
11233
11391
  * The four aggregate columns exist so "how much footage is on this disk" is a
11234
11392
  * `SUM`/`MIN`/`MAX` over ~19 k hour rows instead of a walk over ~7.1 M in-RAM
11235
- * segment rows. They are DERIVED from `paths` — never a second authority — and
11236
- * {@link hourAggregate} is the only place that derives them, so a row loaded
11237
- * from a database that predates these columns is corrected in the mirror the
11238
- * moment it is read and repaired on disk by {@link SegmentHourLedger.repairAggregates}.
11393
+ * segment rows. They are DERIVED from `paths` — never a second authority — by
11394
+ * {@link withSegmentPath} and nothing else: {@link hourAggregate} is that step
11395
+ * over every path and {@link appendSegmentPath} is that step over one, so there
11396
+ * is no second derivation to drift. A row loaded from a database that predates
11397
+ * these columns is corrected in the mirror the moment it is read and repaired
11398
+ * on disk by {@link SegmentHourLedger.repairAggregates}.
11239
11399
  */
11240
11400
  var RECORDING_SEGMENT_HOURS_COLUMNS = [
11241
11401
  {
@@ -11312,33 +11472,57 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
11312
11472
  columns: ["locationId", "hourStartMs"]
11313
11473
  })
11314
11474
  ];
11475
+ /** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
11476
+ var EMPTY_HOUR_AGGREGATE = {
11477
+ bytes: 0,
11478
+ segments: 0,
11479
+ minStartMs: 0,
11480
+ maxStartMs: 0
11481
+ };
11315
11482
  /**
11316
- * Bytes / count / oldest / newest for one hour's paths.
11483
+ * ONE path folded into an aggregate the whole derivation, expressed as its
11484
+ * single step.
11485
+ *
11486
+ * {@link hourAggregate} is this step over every path, and {@link
11487
+ * SegmentHourLedger.recordSegment} is this step over the one path that just
11488
+ * arrived. That is not two implementations that happen to agree: the full
11489
+ * derivation is *defined* as this function repeated, so an append is the fold
11490
+ * resumed from the state the held row already carries — a `SegmentHourRow`'s
11491
+ * aggregate half IS the fold state over its `paths`.
11492
+ *
11493
+ * Why it had to become a step: `recordSegment` re-derived the whole hour on
11494
+ * every finalize. Σ1..360 ≈ 65 k path parses per camera-profile-hour at 10 s
11495
+ * segments, Σ1..1708 ≈ 1.46 M in the restart-storm hours of 2026-08-29, at
11496
+ * ~3.4 finalizes/s fleet-wide, forever.
11497
+ *
11498
+ * A path the parser refuses contributes nothing — it is not a segment, and
11499
+ * counting it would inflate a disk report. `segments === 0` is the first-path
11500
+ * sentinel for min/max, so an aggregate with no segments carries 0/0 rather
11501
+ * than a sentinel nobody can compare against.
11502
+ */
11503
+ function withSegmentPath(agg, path) {
11504
+ const p = parseSegmentPath(path);
11505
+ if (p === null) return agg;
11506
+ return {
11507
+ bytes: agg.bytes + p.bytes,
11508
+ segments: agg.segments + 1,
11509
+ minStartMs: agg.segments === 0 || p.startMs < agg.minStartMs ? p.startMs : agg.minStartMs,
11510
+ maxStartMs: agg.segments === 0 || p.startMs > agg.maxStartMs ? p.startMs : agg.maxStartMs
11511
+ };
11512
+ }
11513
+ /**
11514
+ * Bytes / count / oldest / newest for one hour's paths — the sole FULL
11515
+ * derivation, used by rebuilds ({@link recordToRow}, `dropSegments`) and never
11516
+ * by the append path.
11317
11517
  *
11318
11518
  * Every field of a segment path encodes its own facts (`parseSegmentPath`), so
11319
11519
  * this is a derivation, not a measurement: it can never disagree with the paths
11320
- * it was computed from. A path the parser refuses contributes nothing — it is
11321
- * not a segment, and counting it would inflate a disk report.
11520
+ * it was computed from.
11322
11521
  */
11323
11522
  function hourAggregate(paths) {
11324
- let bytes = 0;
11325
- let segments = 0;
11326
- let minStartMs = 0;
11327
- let maxStartMs = 0;
11328
- for (const path of paths) {
11329
- const p = parseSegmentPath(path);
11330
- if (p === null) continue;
11331
- bytes += p.bytes;
11332
- if (segments === 0 || p.startMs < minStartMs) minStartMs = p.startMs;
11333
- if (segments === 0 || p.startMs > maxStartMs) maxStartMs = p.startMs;
11334
- segments += 1;
11335
- }
11336
- return {
11337
- bytes,
11338
- segments,
11339
- minStartMs,
11340
- maxStartMs
11341
- };
11523
+ let agg = EMPTY_HOUR_AGGREGATE;
11524
+ for (const path of paths) agg = withSegmentPath(agg, path);
11525
+ return agg;
11342
11526
  }
11343
11527
  /** Build the persisted row for an hour from its paths — the ONE constructor. */
11344
11528
  function segmentHourRow(base) {
@@ -11348,6 +11532,33 @@ function segmentHourRow(base) {
11348
11532
  };
11349
11533
  }
11350
11534
  /**
11535
+ * The row an hour becomes when ONE more path joins it.
11536
+ *
11537
+ * Identical to `segmentHourRow({ ...held, paths: [...held.paths, path] })` and
11538
+ * strictly cheaper: the aggregate half of `held` is already the fold over
11539
+ * `held.paths`, so this resumes that fold instead of restarting it. The
11540
+ * equality is not an assumption — `segment-hour-append.spec.ts` pins it on
11541
+ * generated hours including out-of-order arrival, duplicate paths, unparseable
11542
+ * paths and storm-sized hours.
11543
+ *
11544
+ * The four fields are named rather than spread on purpose. {@link
11545
+ * withSegmentPath} hands its argument straight back for a path the parser
11546
+ * refuses, and `held` is a whole row — spreading that return would put the
11547
+ * PREVIOUS `paths` array back over the appended one. Which is exactly what it
11548
+ * did until the generated-hours test caught it.
11549
+ */
11550
+ function appendSegmentPath(held, path) {
11551
+ const { bytes, segments, minStartMs, maxStartMs } = withSegmentPath(held, path);
11552
+ return {
11553
+ ...held,
11554
+ paths: [...held.paths, path],
11555
+ bytes,
11556
+ segments,
11557
+ minStartMs,
11558
+ maxStartMs
11559
+ };
11560
+ }
11561
+ /**
11351
11562
  * The largest share of a device's held hours a single walk may delete.
11352
11563
  *
11353
11564
  * On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
@@ -11680,19 +11891,31 @@ var SegmentHourLedger = class {
11680
11891
  } });
11681
11892
  return repaired;
11682
11893
  }
11683
- /** Write-behind: a just-finalized segment joins its hour row. */
11894
+ /**
11895
+ * Write-behind: a just-finalized segment joins its hour row.
11896
+ *
11897
+ * THE hot path — every profile-writer lands here every `segmentSeconds`,
11898
+ * ~3.4 times a second fleet-wide. The append is {@link appendSegmentPath}:
11899
+ * one `parseSegmentPath` and four scalar updates, whatever the hour already
11900
+ * holds. It used to re-derive the whole hour, which made one hour
11901
+ * O(N²) — 65 k parses at 360 segments, 1.46 M in a restart-storm hour.
11902
+ */
11684
11903
  async recordSegment(row) {
11685
11904
  const { key, hourStartMs: hour } = hourOf(row);
11686
11905
  const held = this.ledger.get(key);
11687
- if (held !== void 0 && held.paths.includes(row.path)) return;
11688
- await this.ledger.put(segmentHourRow({
11689
- key,
11690
- deviceId: row.deviceId,
11691
- profile: row.profile,
11692
- locationId: row.locationId,
11693
- hourStartMs: hour,
11694
- paths: held === void 0 ? [row.path] : [...held.paths, row.path]
11695
- }));
11906
+ if (held === void 0) {
11907
+ await this.ledger.put(segmentHourRow({
11908
+ key,
11909
+ deviceId: row.deviceId,
11910
+ profile: row.profile,
11911
+ locationId: row.locationId,
11912
+ hourStartMs: hour,
11913
+ paths: [row.path]
11914
+ }));
11915
+ return;
11916
+ }
11917
+ if (held.paths.includes(row.path)) return;
11918
+ await this.ledger.put(appendSegmentPath(held, row.path));
11696
11919
  }
11697
11920
  /**
11698
11921
  * Eviction confirmed these paths are gone from disk. Drop them; forget the
@@ -11756,10 +11979,7 @@ var SegmentHourLedger = class {
11756
11979
  hourStartMs: hour,
11757
11980
  paths: [seg.path]
11758
11981
  }));
11759
- else fromIndex.set(key, segmentHourRow({
11760
- ...existing,
11761
- paths: [...existing.paths, seg.path]
11762
- }));
11982
+ else fromIndex.set(key, appendSegmentPath(existing, seg.path));
11763
11983
  }
11764
11984
  const refused = this.refusedPrunes(walked, fromIndex);
11765
11985
  for (const row of this.ledger.snapshot()) {
@@ -12989,7 +13209,7 @@ var RecorderV2Addon = class extends BaseAddon {
12989
13209
  }
12990
13210
  try {
12991
13211
  const stillService = new StillFrameService({
12992
- listLowSegments: (deviceId) => this.index.segments(deviceId, "low"),
13212
+ lowSegmentsAt: (deviceId, epochMs) => this.index.segmentsOverlapping(deviceId, "low", epochMs, epochMs + 1),
12993
13213
  resolveAbsPath: (row) => this.resolveSegmentAbsPath(row),
12994
13214
  logger: this.ctx.logger.child("Still")
12995
13215
  });