#122: the Notification Outbox KPI snapshots ORed every status in one aggregation, which scanned the clustered index once the table held ~1 M Parked rows (0.7-2.6 s; Health dashboard up to 5 s). Each snapshot now runs three status-equality legs that seek their own index (~0.12 s); a cold Health load on the rig is 0.12-0.52 s. #123: site_events event_type/severity indexes are now composites with timestamp, so a filtered page is read in order and stops at the limit (severity=Error over 1 GB: 2,287 -> 15 ms cold, 115 -> 1.6 ms warm). Costs 454 -> 530 B/row, ~14 % fewer rows under the same cap. The schema apply drops the old indexes in place; the cap size model samples timestamp three times and re-scores inside its band with unchanged constants. #124: the set-based AuditLog insert's NOT EXISTS probe scanned the EventId index and concurrent writers deadlocked on S/IX page locks. WITH (FORCESEEK): 4 writers 775 rows/s + 76 deadlocks in 30 s -> 5,214 rows/s + 0. New concurrent-writer regression test fails 4/4 without the hint.
17 KiB
site_events storage cap — what the cap measures (register row 81)
Date: 2026-08-17
Row: deferred-work register #81 — "the storage cap fights its own oplog."
Outcome: cap semantics changed. ScadaBridge:SiteEventLog:MaxStorageMb now bounds
site_events and its four indexes, not the whole consolidated LocalDb file. dbstat — the
exact measurement the register proposed — turned out to be unavailable in the SQLite this
solution ships, so the table's footprint is modelled from sampled row width. This memo records
the probe that ruled dbstat out, the model, its measured accuracy, and the new meaning of the
wipe-prevention guard.
1. The defect
Since LocalDb Phase 1/2 the site keeps ten tables in one SQLite file at LocalDb:Path, alongside
LocalDb's own __localdb_oplog, __localdb_row_version and __localdb_snapshot_state
bookkeeping. EventLogPurgeService.GetDatabaseSizeBytes() measured
page_count − freelist_count over that whole file and compared it to MaxStorageMb.
On a replication-enabled node the cap trim therefore fought itself. Every 1000-row batch freed
~390 KB of site_events pages and pushed ~1000 rows into __localdb_oplog plus ~1000
row-version tombstones into the same file. The measured size did not fall, the existing
"size did not decrease across an iteration" guard fired, and the trim stopped — with the cap
unmet.
Measured 2026-08-16 during the row-34 work
(docs/plans/2026-08-15-site-events-purge-burst-measurement.md §3.4/§5), 64 MB cap, 400 000 rows:
| Replicated? | Events deleted | Remaining | Size before → after | Reached the cap? |
|---|---|---|---|---|
| no | 248 000 | 152 000 | 158.0 MB → 66.98 MB | yes (cap 67.11 MB) |
| yes | 174 000 | 226 000 | 203.6 MB → 167.7 MB | no — stopped 2.5x over cap |
Two consequences, both real:
- A node whose peer is down cannot enforce its event-log cap at all. Nothing drains the oplog, so the file never shrinks and the guard stops the trim every tick, forever.
- The "1 GB event log" budget was in practice shared with replication bookkeeping the event log does not own. Worse, the direction of the coupling is backwards: a replication fault (peer down, oplog piling up) would, under a naive "just keep deleting" fix, cause operational event history to be destroyed. The cap must not be a function of the peer's health.
The old guard was not the bug — it was doing its job, preventing the table being wiped in pursuit of an unreachable target. The bug is that the target was unreachable, because the metric was wrong.
2. dbstat is not available — the decision this memo turns on
The register proposed SELECT sum(pgsize) FROM dbstat WHERE name='site_events'. dbstat is a
compile-time option (SQLITE_ENABLE_DBSTAT_VTAB), and it was verified before any
implementation was written. It is not enabled in the native library this solution ships
(SQLitePCLRaw.lib.e_sqlite3, the security-pinned 2.1.12 build referenced by both
ZB.MOM.WW.ScadaBridge.SiteEventLogging and its test project).
Probed live against a real file, SQLite 3.53.3:
PRAGMA compile_options -> no ENABLE_DBSTAT_VTAB
(ENABLE_FTS3/4/5, RTREE, GEOPOLY, SESSION, PREUPDATE_HOOK, … present)
pragma_module_list -> 10 modules: fts3, fts3tokenize, fts4, fts4aux, fts5, fts5vocab,
geopoly, pragma_module_list, rtree, rtree_i32
SELECT sum(pgsize) FROM dbstat -> SQLite Error 1: 'no such table: dbstat'
SELECT … FROM dbstat('main') WHERE name=… -> SQLite Error 1: 'no such table: dbstat'
sqlite_dbpage — the other route to per-table page attribution — is absent for the same reason.
The module list is non-empty, so this is a genuine absence and not an empty-result artefact.
This is pinned by a committed test, SqliteCapabilityTests.DbStat_IsNotAvailable_…, written to
fail if dbstat ever appears. If it does, the right move is to delete the model in §3 and measure
exactly; the test's own comment says so.
Alternatives considered and rejected:
- Switch to a SQLite build that enables
dbstat(systemlibsqlite3viaSQLitePCLRaw.bundle_sqlite3, or a vendored native build). Rejected: the provider is shared withZB.MOM.WW.LocalDband every other SQLite consumer in the product, so this trades a storage-cap accuracy problem for fleet-wide version skew across macOS dev boxes and the Debian-based runtime image — a disproportionate blast radius for a coarse safety valve. - Give
site_eventsits own file again. Reverses LocalDb Phase 1/2 consolidation and removes the table from CDC replication. Out of the question. - Maintain a running byte counter in the writer, or via triggers. The writer is not the only
thing that inserts into
site_events(replication applies remote rows), a counter does not survive a restart, and adding our own triggers to aRegisterReplicatedtable is hazardous — LocalDb builds its capture triggers from the column list at registration time and extra triggers would also fire on replicated applies. - Keep the whole-file metric and simply stop re-measuring mid-loop (compute a delete plan once and execute it). This makes the trim terminate, but it makes the event log pay for bookkeeping bytes: an oplog inflated by a down peer would drive aggressive deletion of operational history. That is consequence 2 above, made worse rather than fixed.
3. What the cap measures now
EventLogPurgeService.EstimateSiteEventsSize() returns the live row count and modelled bytes per
row. MaxStorageMb is compared against rowCount × bytesPerRow.
Row width is sampled — SizeSampleRows (500) rows from each end of the table, head and tail.
The head is what the oldest-first trim will actually delete; the tail catches an event stream whose
shape has drifted. Widths are taken with octet_length() (SQLite 3.43+), not length(), which
counts characters and would under-size any multi-byte UTF-8 message; a second committed test pins
that function's availability.
The table term is computed as whole pages, ceil(rows / rowsPerPage) × pageSize, rather than
as a flat bytes-per-row rate:
usablePerPage = (pageSize - 35) * 0.78 // max-local-payload rule × measured mean page fill
avgCellBytes = avgPayloadBytes + 16 // payload-length varint, rowid varint, cell pointer
rowsPerPage = max(1, floor(usablePerPage / avgCellBytes))
tableBytes = ceil(rowCount / rowsPerPage) * pageSize
indexBytes = rowCount * (avgIndexedBytes + 8 * 4) / 0.75
estimate = min(tableBytes + indexBytes, wholeFileLogicalBytes)
Whole pages rather than a rate because that term dominates once rows approach half a page: a 2 KB row cannot be packed two-to-a-page, so each one wastes the remainder. A linear model underestimates that case by ~40 % (measured), and underestimating is the failure direction that matters for a cap.
Index pages count against the cap — deliberately. Measured, the four indexes are ~34 % of
site_events' total footprint (397.2 vs 260.9 bytes/row with and without them, same 200 000 rows),
and the 1 GB default was sized against the 393 bytes/row figure from row 34, which already included
them. Excluding index pages would silently raise real disk consumption by about a third at an
unchanged setting. The index term is driven by the sampled widths of the four indexed columns, so
an event stream that leaves instance_id null is charged less than one that fills it.
Clamped by the file. The whole-file logical size is a true upper bound on any single table's
pages, so the model is capped by it. This can only ever correct an over-estimate downward, and it
cannot reintroduce the row-81 defect: on a replicated node the file is larger than
site_events, never smaller, so the clamp does not bind there.
GetDatabaseSizeBytes() survives, renamed in meaning rather than in signature: it is the whole
consolidated file's logical size, used for the clamp and as a diagnostic. It is no longer what the
cap compares against.
3.1 Measured accuracy
Calibration seeded realistic rows into a real file and measured page_count/freelist_count/
page_size. Two independent cross-checks against row 34's harness: the baseline workload came out
at 397.2 bytes/row against row 34's independently measured 393, and the row-count-at-1 GB
figure follows.
| Workload | rows | actual bytes/row | payload bytes/row |
|---|---|---|---|
| 1-in-10 details @200 B (baseline) | 200 000 | 397.2 | 187.9 |
| same, no indexes | 200 000 | 260.9 | 187.9 |
| no details at all | 200 000 | 373.5 | 167.7 |
| details on every row @200 B | 200 000 | 597.7 | 367.7 |
| details on every row @2 KB | 50 000 | 4295.2 | 2215.7 |
| 1-in-10 details @4 KB | 50 000 | 824.7 | 583.0 |
| small table | 5 000 | 396.5 | 189.6 |
The shipped model, scored against a differential measurement of the table's real page cost
(file measured with ballast only, events added, growth attributed to site_events) — this is
EstimateSiteEventsSize_TracksTheRealFootprint, run on every build:
| Workload | estimated | actual | error |
|---|---|---|---|
| narrow rows, no details | 3 155 626 | 2 912 256 | +8.4 % |
| typical rows, 200 B details | 3 992 096 | 3 538 944 | +12.8 % |
| wide rows, 2 KB details | 12 748 048 | 12 845 056 | −0.8 % |
The test asserts the band −5 % … +25 %. The bias is intentionally to the high side: a cap that fires slightly early forfeits a slice of the budget, whereas one that reads low lets the log overrun the disk allowance the cap exists to defend. Against the previous behaviour's +250 % error on a replicated node this is not a close call.
Known limitation. The model samples 1000 rows out of a table that may hold millions. An event stream whose row width changes sharply in the middle of the history — neither at the head nor the tail — is mis-sized until the trim reaches it. Self-correcting in practice, since the cap is re-evaluated every tick and the head sample advances as the oldest rows go.
3.2 Recalibration for the composite indexes (#123, 2026-11-10)
#123 replaced the single-column event_type and severity indexes with (event_type, timestamp)
and (severity, timestamp). There are still four secondary indexes, so IndexedColumnCount = 4
stands. The sampler's indexed_bytes now counts timestamp three times: once for its own index
and once for each composite. The other constants are unchanged.
The model was re-scored with EstimateSiteEventsSize_TracksTheRealFootprint:
| Workload | estimated | actual | error | before #123 |
|---|---|---|---|---|
| narrow rows, no details | 3 859 626 | 3 551 232 | +8.7 % | +8.4 % |
| typical rows, 200 B details | 4 520 096 | 4 005 888 | +12.8 % | +12.8 % |
| wide rows, 2 KB details | 13 012 048 | 13 078 528 | −0.5 % | −0.8 % |
The band and the high bias are unchanged. SiteEventsSchemaCalibrationTests pins the new index
set and the columns each index covers.
4. The wipe-prevention guard, restated
The register asked whether the "size did not decrease" guard should survive. It should, and it does, but its meaning has changed and it has gained a sibling. There are now two backstops:
-
Unusable row width → refuse to trim. Checked before the cap comparison, because a zero bytes-per-row makes "is
site_eventsover cap" unanswerable rather than false. Logs a Warning naming the row count and deletes nothing. The old code had no analogue: its answer to an unanswerable cap was to keep deleting until the file shrank, which is exactly how a cap that can never be met empties a table. -
Estimated size did not decrease across a batch → stop. The old guard, re-based onto the table-scoped metric. Crucially it can no longer be tripped by replication bookkeeping — the metric excludes it — so when it fires it now means precisely one thing: the table is not shrinking even though deletes are landing, i.e. new events are arriving at least as fast as the trim removes them. That is a real operational condition and the Warning says so.
For this to be observable rather than tautological, the loop re-reads the row count from the database after each batch instead of decrementing a local counter, so concurrent recorder writes are visible. Bytes-per-row, by contrast, is sampled once per tick and held fixed — re-sampling every batch would make the estimate jitter as the trim consumes the head of the table, and a sample that happened to widen could stall the loop through this very guard. That is the same class of self-defeating measurement as row 81 itself, and it is deliberately avoided.
Both backstops are covered by tests that pin a degenerate estimate through internal virtual
seams (EstimateSiteEventsSize, GetSiteEventsRowCount).
5. Behaviour on an UNREPLICATED node
Materially unchanged, as intended — but not bit-identical, and the delta is worth stating.
On a node with no CDC triggers there is no oplog and no tombstone table, so whole-file logical size
was already ≈ site_events + the nine other (largely small) tables. The cap therefore used to fire
at roughly the right place, and the row-34 measurement confirms it: the unreplicated trim reached
66.98 MB against a 67.11 MB cap.
What changes:
- Slightly more headroom. The other nine tables no longer count against the event log's budget. On a rig-shaped site those tables are small relative to a 1 GB event log, so the shift is a few percent at most.
- Model error replaces measurement error. Where the old number was exact-but-wrong-scoped, the new one is right-scoped but carries the +8 %/+13 % conservative bias of §3.1. Net effect on an unreplicated node: the cap fires marginally earlier than before at the same setting.
Neither warrants a config change. Sites that were happy with MaxStorageMb: 1024 remain so.
6. Explicitly out of scope: LocalDb's own bookkeeping
This change makes the cap ignore __localdb_oplog, __localdb_row_version and
__localdb_snapshot_state. It does not attempt to manage them, and should not.
Their size is governed by LocalDb's own machinery — LocalDb:Replication:MaxOplogRows (default
1 000 000) and TombstoneRetention (default 7 days) — and row 34's pacing
(MaxPurgedRowsPerTick) is what keeps a purge burst from blowing through the oplog cap and forcing
a snapshot resync. Those are the correct controls and they already exist. A second, competing
bound applied from the event log would only obscure which subsystem is responsible for a full disk.
The honest statement of the budget is therefore: MaxStorageMb bounds site_events and its
indexes; LocalDb replication bookkeeping is separate overhead on the same volume, bounded
separately. Sizing a site's data volume means adding the two, and the topology guide now says so.
Row 34's measurement gives the scale — a 2.73 M-row purge left 707 MB of bookkeeping in the file
after deleting a 1 GB event log — and that overhead is transient, draining as the peer acks and as
tombstones expire.
7. Verification
dbstatunavailability probed before any implementation was written, on a real file, with the result recorded in §2 and pinned bySqliteCapabilityTests.- Fail-before, row 81. With
EstimateSiteEventsSizereverted to pre-row-81 semantics (whole file attributed tosite_events) and nothing else changed, 5 of the new tests fail and all 99 others pass.PurgeByStorageCap_WithLargeBallastInTheSameFile_StillEnforces…reproduces the original signature exactly:remaining=2000 estimate=21725184 cap=11534336— 4000 of 6000 rows destroyed and the cap still unmet. - Fail-before, row 84. With per-phase emission restored,
RunPurge_WhenBothPhasesDeleteRows_LogsTheBacklogNoteOnceForTheWholeTickfails withAssert.Single() Failure: The collection contained 2 matching items. dotnet build ZB.MOM.WW.ScadaBridge.slnx— clean, 0 warnings.ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests: 104 passed / 0 failed (93 before this change).- The docker rig was not touched.
8. Register row 84, folded in
LogReplicationBacklogNote was emitted by each purge phase, so a tick in which the retention purge
and the cap trim both deleted rows produced two identical Information notes. It is now emitted
once from RunPurge, driven by the tick's shared PurgeBudget.
The budget is the natural source: it already holds exactly "rows this tick deleted", which is also
the more useful number to put next to an oplog spike — the tick's whole contribution rather than
one phase's share. PurgeByRetention keeps its own "Purged N events older than D days" line; only
the replication note moved.