fix(siteeventlog): scope the storage cap to site_events' own pages (register rows 81, 84)
Row 81 — the cap could not be enforced on a replicated node. MaxStorageMb was compared against page_count-freelist_count over the WHOLE consolidated LocalDb file, which since Phase 1/2 holds ten tables plus __localdb_oplog / __localdb_row_version / __localdb_snapshot_state. Every 1000-row trim batch freed ~390 KB of site_events pages and added ~1000 oplog rows + ~1000 tombstones to the same file, so the measured size did not fall, the "size did not decrease" guard fired, and the trim stopped 2.5x over a 64 MB cap with 226 k rows still present. A node whose peer was down could not enforce its cap at all, and the event log's budget was in practice shared with bookkeeping it does not own. The cap now measures site_events AND ITS FOUR INDEXES only, so replication bookkeeping can neither consume nor block the budget — and the cap stops being a function of the peer's health, which was the wrong direction of coupling: an inflated oplog is a replication fault, and letting it drive event deletion would destroy operational history in response to one. dbstat — the exact per-table measurement the register proposed — is NOT usable here, verified before implementing. It is a compile-time option and the bundled SQLitePCLRaw.lib.e_sqlite3 (SQLite 3.53.3) does not enable it: PRAGMA compile_options lists no ENABLE_DBSTAT_VTAB, pragma_module_list returns ten modules with neither dbstat nor sqlite_dbpage, and selecting from dbstat fails with "no such table". Switching SQLite builds was rejected — the provider is shared with ZB.MOM.WW.LocalDb and every other consumer, so it trades cap accuracy for fleet-wide version skew. The footprint is therefore modelled from sampled row width (500 rows each end, octet_length so multi-byte UTF-8 is not under-counted) through a page-layout model; whole pages rather than a flat rate, because page fill collapses once rows approach half a page and a linear model underestimates that by ~40%. Clamped by whole-file logical size, a true upper bound that can only correct an over-estimate and never binds on a replicated node. Index pages count against the cap deliberately: measured at ~34% of the table's footprint, and the 1 GB default was sized against a 393 B/row figure that already included them. Guard semantics restated — two backstops replace the single on-disk check: (1) row width unsampleable => refuse to trim (checked before the cap comparison, since a zero width makes "over cap" unanswerable rather than false; the old loop's answer to an unanswerable cap was to keep deleting); (2) estimated size not falling across a batch => stop. (2) is the old guard re-based onto the table-scoped metric, so it can no longer be tripped by replication bookkeeping and now means exactly one thing: events are arriving as fast as the trim removes them. For that to be observable the loop re-reads the row count each batch, while bytes-per-row is sampled once per tick and held fixed — re-sampling per batch would jitter the estimate as the trim eats the head of the table and could stall the loop through guard (2), the same self-defeating measurement as row 81 itself. Row 84 — LogReplicationBacklogNote moved from per-phase to once per tick, driven by the shared PurgeBudget. A tick where retention AND the cap trim both deleted used to emit two identical Information notes; the tick total is also the more useful number beside an oplog spike. Measured model error: -0.8% / +8.4% / +12.8% across representative workloads, biased high on purpose (an early cap forfeits budget; a low one fails at its only job). Independently cross-checks row 34's harness at 397.2 vs 393 B/row. Fail-before: reverting the estimator to whole-file semantics fails 5 of the new tests and reproduces the original signature exactly (remaining=2000 estimate=21725184 cap=11534336 — rows destroyed, cap still unmet); restoring per-phase notes fails the row-84 test with "collection contained 2 matching items". No config keys added or renamed, so no rig/deploy changes. Decision record: docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md Tests: SiteEventLogging 104/104 (was 93), Host 520/520, solution build clean.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -60,6 +62,40 @@ public class EventLogPurgeService : BackgroundService
|
||||
/// <summary>Number of events deleted per retention-purge batch — same size as <see cref="CapPurgeBatchSize"/>.</summary>
|
||||
private const int RetentionPurgeBatchSize = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Rows sampled from each end of <c>site_events</c> when sizing an average row. See
|
||||
/// <see cref="EstimateSiteEventsSize"/>.
|
||||
/// </summary>
|
||||
private const int SizeSampleRows = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Per-row bookkeeping a table B-tree leaf spends on top of the row's own bytes: the
|
||||
/// payload-length varint, the rowid varint and the 2-byte cell-pointer slot.
|
||||
/// </summary>
|
||||
private const int TableCellOverheadBytes = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Bytes at the end of a page SQLite will not fill with local payload — the
|
||||
/// <c>pageSize - 35</c> maximum-local-payload rule for table leaves.
|
||||
/// </summary>
|
||||
private const int PageReservedBytes = 35;
|
||||
|
||||
/// <summary>
|
||||
/// Average proportion of a table leaf page that actually holds rows. B-tree splits leave
|
||||
/// pages part-empty; 0.78 is the measured mean across the workloads in
|
||||
/// <c>docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md</c> §3.
|
||||
/// </summary>
|
||||
private const double TablePageFillFactor = 0.78;
|
||||
|
||||
/// <summary>Number of indexes on <c>site_events</c> (timestamp, event_type, instance_id, severity).</summary>
|
||||
private const int IndexedColumnCount = 4;
|
||||
|
||||
/// <summary>Per-entry bookkeeping in an index B-tree leaf: key-length varint, rowid and cell pointer.</summary>
|
||||
private const int IndexEntryOverheadBytes = 8;
|
||||
|
||||
/// <summary>Average fill of an index leaf page; measured alongside <see cref="TablePageFillFactor"/>.</summary>
|
||||
private const double IndexPageFillFactor = 0.75;
|
||||
|
||||
/// <summary>Minimum spacing between ceiling-reached log lines; see <see cref="LogCeilingReached"/>.</summary>
|
||||
private static readonly TimeSpan CeilingLogInterval = TimeSpan.FromMinutes(15);
|
||||
|
||||
@@ -186,6 +222,14 @@ public class EventLogPurgeService : BackgroundService
|
||||
PurgeByRetention(budget);
|
||||
PurgeByStorageCap(budget);
|
||||
|
||||
// Register row 84: emitted ONCE per tick, from the tick's shared budget, rather
|
||||
// than once per phase. A tick in which both the retention purge and the cap trim
|
||||
// deleted rows used to log two identical Information notes; the number an operator
|
||||
// wants next to the oplog spike is the tick total anyway, which is exactly what
|
||||
// the shared budget already holds.
|
||||
if (budget.Deleted > 0)
|
||||
LogReplicationBacklogNote(budget.Deleted);
|
||||
|
||||
if (!budget.StoppedAtCeiling)
|
||||
return false;
|
||||
|
||||
@@ -290,10 +334,7 @@ public class EventLogPurgeService : BackgroundService
|
||||
}
|
||||
|
||||
if (totalDeleted > 0)
|
||||
{
|
||||
_logger.LogInformation("Purged {Count} events older than {Days} days", totalDeleted, _options.RetentionDays);
|
||||
LogReplicationBacklogNote(totalDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -318,9 +359,17 @@ public class EventLogPurgeService : BackgroundService
|
||||
|
||||
/// <summary>
|
||||
/// Emits the operator breadcrumb that explains the oplog backlog spike a purge produces on a
|
||||
/// replication-enabled node. No-op when this node has no replication peer.
|
||||
/// replication-enabled node — <b>once per purge tick</b>. No-op when this node has no
|
||||
/// replication peer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Called from <see cref="RunPurge"/> rather than from each phase (register row 84). Both the
|
||||
/// retention purge and the cap trim generate the same CDC cost, and a tick in which both
|
||||
/// deleted rows used to emit two identical Information notes. One note carrying the tick's
|
||||
/// combined total is what an operator correlating an oplog spike actually wants.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>site_events</c> is a replicated table, and a retention/cap DELETE is captured by CDC
|
||||
/// exactly like an ordinary write — there is deliberately no purge-exemption path (LocalDb
|
||||
/// Phase 2: CDC does all three jobs). The daily purge therefore pushes one oplog row per
|
||||
@@ -329,8 +378,12 @@ public class EventLogPurgeService : BackgroundService
|
||||
/// on its own; without this line an operator correlating the spike has nothing in the log to
|
||||
/// tie it to. Information level because it is only interesting next to the purge line it
|
||||
/// follows — the spike itself is not a fault.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="deletedRows">Number of rows the purge just deleted; always > 0 at the call sites.</param>
|
||||
/// <param name="deletedRows">
|
||||
/// Rows deleted by the WHOLE tick — retention purge and cap trim combined, read off the
|
||||
/// shared <see cref="PurgeBudget"/>. Always > 0 at the single call site.
|
||||
/// </param>
|
||||
private void LogReplicationBacklogNote(int deletedRows)
|
||||
{
|
||||
bool replicated;
|
||||
@@ -358,13 +411,52 @@ public class EventLogPurgeService : BackgroundService
|
||||
_options.MaxPurgedRowsPerTick > 0 ? _options.MaxPurgedRowsPerTick.ToString() : "unlimited");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trims the oldest events until <c>site_events</c>' own footprint is back under
|
||||
/// <see cref="SiteEventLogOptions.MaxStorageMb"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The cap is table-scoped as of register row 81.</b> It used to compare
|
||||
/// <see cref="SiteEventLogOptions.MaxStorageMb"/> against the whole consolidated LocalDb
|
||||
/// file, which on a replication-enabled node made the trim unwinnable: every batch freed
|
||||
/// event pages and added an equal number of oplog rows and row-version tombstones to the
|
||||
/// same file, so the measured size did not fall and the progress guard stopped the trim
|
||||
/// 2.5x over cap with 226 k rows still present. The cap now measures
|
||||
/// <see cref="EstimateSiteEventsSize"/> — <c>site_events</c> and its four indexes only —
|
||||
/// so replication bookkeeping can no longer consume, or block, the event log's budget.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Bytes-per-row is sampled ONCE per tick and held fixed for the whole loop, while the row
|
||||
/// count is re-read after each batch. That split is deliberate: re-sampling row width 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 the progress guard below —
|
||||
/// the same class of self-defeating measurement row 81 was about.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="budget">The tick's shared delete budget, already drawn down by the retention purge.</param>
|
||||
private void PurgeByStorageCap(PurgeBudget budget)
|
||||
{
|
||||
var capBytes = (long)_options.MaxStorageMb * 1024 * 1024;
|
||||
var currentSizeBytes = GetDatabaseSizeBytes();
|
||||
var estimate = EstimateSiteEventsSize();
|
||||
|
||||
if (currentSizeBytes <= capBytes)
|
||||
if (estimate.RowCount <= 0)
|
||||
return;
|
||||
|
||||
// Wipe-prevention backstop, first of two — checked BEFORE the cap comparison, because a
|
||||
// zero row width makes "site_events is over cap" unanswerable rather than false. An
|
||||
// unusable width (empty sample, degenerate estimate) used to have no analogue: the old
|
||||
// loop's answer to an unanswerable cap was to keep deleting. Refuse instead.
|
||||
if (estimate.BytesPerRow <= 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Storage-cap trim skipped: site_events row width could not be sampled ({Rows} rows). " +
|
||||
"No rows are deleted rather than trimming against an unusable size estimate.",
|
||||
estimate.RowCount);
|
||||
return;
|
||||
}
|
||||
|
||||
if (estimate.EstimatedBytes <= capBytes)
|
||||
return;
|
||||
|
||||
// Over cap but out of budget: flag the catch-up tick and stay silent rather than logging
|
||||
@@ -378,14 +470,18 @@ public class EventLogPurgeService : BackgroundService
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Event log size {Size:F1} MB exceeds cap {Cap} MB — purging oldest events",
|
||||
currentSizeBytes / (1024.0 * 1024.0), _options.MaxStorageMb);
|
||||
"site_events occupies about {Size:F1} MB across {Rows} rows — over the {Cap} MB cap. " +
|
||||
"Trimming the oldest events. (The cap measures the event table and its indexes only, " +
|
||||
"not the whole consolidated LocalDb file.)",
|
||||
estimate.EstimatedBytes / (1024.0 * 1024.0), estimate.RowCount, _options.MaxStorageMb);
|
||||
|
||||
// Delete the oldest events in batches until the database is under the cap.
|
||||
// The loop also stops if the on-disk size fails to decrease across an
|
||||
// iteration (e.g. if vacuum cannot reclaim space), so a cap that can never
|
||||
// be met does not silently empty the entire table.
|
||||
// Delete the oldest events in batches until the ESTIMATED TABLE size is under the cap.
|
||||
// Wipe-prevention backstop, second of two: the loop also stops if the estimate fails to
|
||||
// fall across an iteration. Replication bookkeeping can no longer trip that — the metric
|
||||
// excludes it — so it now means what it says: the table is not shrinking, because a batch
|
||||
// deleted nothing or because concurrent recorder writes are outrunning the trim.
|
||||
var totalDeleted = 0;
|
||||
var currentSizeBytes = estimate.EstimatedBytes;
|
||||
|
||||
while (currentSizeBytes > capBytes)
|
||||
{
|
||||
@@ -430,57 +526,213 @@ public class EventLogPurgeService : BackgroundService
|
||||
|
||||
totalDeleted += deleted;
|
||||
budget.Record(deleted);
|
||||
currentSizeBytes = GetDatabaseSizeBytes();
|
||||
|
||||
// Row count re-read from the database, not decremented locally, so concurrent
|
||||
// recorder writes are visible; row width stays fixed for the tick (see remarks).
|
||||
currentSizeBytes = (long)(GetSiteEventsRowCount() * estimate.BytesPerRow);
|
||||
|
||||
if (currentSizeBytes >= previousSizeBytes)
|
||||
{
|
||||
// Size is not shrinking despite deletes — stop rather than wipe the
|
||||
// whole table. This should not happen now that logical size is
|
||||
// measured, but guards against any future regression.
|
||||
_logger.LogWarning(
|
||||
"Event log size did not decrease after a cap-purge batch ({Size:F1} MB); " +
|
||||
"stopping to avoid emptying the log",
|
||||
"Estimated site_events size did not decrease after a cap-purge batch " +
|
||||
"({Size:F1} MB); stopping to avoid emptying the log. Deletes are landing, so " +
|
||||
"this means new events are arriving at least as fast as the trim removes them.",
|
||||
currentSizeBytes / (1024.0 * 1024.0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Same CDC cost as the retention purge above — a cap-driven trim is usually the
|
||||
// larger of the two, so it gets the same operator breadcrumb.
|
||||
if (totalDeleted > 0)
|
||||
{
|
||||
LogReplicationBacklogNote(totalDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the logical size of the database in bytes — only pages that hold live
|
||||
/// data, excluding free pages on the freelist. Measuring logical size (rather than
|
||||
/// the raw file size from <c>page_count</c>) means the storage-cap loop observes
|
||||
/// space being reclaimed even if free pages have not yet been returned to the OS.
|
||||
/// Returns the logical size of the WHOLE consolidated database file in bytes — only pages
|
||||
/// that hold live data, excluding free pages on the freelist.
|
||||
/// </summary>
|
||||
/// <returns>The logical database size in bytes (used pages × page size).</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is no longer what the storage cap measures</b> (register row 81,
|
||||
/// <c>docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md</c>). Since LocalDb
|
||||
/// Phase 1/2 the file holds ten tables plus <c>__localdb_oplog</c>,
|
||||
/// <c>__localdb_row_version</c> and <c>__localdb_snapshot_state</c>, so a whole-file
|
||||
/// number is not <c>site_events</c>' size: on a replication-enabled node each cap-trim
|
||||
/// batch freed ~390 KB of event pages while adding ~1000 oplog rows and ~1000 tombstones
|
||||
/// to the same file, and the trim fought itself to a standstill 2.5x over cap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It survives for two narrow jobs: it is a true UPPER BOUND on <c>site_events</c>' own
|
||||
/// pages, which <see cref="EstimateSiteEventsSize"/> uses to clamp an over-eager model,
|
||||
/// and it remains a useful diagnostic for the file as a whole.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <returns>The logical size of the whole database file in bytes (used pages × page size).</returns>
|
||||
internal long GetDatabaseSizeBytes()
|
||||
{
|
||||
return _eventLogger.WithConnection(connection => ReadLogicalFileBytes(connection));
|
||||
}
|
||||
|
||||
/// <summary>Logical size of the whole file, on an already-held connection.</summary>
|
||||
/// <param name="connection">An open connection to the consolidated database.</param>
|
||||
/// <returns>Used pages × page size, in bytes.</returns>
|
||||
private static long ReadLogicalFileBytes(SqliteConnection connection)
|
||||
{
|
||||
var pageCount = ReadPragmaLong(connection, "PRAGMA page_count");
|
||||
var freeListCount = ReadPragmaLong(connection, "PRAGMA freelist_count");
|
||||
var pageSize = ReadPragmaLong(connection, "PRAGMA page_size");
|
||||
return Math.Max(0, pageCount - freeListCount) * pageSize;
|
||||
}
|
||||
|
||||
/// <summary>Reads a single-value PRAGMA as a 64-bit integer.</summary>
|
||||
/// <param name="connection">An open connection.</param>
|
||||
/// <param name="pragma">The PRAGMA statement to run.</param>
|
||||
/// <returns>The PRAGMA's value.</returns>
|
||||
private static long ReadPragmaLong(SqliteConnection connection, string pragma)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = pragma;
|
||||
return Convert.ToInt64(cmd.ExecuteScalar(), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many rows <c>site_events</c> currently holds, and how many bytes of the file one
|
||||
/// average row occupies — the two numbers the storage cap is expressed in.
|
||||
/// </summary>
|
||||
/// <param name="RowCount">Live row count at the time of the estimate.</param>
|
||||
/// <param name="BytesPerRow">
|
||||
/// Modelled file bytes per row, table pages plus index pages. <c>0</c> when the table is
|
||||
/// empty or the sample could not be sized, which the cap trim treats as "do not trim".
|
||||
/// </param>
|
||||
internal readonly record struct SiteEventsSizeEstimate(long RowCount, double BytesPerRow)
|
||||
{
|
||||
/// <summary>Modelled total bytes <c>site_events</c> occupies in the file.</summary>
|
||||
public long EstimatedBytes => (long)(RowCount * BytesPerRow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates how much of the consolidated file <c>site_events</c> — and only
|
||||
/// <c>site_events</c> — occupies, by sampling average row width and modelling SQLite's
|
||||
/// page layout.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why a model and not a measurement.</b> SQLite can report per-table page usage
|
||||
/// exactly through the <c>dbstat</c> virtual table, and that was the intended fix for
|
||||
/// register row 81 — but <c>dbstat</c> is a compile-time option and the
|
||||
/// <c>SQLitePCLRaw.lib.e_sqlite3</c> native build this solution ships does NOT enable it.
|
||||
/// Probed directly (<c>SqliteCapabilityTests</c>): <c>PRAGMA compile_options</c> on
|
||||
/// SQLite 3.53.3 lists no <c>ENABLE_DBSTAT_VTAB</c>, <c>pragma_module_list</c> returns ten
|
||||
/// modules (fts3/4/5, rtree, geopoly) with neither <c>dbstat</c> nor <c>sqlite_dbpage</c>
|
||||
/// among them, and selecting from <c>dbstat</c> fails with "no such table". So an exact
|
||||
/// per-table page count is not obtainable from this provider at all, and the cap is
|
||||
/// modelled instead. That trade is recorded in
|
||||
/// <c>docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The model.</b> Rows are sampled from both ends of the table (<see cref="SizeSampleRows"/>
|
||||
/// from each — the head is what the oldest-first trim will actually delete, the tail catches a
|
||||
/// workload whose event shape has drifted) to get an average payload width and an average
|
||||
/// width across the four indexed columns. Table cost is then computed as whole pages —
|
||||
/// <c>ceil(rows / rowsPerPage) × pageSize</c> — rather than as a flat bytes-per-row rate,
|
||||
/// because that is the term that dominates once rows approach half a page: rows too wide to
|
||||
/// pack two-per-page waste the remainder, which a linear model underestimates by ~40 %.
|
||||
/// Index cost is added per row from the sampled indexed-column widths, so an event stream
|
||||
/// that leaves <c>instance_id</c> null is charged less than one that fills it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Index pages count against the cap</b> — deliberately. The four indexes are storage the
|
||||
/// event log causes and owns (measured at ~34 % of the table's total footprint), and the
|
||||
/// 1 GB default was sized against the 393 bytes/row figure that already included them.
|
||||
/// Excluding them would silently raise real disk use by about a third at the same setting.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Clamped by the file.</b> The whole-file logical size is a true upper bound on any one
|
||||
/// table's pages, so the model is capped by it. That can only ever correct an
|
||||
/// over-estimate downward; it cannot reintroduce the row-81 defect, because on a replicated
|
||||
/// node the file is larger than <c>site_events</c>, not smaller, and so never binds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Marked <c>virtual</c> so tests can substitute a degenerate estimate and exercise the
|
||||
/// trim's backstops.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <returns>The current row count and modelled bytes per row.</returns>
|
||||
internal virtual SiteEventsSizeEstimate EstimateSiteEventsSize()
|
||||
{
|
||||
return _eventLogger.WithConnection(connection =>
|
||||
{
|
||||
using var pageCountCmd = connection.CreateCommand();
|
||||
pageCountCmd.CommandText = "PRAGMA page_count";
|
||||
var pageCount = (long)pageCountCmd.ExecuteScalar()!;
|
||||
var rowCount = ReadPragmaLong(connection, "SELECT COUNT(*) FROM site_events");
|
||||
if (rowCount <= 0)
|
||||
return new SiteEventsSizeEstimate(0, 0);
|
||||
|
||||
using var freeListCmd = connection.CreateCommand();
|
||||
freeListCmd.CommandText = "PRAGMA freelist_count";
|
||||
var freeListCount = (long)freeListCmd.ExecuteScalar()!;
|
||||
double avgPayloadBytes;
|
||||
double avgIndexedBytes;
|
||||
using (var sampleCmd = connection.CreateCommand())
|
||||
{
|
||||
// octet_length (SQLite 3.43+) rather than length(), which counts CHARACTERS:
|
||||
// a message with multi-byte UTF-8 would otherwise be under-sized. Pinned by
|
||||
// SqliteCapabilityTests so a provider downgrade fails a test rather than
|
||||
// silently shrinking every estimate.
|
||||
sampleCmd.CommandText = $"""
|
||||
SELECT AVG(payload_bytes), AVG(indexed_bytes) FROM (
|
||||
SELECT payload_bytes, indexed_bytes FROM (
|
||||
SELECT
|
||||
octet_length(id) + octet_length(timestamp) + octet_length(event_type)
|
||||
+ octet_length(severity) + COALESCE(octet_length(instance_id), 0)
|
||||
+ octet_length(source) + octet_length(message)
|
||||
+ COALESCE(octet_length(details), 0) AS payload_bytes,
|
||||
octet_length(timestamp) + octet_length(event_type)
|
||||
+ octet_length(severity) + COALESCE(octet_length(instance_id), 0)
|
||||
AS indexed_bytes
|
||||
FROM site_events LIMIT {SizeSampleRows}
|
||||
)
|
||||
UNION ALL
|
||||
SELECT payload_bytes, indexed_bytes FROM (
|
||||
SELECT
|
||||
octet_length(id) + octet_length(timestamp) + octet_length(event_type)
|
||||
+ octet_length(severity) + COALESCE(octet_length(instance_id), 0)
|
||||
+ octet_length(source) + octet_length(message)
|
||||
+ COALESCE(octet_length(details), 0) AS payload_bytes,
|
||||
octet_length(timestamp) + octet_length(event_type)
|
||||
+ octet_length(severity) + COALESCE(octet_length(instance_id), 0)
|
||||
AS indexed_bytes
|
||||
FROM site_events ORDER BY rowid DESC LIMIT {SizeSampleRows}
|
||||
)
|
||||
)
|
||||
""";
|
||||
using var reader = sampleCmd.ExecuteReader();
|
||||
if (!reader.Read() || reader.IsDBNull(0))
|
||||
return new SiteEventsSizeEstimate(rowCount, 0);
|
||||
|
||||
using var pageSizeCmd = connection.CreateCommand();
|
||||
pageSizeCmd.CommandText = "PRAGMA page_size";
|
||||
var pageSize = (long)pageSizeCmd.ExecuteScalar()!;
|
||||
avgPayloadBytes = reader.GetDouble(0);
|
||||
avgIndexedBytes = reader.IsDBNull(1) ? 0 : reader.GetDouble(1);
|
||||
}
|
||||
|
||||
var usedPages = Math.Max(0, pageCount - freeListCount);
|
||||
return usedPages * pageSize;
|
||||
var pageSize = ReadPragmaLong(connection, "PRAGMA page_size");
|
||||
var usablePerPage = (pageSize - PageReservedBytes) * TablePageFillFactor;
|
||||
var avgCellBytes = avgPayloadBytes + TableCellOverheadBytes;
|
||||
|
||||
// At least one row per page: a row wider than a page spills to overflow pages,
|
||||
// which this floor charges as a whole page each — close enough, and never under.
|
||||
var rowsPerPage = Math.Max(1, (long)(usablePerPage / Math.Max(1, avgCellBytes)));
|
||||
var tableBytes = (long)Math.Ceiling(rowCount / (double)rowsPerPage) * pageSize;
|
||||
|
||||
var indexBytesPerRow =
|
||||
(avgIndexedBytes + (IndexEntryOverheadBytes * IndexedColumnCount)) / IndexPageFillFactor;
|
||||
|
||||
var modelled = tableBytes + (long)(rowCount * indexBytesPerRow);
|
||||
var clamped = Math.Min(modelled, ReadLogicalFileBytes(connection));
|
||||
|
||||
return new SiteEventsSizeEstimate(rowCount, clamped / (double)rowCount);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Live <c>site_events</c> row count.</summary>
|
||||
/// <remarks>
|
||||
/// Re-read after every cap-trim batch so the trim's progress check sees concurrent
|
||||
/// recorder writes, not just its own deletes. <c>virtual</c> for the backstop tests.
|
||||
/// </remarks>
|
||||
/// <returns>Number of rows currently in <c>site_events</c>.</returns>
|
||||
internal virtual long GetSiteEventsRowCount() =>
|
||||
_eventLogger.WithConnection(connection => ReadPragmaLong(connection, "SELECT COUNT(*) FROM site_events"));
|
||||
|
||||
/// <summary>
|
||||
/// One purge tick's shared delete allowance, drawn down by the retention purge and then the
|
||||
/// storage-cap trim.
|
||||
|
||||
@@ -4,7 +4,34 @@ public class SiteEventLogOptions
|
||||
{
|
||||
/// <summary>Number of days to retain site event log entries before purge; default 30.</summary>
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
/// <summary>Maximum SQLite database size in megabytes before old entries are purged; default 1024 MB.</summary>
|
||||
/// <summary>
|
||||
/// Maximum size in megabytes of <c>site_events</c> AND ITS FOUR INDEXES before the oldest
|
||||
/// entries are trimmed; default 1024 MB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is not a cap on the database file</b> (register row 81, decision record
|
||||
/// <c>docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md</c>). <c>site_events</c>
|
||||
/// shares one SQLite file at <c>LocalDb:Path</c> with nine other tables plus LocalDb's
|
||||
/// <c>__localdb_oplog</c> / <c>__localdb_row_version</c> bookkeeping. Measuring the whole file
|
||||
/// made the trim unwinnable on a replication-enabled node — each batch of deletes generated
|
||||
/// an equal number of oplog rows and tombstones in the same file, so the measured size never
|
||||
/// fell and the trim stopped 2.5x over cap. Worse, it made the event log's budget a function
|
||||
/// of the replication peer's health, in the direction where a replication fault destroys
|
||||
/// operational history.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Consequence for sizing a site's data volume: this value alone does not bound it. LocalDb
|
||||
/// replication bookkeeping is separate overhead on the same disk, bounded by
|
||||
/// <c>LocalDb:Replication:MaxOplogRows</c> and <c>TombstoneRetention</c>. Budget the sum.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The footprint is MODELLED from sampled row width rather than measured exactly: SQLite's
|
||||
/// <c>dbstat</c> virtual table would give an exact per-table page count but is not compiled
|
||||
/// into the bundled native library. Measured accuracy is −0.8 % to +12.8 %, deliberately
|
||||
/// biased high. See <c>EventLogPurgeService.EstimateSiteEventsSize</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int MaxStorageMb { get; set; } = 1024;
|
||||
/// <summary>
|
||||
/// MIGRATION-ONLY as of LocalDb Phase 1. <see cref="SiteEventLogger"/> no longer reads
|
||||
|
||||
Reference in New Issue
Block a user