@camstack/addon-post-analysis 1.2.152 → 1.2.153
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/{dist-DSg_jDMt.mjs → dist-DDIu0Q1W.mjs} +86 -1
- package/dist/{dist-Bw6cidiJ.js → dist-DeMgxNFC.js} +91 -0
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DTO1HvN7.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cil5ur25.mjs} +3 -3
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DxVeMmnD.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CG8y8a6j.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-r4RyRXk7.mjs → hostInit-I37PqSoI.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +370 -54
- package/dist/pipeline-analytics/index.mjs +370 -54
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-DeMgxNFC.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -25694,6 +25694,67 @@ var MEDIA_INDEXES = [
|
|
|
25694
25694
|
{
|
|
25695
25695
|
name: "idx_media_location",
|
|
25696
25696
|
columns: ["locationId"]
|
|
25697
|
+
}),
|
|
25698
|
+
(
|
|
25699
|
+
/**
|
|
25700
|
+
* The events-management footprint — `GROUP BY deviceId` and
|
|
25701
|
+
* `SUM(sizeBytes) WHERE deviceId = ?`. See {@link MediaStore.footprintByDevice}.
|
|
25702
|
+
*
|
|
25703
|
+
* **Why not `idx_media_device_ts`.** That index already leads with
|
|
25704
|
+
* `deviceId`, so it can find the range — but it does not carry `sizeBytes`,
|
|
25705
|
+
* so the sum has to fetch the table row behind every index entry it visits.
|
|
25706
|
+
* Over the whole collection that is one scattered read of the entire ~1.25 M
|
|
25707
|
+
* row table (`path` alone is ~80 characters per row) on the volume the
|
|
25708
|
+
* recorder is writing to. With `sizeBytes` in the index the planner never
|
|
25709
|
+
* touches the table: `SCAN … USING COVERING INDEX idx_media_device_size`.
|
|
25710
|
+
*
|
|
25711
|
+
* It has to be a NEW index rather than a third column on the existing one.
|
|
25712
|
+
* `declareCollection` emits `CREATE INDEX IF NOT EXISTS` keyed on the index
|
|
25713
|
+
* NAME, so widening `idx_media_device_ts` in place would be a silent no-op on
|
|
25714
|
+
* every hub that already has it — the declaration would read as done and the
|
|
25715
|
+
* query would still hit the table.
|
|
25716
|
+
*
|
|
25717
|
+
* **What it costs.** ~19 bytes of index per row (two integers plus the
|
|
25718
|
+
* rowid), so ~24 MB at 1.25 M rows, plus one more b-tree maintained by every
|
|
25719
|
+
* insert into a table taking ~92 k rows/day. At ~1 insert/s that maintenance
|
|
25720
|
+
* is noise beside the JPEG write the same call performs. The cost that is not
|
|
25721
|
+
* noise is the one-off: the first `declareCollection` after this ships builds
|
|
25722
|
+
* the index over every existing row. Do not deploy it beside a running drain
|
|
25723
|
+
* or seal — the same caveat `idx_media_location` carries, for the same
|
|
25724
|
+
* reason.
|
|
25725
|
+
*/
|
|
25726
|
+
{
|
|
25727
|
+
name: "idx_media_device_size",
|
|
25728
|
+
columns: ["deviceId", "sizeBytes"]
|
|
25729
|
+
}),
|
|
25730
|
+
(
|
|
25731
|
+
/**
|
|
25732
|
+
* The per-KIND footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE kind = ?`, and
|
|
25733
|
+
* the same scoped to one camera. See {@link MediaStore.footprintByKind}.
|
|
25734
|
+
*
|
|
25735
|
+
* `kind` leads no existing index, so without this each of the thirteen
|
|
25736
|
+
* per-kind questions is a full table scan and the breakdown costs thirteen of
|
|
25737
|
+
* them. With it, each kind is a contiguous covering range and the thirteen
|
|
25738
|
+
* together scan the index once — the cost of the whole breakdown is one pass
|
|
25739
|
+
* over ~36 MB of index instead of thirteen passes over the table.
|
|
25740
|
+
*
|
|
25741
|
+
* `deviceId` sits between `kind` and `sizeBytes` so the per-camera variant
|
|
25742
|
+
* (`WHERE kind = ? AND deviceId = ?`) stays covering too, for ~3 bytes a row
|
|
25743
|
+
* more than `(kind, sizeBytes)` would cost. Without it that query finds its
|
|
25744
|
+
* range from the index and then reads the table for every row in it.
|
|
25745
|
+
*
|
|
25746
|
+
* ~29 bytes of index per row, so ~36 MB at 1.25 M rows, and one more b-tree
|
|
25747
|
+
* on every insert. Same one-off caveat as the two indexes above: the first
|
|
25748
|
+
* declare builds it over every existing row, so do not ship it beside a
|
|
25749
|
+
* running drain or seal.
|
|
25750
|
+
*/
|
|
25751
|
+
{
|
|
25752
|
+
name: "idx_media_kind_device_size",
|
|
25753
|
+
columns: [
|
|
25754
|
+
"kind",
|
|
25755
|
+
"deviceId",
|
|
25756
|
+
"sizeBytes"
|
|
25757
|
+
]
|
|
25697
25758
|
})
|
|
25698
25759
|
];
|
|
25699
25760
|
/** The storage location of a media row/record: its stamped `locationId`, or
|
|
@@ -26348,47 +26409,136 @@ var MediaStore = class {
|
|
|
26348
26409
|
};
|
|
26349
26410
|
}
|
|
26350
26411
|
/**
|
|
26351
|
-
* On-demand footprint aggregation for the events-management UI:
|
|
26352
|
-
* `sizeBytes`
|
|
26353
|
-
*
|
|
26354
|
-
*
|
|
26355
|
-
*
|
|
26412
|
+
* On-demand footprint aggregation for the events-management UI: rows and
|
|
26413
|
+
* summed `sizeBytes` per camera, across the whole media collection.
|
|
26414
|
+
*
|
|
26415
|
+
* ## Why this is a histogram plus one aggregate per camera
|
|
26416
|
+
*
|
|
26417
|
+
* It used to page the ENTIRE collection — `ORDER BY id LIMIT 1000 OFFSET n`,
|
|
26418
|
+
* no predicate — and add `sizeBytes` up in JS. SQLite honours an OFFSET by
|
|
26419
|
+
* stepping over `n` entries, so the walk is quadratic in the table: at the
|
|
26420
|
+
* live hub's 1.25 M rows that is ~1 250 round trips and ~780 M index steps,
|
|
26421
|
+
* with every row serialised across an RPC boundary and rebuilt in V8 to be
|
|
26422
|
+
* added to a number. It timed out at 60 s on every call it ever made, and it
|
|
26423
|
+
* is the only thing that can size the surplus-media deletion it gates.
|
|
26424
|
+
*
|
|
26425
|
+
* The answer is at most a few dozen `(deviceId, rows, bytes)` triples.
|
|
26426
|
+
*
|
|
26427
|
+
* - The DEVICE SET comes from a histogram on `deviceId` with `bucketSize: 1`
|
|
26428
|
+
* — `GROUP BY deviceId`, served from `idx_media_device_size` as a covering
|
|
26429
|
+
* scan, the same trick `TrackStore.listDeviceIds` already uses (D56).
|
|
26430
|
+
* `bucket` IS the deviceId: the column is INTEGER and the bucket
|
|
26431
|
+
* expression is `CAST((deviceId - 0) / 1 AS INTEGER)`.
|
|
26432
|
+
* - The NUMBERS come from one `aggregate` per camera — `COUNT(*)` and
|
|
26433
|
+
* `SUM(sizeBytes)` in a single statement over an indexed `deviceId` range.
|
|
26434
|
+
*
|
|
26435
|
+
* Both figures for a camera come from the SAME statement deliberately. Taking
|
|
26436
|
+
* `rows` from the histogram and `bytes` from the aggregate would let a write
|
|
26437
|
+
* landing between the two produce a footprint whose row count and byte count
|
|
26438
|
+
* describe different populations — a disagreement an operator sizing a
|
|
26439
|
+
* deletion has no way to detect.
|
|
26440
|
+
*
|
|
26441
|
+
* A read that fails THROWS. It must never degrade to a zero: "this camera
|
|
26442
|
+
* holds nothing" is precisely the claim a failed measurement must not be
|
|
26443
|
+
* allowed to make to something about to delete (D295, D312).
|
|
26356
26444
|
*/
|
|
26357
26445
|
async footprintByDevice() {
|
|
26358
|
-
const
|
|
26446
|
+
const buckets = await this.store.histogram.query({
|
|
26447
|
+
collection: MEDIA_COLLECTION,
|
|
26448
|
+
field: "deviceId",
|
|
26449
|
+
bucketSize: 1,
|
|
26450
|
+
origin: 0
|
|
26451
|
+
});
|
|
26359
26452
|
const out = /* @__PURE__ */ new Map();
|
|
26360
|
-
|
|
26361
|
-
|
|
26362
|
-
const
|
|
26453
|
+
for (const bucket of buckets) {
|
|
26454
|
+
if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
|
|
26455
|
+
const deviceId = bucket.bucket;
|
|
26456
|
+
const agg = await this.store.aggregate.query({
|
|
26363
26457
|
collection: MEDIA_COLLECTION,
|
|
26364
|
-
|
|
26365
|
-
|
|
26366
|
-
|
|
26367
|
-
|
|
26368
|
-
|
|
26369
|
-
|
|
26370
|
-
|
|
26371
|
-
|
|
26458
|
+
fields: [{
|
|
26459
|
+
as: "bytes",
|
|
26460
|
+
field: "sizeBytes",
|
|
26461
|
+
op: "sum"
|
|
26462
|
+
}],
|
|
26463
|
+
filter: { where: { deviceId } }
|
|
26464
|
+
});
|
|
26465
|
+
if (agg.count === 0) continue;
|
|
26466
|
+
const bytes = agg.values["bytes"];
|
|
26467
|
+
out.set(deviceId, {
|
|
26468
|
+
bytes: bytes ?? 0,
|
|
26469
|
+
rows: agg.count
|
|
26372
26470
|
});
|
|
26373
|
-
if (rows.length === 0) break;
|
|
26374
|
-
for (const row of rows) {
|
|
26375
|
-
const data = row.data;
|
|
26376
|
-
const deviceId = Number(data["deviceId"]);
|
|
26377
|
-
const sizeBytes = Number(data["sizeBytes"]);
|
|
26378
|
-
if (!Number.isFinite(deviceId)) continue;
|
|
26379
|
-
const acc = out.get(deviceId) ?? {
|
|
26380
|
-
bytes: 0,
|
|
26381
|
-
rows: 0
|
|
26382
|
-
};
|
|
26383
|
-
acc.bytes += Number.isFinite(sizeBytes) ? sizeBytes : 0;
|
|
26384
|
-
acc.rows += 1;
|
|
26385
|
-
out.set(deviceId, acc);
|
|
26386
|
-
}
|
|
26387
|
-
if (rows.length < PAGE) break;
|
|
26388
|
-
offset += PAGE;
|
|
26389
26471
|
}
|
|
26390
26472
|
return out;
|
|
26391
26473
|
}
|
|
26474
|
+
/**
|
|
26475
|
+
* The media footprint broken down by KIND — fleet-wide, or for one camera.
|
|
26476
|
+
*
|
|
26477
|
+
* `footprintByDevice` answers "how much is where"; this answers "how much is
|
|
26478
|
+
* WHAT", which is the axis a deletion decision turns on. The deletable set
|
|
26479
|
+
* (the periodic `snapshot` filmstrip, the surplus per-edge motion stills) and
|
|
26480
|
+
* the keep set (`firstFrame`, rolling `lastFrame`, `thumbnail` /
|
|
26481
|
+
* `thumbnailSmall`, `keyFrame` / `keyFrameSmall`, the face and plate buffers,
|
|
26482
|
+
* gallery media, the CLIP `crop`) are told apart by `kind` and by nothing
|
|
26483
|
+
* else. A byte total cannot size a deletion; this can.
|
|
26484
|
+
*
|
|
26485
|
+
* One `aggregate` per kind, each an indexed covering range on
|
|
26486
|
+
* `idx_media_kind_device_size`, plus ONE unfiltered aggregate for the totals.
|
|
26487
|
+
*
|
|
26488
|
+
* That last statement is the point of the design and not a spare round trip.
|
|
26489
|
+
* `MediaFileKindEnum.options` is the closed set the writers use, but the
|
|
26490
|
+
* TABLE is not closed: a row written by a retired path, or by a version that
|
|
26491
|
+
* knew a kind this one does not, matches no per-kind query. Adding the kinds
|
|
26492
|
+
* up to produce the total would make such a row invisible and the denominator
|
|
26493
|
+
* quietly too small — the failure mode that matters most here, because the
|
|
26494
|
+
* caller is about to delete against it. So the total is measured
|
|
26495
|
+
* independently and the difference is REPORTED as `unaccounted*` rather than
|
|
26496
|
+
* being absorbed.
|
|
26497
|
+
*/
|
|
26498
|
+
async footprintByKind(deviceId) {
|
|
26499
|
+
const scope = deviceId === void 0 ? void 0 : { where: { deviceId } };
|
|
26500
|
+
const fields = [{
|
|
26501
|
+
as: "bytes",
|
|
26502
|
+
field: "sizeBytes",
|
|
26503
|
+
op: "sum"
|
|
26504
|
+
}];
|
|
26505
|
+
const totals = await this.store.aggregate.query({
|
|
26506
|
+
collection: MEDIA_COLLECTION,
|
|
26507
|
+
fields: [...fields],
|
|
26508
|
+
...scope === void 0 ? {} : { filter: scope }
|
|
26509
|
+
});
|
|
26510
|
+
const kinds = [];
|
|
26511
|
+
let countedRows = 0;
|
|
26512
|
+
let countedBytes = 0;
|
|
26513
|
+
for (const kind of require_dist.MediaFileKindEnum.options) {
|
|
26514
|
+
const agg = await this.store.aggregate.query({
|
|
26515
|
+
collection: MEDIA_COLLECTION,
|
|
26516
|
+
fields: [...fields],
|
|
26517
|
+
filter: { where: {
|
|
26518
|
+
kind,
|
|
26519
|
+
...deviceId === void 0 ? {} : { deviceId }
|
|
26520
|
+
} }
|
|
26521
|
+
});
|
|
26522
|
+
if (agg.count === 0) continue;
|
|
26523
|
+
const bytes = agg.values["bytes"] ?? 0;
|
|
26524
|
+
countedRows += agg.count;
|
|
26525
|
+
countedBytes += bytes;
|
|
26526
|
+
kinds.push({
|
|
26527
|
+
kind,
|
|
26528
|
+
rows: agg.count,
|
|
26529
|
+
bytes
|
|
26530
|
+
});
|
|
26531
|
+
}
|
|
26532
|
+
kinds.sort((a, b) => b.bytes - a.bytes);
|
|
26533
|
+
const totalBytes = totals.values["bytes"] ?? 0;
|
|
26534
|
+
return {
|
|
26535
|
+
totalRows: totals.count,
|
|
26536
|
+
totalBytes,
|
|
26537
|
+
kinds,
|
|
26538
|
+
unaccountedRows: totals.count - countedRows,
|
|
26539
|
+
unaccountedBytes: totalBytes - countedBytes
|
|
26540
|
+
};
|
|
26541
|
+
}
|
|
26392
26542
|
/** Move a single media entry from one owner to another.
|
|
26393
26543
|
* The new copy is written before the old one is removed.
|
|
26394
26544
|
* The old-entry deletion is best-effort: on failure a warning is logged
|
|
@@ -37042,10 +37192,34 @@ var AnalyticsQueryFacade = class {
|
|
|
37042
37192
|
}
|
|
37043
37193
|
/**
|
|
37044
37194
|
* Durable event-store footprint for the events-management UI: event ROWS
|
|
37045
|
-
* (motion + object + audio)
|
|
37046
|
-
*
|
|
37047
|
-
*
|
|
37048
|
-
*
|
|
37195
|
+
* (motion + object + audio) per camera + total, and event-owned media BYTES
|
|
37196
|
+
* on disk per camera + total. Cameras with neither rows nor bytes are
|
|
37197
|
+
* omitted; the rest come back ordered by bytes, descending, which is the
|
|
37198
|
+
* order the operator's table renders.
|
|
37199
|
+
*
|
|
37200
|
+
* ## The device set is a union of THREE sources
|
|
37201
|
+
*
|
|
37202
|
+
* It used to be `media ∪ tracks`, and a camera holding events but no media
|
|
37203
|
+
* row and no persisted track was therefore invisible — not listed, and its
|
|
37204
|
+
* events missing from `totalRows`. That combination is not exotic: media
|
|
37205
|
+
* retention, a media deletion and `pruneTracksBefore` all run on clocks of
|
|
37206
|
+
* their own, and every one of them can outlive the event rows. The number
|
|
37207
|
+
* this method exists to produce is the denominator for a deletion, so an
|
|
37208
|
+
* undercount is the one failure mode that matters, and it was silent.
|
|
37209
|
+
*
|
|
37210
|
+
* Events now contribute their own device set, so a camera is present if ANY
|
|
37211
|
+
* of the three sources has ever heard of it.
|
|
37212
|
+
*
|
|
37213
|
+
* ## The cost is the answer, not the table
|
|
37214
|
+
*
|
|
37215
|
+
* Three grouped reads for the event rows (one per event collection, never
|
|
37216
|
+
* three per camera), one grouped read plus one aggregate per camera for the
|
|
37217
|
+
* media bytes, one for the track device set. The retired shape paid ~1 250
|
|
37218
|
+
* round trips for the media half alone and timed out at 60 s on every call.
|
|
37219
|
+
* See {@link MediaStore.footprintByDevice} and {@link EventStore.countByDevice}.
|
|
37220
|
+
*
|
|
37221
|
+
* A store that cannot answer THROWS rather than returning zeroes — see the
|
|
37222
|
+
* same note on both.
|
|
37049
37223
|
*/
|
|
37050
37224
|
async getEventStoreFootprint() {
|
|
37051
37225
|
const eventStore = this.deps.eventStore();
|
|
@@ -37055,15 +37229,16 @@ var AnalyticsQueryFacade = class {
|
|
|
37055
37229
|
totalBytes: 0,
|
|
37056
37230
|
devices: []
|
|
37057
37231
|
};
|
|
37058
|
-
const mediaByDevice = await mediaStore.footprintByDevice();
|
|
37232
|
+
const [mediaByDevice, eventRowsByDevice] = await Promise.all([mediaStore.footprintByDevice(), eventStore.countByDevice()]);
|
|
37059
37233
|
const deviceIds = new Set(mediaByDevice.keys());
|
|
37234
|
+
for (const id of eventRowsByDevice.keys()) deviceIds.add(id);
|
|
37060
37235
|
const trackStore = this.deps.trackStore();
|
|
37061
37236
|
if (trackStore) for (const id of await trackStore.listDeviceIds()) deviceIds.add(id);
|
|
37062
37237
|
const devices = [];
|
|
37063
37238
|
let totalRows = 0;
|
|
37064
37239
|
let totalBytes = 0;
|
|
37065
37240
|
for (const deviceId of deviceIds) {
|
|
37066
|
-
const rows =
|
|
37241
|
+
const rows = eventRowsByDevice.get(deviceId) ?? 0;
|
|
37067
37242
|
const bytes = mediaByDevice.get(deviceId)?.bytes ?? 0;
|
|
37068
37243
|
if (rows === 0 && bytes === 0) continue;
|
|
37069
37244
|
totalRows += rows;
|
|
@@ -37082,6 +37257,26 @@ var AnalyticsQueryFacade = class {
|
|
|
37082
37257
|
};
|
|
37083
37258
|
}
|
|
37084
37259
|
/**
|
|
37260
|
+
* The media footprint by KIND — the axis that says what is safe to delete.
|
|
37261
|
+
* See {@link MediaStore.footprintByKind}.
|
|
37262
|
+
*
|
|
37263
|
+
* An absent media store is an empty breakdown, not a refusal: it is the same
|
|
37264
|
+
* "the addon has not finished booting" branch every other method here takes,
|
|
37265
|
+
* and the zeroes are accompanied by an empty `kinds` list, which no reader
|
|
37266
|
+
* can mistake for a measured "there is nothing".
|
|
37267
|
+
*/
|
|
37268
|
+
async getEventMediaFootprintByKind(input) {
|
|
37269
|
+
const mediaStore = this.deps.mediaStore();
|
|
37270
|
+
if (!mediaStore) return {
|
|
37271
|
+
totalRows: 0,
|
|
37272
|
+
totalBytes: 0,
|
|
37273
|
+
kinds: [],
|
|
37274
|
+
unaccountedRows: 0,
|
|
37275
|
+
unaccountedBytes: 0
|
|
37276
|
+
};
|
|
37277
|
+
return mediaStore.footprintByKind(input.deviceId);
|
|
37278
|
+
}
|
|
37279
|
+
/**
|
|
37085
37280
|
* Cluster-wide prune of events with `timestamp < olderThanMs` across every
|
|
37086
37281
|
* camera (via the device-agnostic `evictBefore`), deleting each pruned event's
|
|
37087
37282
|
* media in lockstep. Logs one global (`deviceId:null`) events ops-log row.
|
|
@@ -44750,6 +44945,15 @@ function snapshot(j) {
|
|
|
44750
44945
|
*/
|
|
44751
44946
|
var LOCATION_ID_FIELD = "locationId";
|
|
44752
44947
|
/**
|
|
44948
|
+
* Unstamped rows claimed per bulk seal statement.
|
|
44949
|
+
*
|
|
44950
|
+
* Bounds two things at once: the number of bound parameters in the `IN` list,
|
|
44951
|
+
* and how long one `UPDATE` holds the write lock on the database every process
|
|
44952
|
+
* in the cluster reads configuration through. Deliberately well under SQLite's
|
|
44953
|
+
* variable ceiling, and small enough that a cancel lands within one statement.
|
|
44954
|
+
*/
|
|
44955
|
+
var SEAL_BATCH_SIZE = 500;
|
|
44956
|
+
/**
|
|
44753
44957
|
* True when the row names NO location — the population `seal` exists for.
|
|
44754
44958
|
*
|
|
44755
44959
|
* Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
|
|
@@ -44981,6 +45185,17 @@ var MediaRelocateEngine = class {
|
|
|
44981
45185
|
}
|
|
44982
45186
|
async countSelected(mode, toLocationId, stop) {
|
|
44983
45187
|
try {
|
|
45188
|
+
if (mode === "seal") {
|
|
45189
|
+
let sealable = 0;
|
|
45190
|
+
for (const collection of collectionsFor(mode)) {
|
|
45191
|
+
if (stop()) return null;
|
|
45192
|
+
sealable += await this.deps.store.count.query({
|
|
45193
|
+
collection: collection.name,
|
|
45194
|
+
filter: { where: { [LOCATION_ID_FIELD]: null } }
|
|
45195
|
+
});
|
|
45196
|
+
}
|
|
45197
|
+
return { rows: sealable };
|
|
45198
|
+
}
|
|
44984
45199
|
let rows = 0;
|
|
44985
45200
|
for (const collection of collectionsFor(mode)) {
|
|
44986
45201
|
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
@@ -45012,6 +45227,14 @@ var MediaRelocateEngine = class {
|
|
|
45012
45227
|
* gets believed. */
|
|
45013
45228
|
let pathlessRows = 0;
|
|
45014
45229
|
job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
|
|
45230
|
+
if (job.mode === "seal") {
|
|
45231
|
+
for (const collection of collectionsFor(job.mode)) {
|
|
45232
|
+
if (job.cancelRequested) break;
|
|
45233
|
+
await this.sealCollection(collection.name, job);
|
|
45234
|
+
}
|
|
45235
|
+
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
45236
|
+
return;
|
|
45237
|
+
}
|
|
45015
45238
|
for (const collection of collectionsFor(job.mode)) {
|
|
45016
45239
|
if (job.cancelRequested) break;
|
|
45017
45240
|
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
@@ -45019,20 +45242,6 @@ var MediaRelocateEngine = class {
|
|
|
45019
45242
|
if (job.cancelRequested) break;
|
|
45020
45243
|
const data = row.data;
|
|
45021
45244
|
if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
|
|
45022
|
-
if (job.mode === "seal") {
|
|
45023
|
-
try {
|
|
45024
|
-
await this.stamp(collection.name, row.id, data, job.toLocationId);
|
|
45025
|
-
job.filesMoved++;
|
|
45026
|
-
} catch (err) {
|
|
45027
|
-
failedRows++;
|
|
45028
|
-
this.deps.logger.debug("event-media seal row failed", { meta: {
|
|
45029
|
-
collection: collection.name,
|
|
45030
|
-
key: row.id,
|
|
45031
|
-
error: String(err)
|
|
45032
|
-
} });
|
|
45033
|
-
}
|
|
45034
|
-
continue;
|
|
45035
|
-
}
|
|
45036
45245
|
const from = mediaRowLocation(data);
|
|
45037
45246
|
const relativePath = String(data["path"] ?? "");
|
|
45038
45247
|
if (relativePath.length === 0) {
|
|
@@ -45095,6 +45304,73 @@ var MediaRelocateEngine = class {
|
|
|
45095
45304
|
this.deps.onFinished?.(snapshot(job));
|
|
45096
45305
|
}
|
|
45097
45306
|
}
|
|
45307
|
+
/**
|
|
45308
|
+
* Stamp every unstamped row of one collection with the job's target, in
|
|
45309
|
+
* bounded bulk statements.
|
|
45310
|
+
*
|
|
45311
|
+
* ## What this replaced
|
|
45312
|
+
*
|
|
45313
|
+
* A per-row loop: page the whole collection through {@link pageRows}, then
|
|
45314
|
+
* one `store.set.mutate` per selected row — a full-row UPSERT rewriting every
|
|
45315
|
+
* column to change one field, one RPC each, plus a second full walk before it
|
|
45316
|
+
* to produce the denominator. Measured on the live hub against the 464 902
|
|
45317
|
+
* unstamped rows: **2.3 rows/s, falling to 0.7 rows/s under a competing
|
|
45318
|
+
* drain**, i.e. over two and a half days for a metadata stamp that reads no
|
|
45319
|
+
* file and moves no byte. It was cancelled. This is the write-side twin of
|
|
45320
|
+
* the read-side walk {@link countUnstamped} retired, and the fourth instance
|
|
45321
|
+
* of the same pattern found in one day — work looped over the door instead of
|
|
45322
|
+
* pushed into the statement.
|
|
45323
|
+
*
|
|
45324
|
+
* ## The shape
|
|
45325
|
+
*
|
|
45326
|
+
* Probe the indexed `IS NULL` range for a batch of ids, then ONE
|
|
45327
|
+
* `updateWhere` over those ids. Two round trips per {@link SEAL_BATCH_SIZE}
|
|
45328
|
+
* rows instead of one write per row.
|
|
45329
|
+
*
|
|
45330
|
+
* The probe carries no cursor and needs none: the predicate CONSUMES itself.
|
|
45331
|
+
* A stamped row leaves the range, so the next probe returns the next batch,
|
|
45332
|
+
* and a run that is interrupted anywhere simply resumes on what is left. That
|
|
45333
|
+
* is also why the seal is idempotent (a second run probes an empty range and
|
|
45334
|
+
* stamps nothing) and why it cannot repoint a row that already names a
|
|
45335
|
+
* different location — `IS NULL` never selects one.
|
|
45336
|
+
*
|
|
45337
|
+
* A single unbounded `UPDATE … WHERE locationId IS NULL` would be one
|
|
45338
|
+
* statement instead of ~1 860, and is not used: half a million row rewrites,
|
|
45339
|
+
* each maintaining four indexes, is an unbounded statement holding the write
|
|
45340
|
+
* lock on a database the whole cluster reads configuration through, under an
|
|
45341
|
+
* RPC deadline that must not be raised. Batching bounds the statement, keeps
|
|
45342
|
+
* `cancel` responsive between batches, and keeps `filesMoved` honest.
|
|
45343
|
+
*
|
|
45344
|
+
* ## The termination guard
|
|
45345
|
+
*
|
|
45346
|
+
* If the probe finds rows and the update reports **0**, this THROWS. That is
|
|
45347
|
+
* not defensive noise: it is precisely what a backend compiling
|
|
45348
|
+
* `locationId = NULL` does — matches nothing, reports success (D312) — and
|
|
45349
|
+
* against it a self-consuming loop never terminates and a non-looping one
|
|
45350
|
+
* reports a completed seal having stamped nothing. Zero unstamped rows is the
|
|
45351
|
+
* single value the migration gate reads as "safe to cut over".
|
|
45352
|
+
*/
|
|
45353
|
+
async sealCollection(collection, job) {
|
|
45354
|
+
for (;;) {
|
|
45355
|
+
if (job.cancelRequested) return;
|
|
45356
|
+
const batch = await this.deps.store.query.query({
|
|
45357
|
+
collection,
|
|
45358
|
+
filter: {
|
|
45359
|
+
where: { [LOCATION_ID_FIELD]: null },
|
|
45360
|
+
limit: SEAL_BATCH_SIZE
|
|
45361
|
+
},
|
|
45362
|
+
columns: [LOCATION_ID_FIELD]
|
|
45363
|
+
});
|
|
45364
|
+
if (batch.length === 0) return;
|
|
45365
|
+
const { updated } = await this.deps.store.updateWhere.mutate({
|
|
45366
|
+
collection,
|
|
45367
|
+
filter: { whereIn: { id: batch.map((row) => row.id) } },
|
|
45368
|
+
data: { [LOCATION_ID_FIELD]: job.toLocationId }
|
|
45369
|
+
});
|
|
45370
|
+
if (updated === 0) throw new Error(`event-media seal stamped 0 of ${batch.length} unstamped row(s) in "${collection}" — the bulk predicate matched nothing, so the population cannot be sealed`);
|
|
45371
|
+
job.filesMoved += updated;
|
|
45372
|
+
}
|
|
45373
|
+
}
|
|
45098
45374
|
async stamp(collection, key, data, locationId) {
|
|
45099
45375
|
await this.deps.store.set.mutate({
|
|
45100
45376
|
collection,
|
|
@@ -51826,6 +52102,43 @@ var EventStore = class {
|
|
|
51826
52102
|
return motion + object + audio;
|
|
51827
52103
|
}
|
|
51828
52104
|
/**
|
|
52105
|
+
* Persisted event rows (motion + object + audio) for EVERY device that has
|
|
52106
|
+
* any — three round trips for the whole fleet.
|
|
52107
|
+
*
|
|
52108
|
+
* {@link countForDevice} answers one camera in three `COUNT(*)`s, which is
|
|
52109
|
+
* right for one camera and wrong in a loop: the events-management footprint
|
|
52110
|
+
* called it once per device, so the cost of "how many events does each camera
|
|
52111
|
+
* hold" grew with the camera count for a result that is a few dozen integers.
|
|
52112
|
+
*
|
|
52113
|
+
* A histogram on `deviceId` with `bucketSize: 1` is `GROUP BY deviceId`, and
|
|
52114
|
+
* every event collection already carries `idx_<kind>_device_ts`, which leads
|
|
52115
|
+
* with `deviceId` — so the planner serves each of these from a covering index
|
|
52116
|
+
* with no table access at all. `bucket` IS the deviceId (the column is
|
|
52117
|
+
* INTEGER, the bucket expression is `CAST((deviceId - 0) / 1 AS INTEGER)`).
|
|
52118
|
+
*
|
|
52119
|
+
* Not best-effort: a failure propagates. The one caller is sizing a deletion,
|
|
52120
|
+
* and a camera silently reported as holding zero events is the answer that
|
|
52121
|
+
* must never come from a read that did not happen.
|
|
52122
|
+
*/
|
|
52123
|
+
async countByDevice() {
|
|
52124
|
+
const perCollection = await Promise.all([
|
|
52125
|
+
MOTION_EVENTS_COLLECTION,
|
|
52126
|
+
OBJECT_EVENTS_COLLECTION,
|
|
52127
|
+
AUDIO_EVENTS_COLLECTION
|
|
52128
|
+
].map((collection) => this.store.histogram.query({
|
|
52129
|
+
collection,
|
|
52130
|
+
field: "deviceId",
|
|
52131
|
+
bucketSize: 1,
|
|
52132
|
+
origin: 0
|
|
52133
|
+
})));
|
|
52134
|
+
const out = /* @__PURE__ */ new Map();
|
|
52135
|
+
for (const buckets of perCollection) for (const bucket of buckets) {
|
|
52136
|
+
if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
|
|
52137
|
+
out.set(bucket.bucket, (out.get(bucket.bucket) ?? 0) + bucket.count);
|
|
52138
|
+
}
|
|
52139
|
+
return out;
|
|
52140
|
+
}
|
|
52141
|
+
/**
|
|
51829
52142
|
* Return per-kind event counts in equal-width time buckets.
|
|
51830
52143
|
*
|
|
51831
52144
|
* Each bucket maps to a `bucketStart = since + i * bucketMs`.
|
|
@@ -71549,6 +71862,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
71549
71862
|
async getEventStoreFootprint() {
|
|
71550
71863
|
return this.queryFacade.getEventStoreFootprint();
|
|
71551
71864
|
}
|
|
71865
|
+
async getEventMediaFootprintByKind(input) {
|
|
71866
|
+
return this.queryFacade.getEventMediaFootprintByKind(input);
|
|
71867
|
+
}
|
|
71552
71868
|
async pruneEvents(input) {
|
|
71553
71869
|
return this.queryFacade.pruneEvents(input);
|
|
71554
71870
|
}
|