using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging;
///
/// Predicate the consults at the top of every purge
/// tick. As of arch-review 2026-08-19 P4-4 it no longer GATES the purge — DataConnectionActor
/// writes site_events on both site nodes (register rows 89/90/97), so a standby that
/// skipped its own purge would grow its file unboundedly on an unreplicated site. Every node
/// now purges its own file every tick; this predicate is consulted only to annotate the tick's
/// log line with whether this node is the active one.
///
/// Registration is the Host's responsibility (it knows the cluster topology);
/// when no implementation is registered the purge service treats every tick as active for
/// logging purposes — backward compatible with hosts that never register this delegate.
///
/// true if this node is the active site member and should run the purge; false to skip.
public delegate bool SiteEventLogActiveNodeCheck();
///
/// Predicate the consults when a purge actually deleted
/// rows, to decide whether to add the "expect a transient replication backlog" operator note
/// to its purge log line.
///
///
/// site_events is one of LocalDb's replicated tables, and CDC captures a retention
/// DELETE exactly like any other row change — by design, since Phase 2 there is deliberately
/// no purge-exemption path. So on a replication-enabled node the purge briefly inflates the
/// oplog (health field LocalDbOplogBacklog, Prometheus localdb_oplog_depth),
/// which reads like a replication fault to an operator who does not know a purge just ran.
/// The log line is the breadcrumb that ties the two together; it is a message-wording choice
/// only and changes no purge behaviour.
///
///
///
/// Registration is the Host's responsibility — SiteEventLogging has no view of
/// LocalDb:Replication and must not grow one; the Host already owns that predicate
/// (SiteLocalDbSetup.ReplicationIsConfigured, the PeerAddress-OR-ApiKey
/// rule) and passes it in. When no implementation is registered the note is suppressed, which
/// matches the product default: replication is opt-in and off unless configured.
///
///
/// true if this node has LocalDb replication configured; false otherwise.
public delegate bool SiteEventLogReplicationCheck();
///
/// Background service that periodically purges old events from the SQLite event log.
/// Enforces both time-based retention (default 30 days) and storage cap (default 1GB).
/// Runs on a background thread and does not block event recording.
///
public class EventLogPurgeService : BackgroundService
{
/// Number of events deleted per cap-purge batch.
private const int CapPurgeBatchSize = 1000;
/// Number of events deleted per retention-purge batch — same size as .
private const int RetentionPurgeBatchSize = 1000;
///
/// Rows sampled from each end of site_events when sizing an average row. See
/// .
///
private const int SizeSampleRows = 500;
///
/// 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.
///
private const int TableCellOverheadBytes = 16;
///
/// Bytes at the end of a page SQLite will not fill with local payload — the
/// pageSize - 35 maximum-local-payload rule for table leaves.
///
private const int PageReservedBytes = 35;
///
/// 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
/// docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md §3.
///
private const double TablePageFillFactor = 0.78;
/// Number of indexes on site_events (timestamp, event_type, instance_id, severity).
private const int IndexedColumnCount = 4;
/// Per-entry bookkeeping in an index B-tree leaf: key-length varint, rowid and cell pointer.
private const int IndexEntryOverheadBytes = 8;
/// Average fill of an index leaf page; measured alongside .
private const double IndexPageFillFactor = 0.75;
/// Minimum spacing between ceiling-reached log lines; see .
private static readonly TimeSpan CeilingLogInterval = TimeSpan.FromMinutes(15);
///
/// When the ceiling-reached line was last emitted, for the rate limit. Instance state and
/// only ever touched from the single purge loop, so no synchronisation is needed.
///
private DateTimeOffset? _lastCeilingLogUtc;
private readonly SiteEventLogger _eventLogger;
private readonly SiteEventLogOptions _options;
private readonly ILogger _logger;
private readonly SiteEventLogActiveNodeCheck _isActiveNode;
private readonly SiteEventLogReplicationCheck _isReplicationConfigured;
/// Initializes a new instance of .
/// The concrete event logger providing lock-guarded database access.
/// Site event log options (retention days, storage cap, purge interval).
/// Logger instance.
///
/// Optional active-node check, consulted only to annotate the purge tick's log line
/// (arch-review 2026-08-19 P4-4 removed the early-exit this used to drive — every node
/// purges its own site_events file every tick, since DataConnectionActor writes
/// on both site nodes and a standby that skipped its own purge would grow unbounded on
/// an unreplicated site). When null every tick logs as active.
///
///
/// Optional LocalDb-replication check. When null — non-clustered hosts,
/// unit tests — the purge log omits the replication-backlog operator note, matching
/// the product default that replication is opt-in and off unless configured.
/// See .
///
public EventLogPurgeService(
SiteEventLogger eventLogger,
IOptions options,
ILogger logger,
SiteEventLogActiveNodeCheck? isActiveNode = null,
SiteEventLogReplicationCheck? isReplicationConfigured = null)
{
// Depend on the concrete recorder directly: purge must funnel database access
// through its lock-guarded WithConnection. Taking ISiteEventLogger and
// downcasting would throw InvalidCastException for any other implementation.
_eventLogger = eventLogger;
_options = options.Value;
_logger = logger;
_isActiveNode = isActiveNode ?? (static () => true);
_isReplicationConfigured = isReplicationConfigured ?? (static () => false);
}
///
///
/// Deliberately a delay loop rather than a : the wait between
/// ticks is no longer a constant. A tick that stopped at
/// still has expired rows to delete,
/// and waiting a full (a day) before
/// resuming would make a paced purge take longer to converge than the retention window it
/// enforces. Consequence worth knowing: the cadence is now anchored to the END of the
/// previous purge rather than to process start, so a long purge shifts the next tick by its
/// own duration. At the daily interval and a measured ~17 s for a full 250 000-row tick that
/// drift is immaterial.
///
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"Event log purge service started — retention: {Days} days, cap: {Cap} MB, interval: {Interval}, " +
"per-tick delete ceiling: {Ceiling}",
_options.RetentionDays, _options.MaxStorageMb, _options.PurgeInterval,
_options.MaxPurgedRowsPerTick > 0 ? _options.MaxPurgedRowsPerTick.ToString() : "unlimited");
// Run an initial purge on startup
var ceilingHit = RunPurge();
while (!stoppingToken.IsCancellationRequested)
{
var delay = ceilingHit ? _options.PurgeCatchUpInterval : _options.PurgeInterval;
try
{
await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
ceilingHit = RunPurge();
}
}
/// Runs a single purge cycle: first by retention age, then by storage cap.
///
/// true when the tick stopped at
/// with rows still to purge, so the caller should schedule an accelerated catch-up tick.
///
internal bool RunPurge()
{
try
{
// arch-review 2026-08-19 P4-4: purge no longer gates on the active-node check.
// DataConnectionActor writes site_events on BOTH site nodes (register rows
// 89/90/97), so the old "standby receives no writes" premise this gate was
// built on is stale — a standby that never purges its own file grows
// site_events forever (no retention, no cap) on an UNREPLICATED site (the rig's
// site-b/site-c posture). On a REPLICATED pair this was masked because the
// retention/cap DELETEs are CDC-captured and converge under LWW regardless of
// which node issues them (same reasoning as the existing purge-on-replicated
// design — see the replication-backlog note below and CLAUDE.md's LocalDb
// bullet); it is REQUIRED on an unreplicated pair because each node's file is
// independent. Both nodes now run retention + cap trim against their own file
// every tick. isActive is retained only to annotate the log line below (and
// defensively defaults to true if the check itself throws) — it no longer
// skips any work.
bool isActive;
try
{
isActive = _isActiveNode();
}
catch (Exception checkEx)
{
_logger.LogDebug(checkEx,
"Active-node check threw during purge tick; treating as active for logging purposes");
isActive = true;
}
_logger.LogDebug("Running event log purge tick (active={IsActive})", isActive);
var budget = new PurgeBudget(_options.MaxPurgedRowsPerTick);
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;
ScadaBridgeTelemetry.RecordSiteEventLogPurgeCeilingHit();
LogCeilingReached(budget.Deleted);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during event log purge");
// A failed tick must not claim a ceiling hit: the accelerated retry exists to finish
// KNOWN remaining work, and turning every fault into a 5-minute retry loop would
// hammer a broken database far harder than the daily cadence it replaced.
return false;
}
}
///
/// Emits the ceiling-reached breadcrumb, rate-limited to one line per
/// .
///
///
/// Rate-limited because the whole point of the ceiling is that a large backlog produces a RUN
/// of ceiling-limited ticks, one every
/// — an unthrottled line would turn a converging purge into log noise at exactly the moment an
/// operator is reading the log for something else. Warning rather than Information because the
/// state it describes is "retention is NOT yet satisfied on this node", which is worth
/// noticing; the counter
/// (scadabridge.site.event_log.purge_ceiling_hits) carries every hit for the series that
/// distinguishes converging from never-converging.
///
/// Rows the ceiling-limited tick deleted.
private void LogCeilingReached(int deletedThisTick)
{
var now = DateTimeOffset.UtcNow;
if (_lastCeilingLogUtc is { } last && now - last < CeilingLogInterval)
return;
_lastCeilingLogUtc = now;
_logger.LogWarning(
"Event log purge stopped at its per-tick ceiling of {Ceiling} rows ({Deleted} deleted); " +
"expired events remain and the next purge runs in {CatchUp} instead of {Interval}. " +
"The ceiling exists so one tick's CDC oplog burst stays well inside LocalDb's " +
"MaxOplogRows budget on a replicated node; set ScadaBridge:SiteEventLog:MaxPurgedRowsPerTick " +
"to 0 to restore unbounded purges.",
_options.MaxPurgedRowsPerTick, deletedThisTick,
_options.PurgeCatchUpInterval, _options.PurgeInterval);
}
///
/// Deletes events older than the retention window, in bounded
/// -row slices per DELETE statement rather than
/// one unbounded DELETE for the whole expired set — mirroring the batching already
/// used a few lines below in ().
/// An expired backlog in the tens/hundreds of thousands of rows would otherwise hold a
/// single SQLite write transaction (and the shared _writeLock) open for the
/// whole delete, blocking every concurrent
/// writer-loop flush for that entire duration.
///
/// The tick's shared delete budget; slices are drawn from it.
private void PurgeByRetention(PurgeBudget budget)
{
var cutoff = DateTimeOffset.UtcNow.AddDays(-_options.RetentionDays).ToString("o");
var totalDeleted = 0;
while (true)
{
// The slice is the smaller of the statement batch and what the tick has left, so the
// ceiling is enforced exactly rather than overshooting by up to one batch.
var slice = budget.NextSlice(RetentionPurgeBatchSize);
if (slice == 0)
{
// Only a ceiling hit if there is genuinely more to purge. An expired set that
// landed exactly on the budget is FINISHED, and claiming otherwise would arm a
// 5-minute catch-up tick that deletes nothing and logs that retention is behind.
budget.StoppedAtCeiling = ExpiredRowsRemain(cutoff);
break;
}
var deleted = _eventLogger.WithConnection(connection =>
{
using var cmd = connection.CreateCommand();
cmd.CommandText = $"""
DELETE FROM site_events WHERE id IN (
SELECT id FROM site_events
WHERE timestamp < $cutoff
LIMIT {slice}
)
""";
cmd.Parameters.AddWithValue("$cutoff", cutoff);
return cmd.ExecuteNonQuery();
});
totalDeleted += deleted;
budget.Record(deleted);
// Compared against the slice actually REQUESTED, not the constant: a short final
// slice that came back full still means "there may be more", and a full-size slice
// that came back short means the expired set is exhausted.
if (deleted < slice)
break;
}
if (totalDeleted > 0)
_logger.LogInformation("Purged {Count} events older than {Days} days", totalDeleted, _options.RetentionDays);
}
///
/// Whether at least one event older than is still present.
///
///
/// An existence probe rather than a COUNT(*): the answer needed is boolean, and on a
/// node working through a multi-million-row backlog a count would walk the whole expired set
/// once per ceiling-limited tick to produce a number nothing reads. idx_events_timestamp
/// makes this one index seek.
///
/// The retention cutoff, ISO-8601 round-trip formatted.
/// true if expired rows remain.
private bool ExpiredRowsRemain(string cutoff) =>
_eventLogger.WithConnection(connection =>
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT EXISTS(SELECT 1 FROM site_events WHERE timestamp < $cutoff)";
cmd.Parameters.AddWithValue("$cutoff", cutoff);
return Convert.ToInt64(cmd.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture) != 0;
});
///
/// Emits the operator breadcrumb that explains the oplog backlog spike a purge produces on a
/// replication-enabled node — once per purge tick. No-op when this node has no
/// replication peer.
///
///
///
/// Called from 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.
///
///
/// site_events 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
/// deleted event, and the site health report's LocalDbOplogBacklog (Prometheus
/// localdb_oplog_depth) spikes until the peer acks them. That is expected and drains
/// 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.
///
///
///
/// Rows deleted by the WHOLE tick — retention purge and cap trim combined, read off the
/// shared . Always > 0 at the single call site.
///
private void LogReplicationBacklogNote(int deletedRows)
{
bool replicated;
try
{
replicated = _isReplicationConfigured();
}
catch (Exception checkEx)
{
// A log-wording predicate must never break the purge loop.
_logger.LogDebug(checkEx, "Replication check threw while composing the purge log note; note suppressed");
return;
}
if (!replicated)
return;
_logger.LogInformation(
"Purged {Count} site_events rows on a replication-enabled node — a transient LocalDb oplog backlog " +
"of up to that many rows is expected while the deletes replicate to the peer. It drains on its own; " +
"LocalDbReplicationConnected staying true with the backlog returning to ~0 is the healthy signature. " +
"A tick deletes at most {Ceiling} rows, so the spike is bounded by that number rather than by how " +
"much history had expired.",
deletedRows,
_options.MaxPurgedRowsPerTick > 0 ? _options.MaxPurgedRowsPerTick.ToString() : "unlimited");
}
///
/// Trims the oldest events until site_events' own footprint is back under
/// .
///
///
///
/// The cap is table-scoped as of register row 81. It used to compare
/// 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
/// — site_events and its four indexes only —
/// so replication bookkeeping can no longer consume, or block, the event log's budget.
///
///
/// 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.
///
///
/// The tick's shared delete budget, already drawn down by the retention purge.
private void PurgeByStorageCap(PurgeBudget budget)
{
var capBytes = (long)_options.MaxStorageMb * 1024 * 1024;
var estimate = EstimateSiteEventsSize();
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
// "purging oldest events" and then deleting nothing. Ordered AFTER the size probe so a
// budget spent by the retention purge on an under-cap database is not mistaken for
// outstanding cap work. The cap is re-evaluated from scratch on the catch-up tick.
if (budget.NextSlice(CapPurgeBatchSize) == 0)
{
budget.StoppedAtCeiling = true;
return;
}
_logger.LogWarning(
"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 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)
{
var slice = budget.NextSlice(CapPurgeBatchSize);
if (slice == 0)
{
budget.StoppedAtCeiling = true;
break;
}
var previousSizeBytes = currentSizeBytes;
var deleted = _eventLogger.WithConnection(connection =>
{
using var cmd = connection.CreateCommand();
// Order by timestamp, NOT by id. The id was a monotonic autoincrement
// when this was written, so "lowest id" meant "oldest"; since LocalDb
// Phase 1 it is a GUID and ordering by it would delete an ARBITRARY
// batch of events rather than the oldest ones. id remains in the ORDER
// BY only as a tie-break so the batch is deterministic.
cmd.CommandText = $"""
DELETE FROM site_events WHERE id IN (
SELECT id FROM site_events
ORDER BY timestamp ASC, id ASC
LIMIT {slice}
)
""";
var rows = cmd.ExecuteNonQuery();
// Reclaim free pages so page_count/freelist measurement reflects the
// delete. Effective because auto_vacuum = INCREMENTAL is set at schema
// creation; harmless otherwise.
using var vacuumCmd = connection.CreateCommand();
vacuumCmd.CommandText = "PRAGMA incremental_vacuum";
vacuumCmd.ExecuteNonQuery();
return rows;
});
if (deleted == 0)
break;
totalDeleted += deleted;
budget.Record(deleted);
// 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)
{
_logger.LogWarning(
"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;
}
}
}
///
/// Returns the logical size of the WHOLE consolidated database file in bytes — only pages
/// that hold live data, excluding free pages on the freelist.
///
///
///
/// This is no longer what the storage cap measures (register row 81,
/// docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md). Since LocalDb
/// Phase 1/2 the file holds ten tables plus __localdb_oplog,
/// __localdb_row_version and __localdb_snapshot_state, so a whole-file
/// number is not site_events' 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.
///
///
/// It survives for two narrow jobs: it is a true UPPER BOUND on site_events' own
/// pages, which uses to clamp an over-eager model,
/// and it remains a useful diagnostic for the file as a whole.
///
///
/// The logical size of the whole database file in bytes (used pages × page size).
internal long GetDatabaseSizeBytes()
{
return _eventLogger.WithConnection(connection => ReadLogicalFileBytes(connection));
}
/// Logical size of the whole file, on an already-held connection.
/// An open connection to the consolidated database.
/// Used pages × page size, in bytes.
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;
}
/// Reads a single-value PRAGMA as a 64-bit integer.
/// An open connection.
/// The PRAGMA statement to run.
/// The PRAGMA's value.
private static long ReadPragmaLong(SqliteConnection connection, string pragma)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = pragma;
return Convert.ToInt64(cmd.ExecuteScalar(), CultureInfo.InvariantCulture);
}
///
/// How many rows site_events currently holds, and how many bytes of the file one
/// average row occupies — the two numbers the storage cap is expressed in.
///
/// Live row count at the time of the estimate.
///
/// Modelled file bytes per row, table pages plus index pages. 0 when the table is
/// empty or the sample could not be sized, which the cap trim treats as "do not trim".
///
internal readonly record struct SiteEventsSizeEstimate(long RowCount, double BytesPerRow)
{
/// Modelled total bytes site_events occupies in the file.
public long EstimatedBytes => (long)(RowCount * BytesPerRow);
}
///
/// Estimates how much of the consolidated file site_events — and only
/// site_events — occupies, by sampling average row width and modelling SQLite's
/// page layout.
///
///
///
/// Why a model and not a measurement. SQLite can report per-table page usage
/// exactly through the dbstat virtual table, and that was the intended fix for
/// register row 81 — but dbstat is a compile-time option and the
/// SQLitePCLRaw.lib.e_sqlite3 native build this solution ships does NOT enable it.
/// Probed directly (SqliteCapabilityTests): PRAGMA compile_options on
/// SQLite 3.53.3 lists no ENABLE_DBSTAT_VTAB, pragma_module_list returns ten
/// modules (fts3/4/5, rtree, geopoly) with neither dbstat nor sqlite_dbpage
/// among them, and selecting from dbstat 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
/// docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md.
///
///
/// The model. Rows are sampled from both ends of the table (
/// 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 —
/// ceil(rows / rowsPerPage) × pageSize — 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 instance_id null is charged less than one that fills it.
///
///
/// Index pages count against the cap — 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.
///
///
/// Clamped by the file. 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 site_events, not smaller, and so never binds.
///
///
/// Marked virtual so tests can substitute a degenerate estimate and exercise the
/// trim's backstops.
///
///
/// The current row count and modelled bytes per row.
internal virtual SiteEventsSizeEstimate EstimateSiteEventsSize()
{
return _eventLogger.WithConnection(connection =>
{
var rowCount = ReadPragmaLong(connection, "SELECT COUNT(*) FROM site_events");
if (rowCount <= 0)
return new SiteEventsSizeEstimate(0, 0);
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);
avgPayloadBytes = reader.GetDouble(0);
avgIndexedBytes = reader.IsDBNull(1) ? 0 : reader.GetDouble(1);
}
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);
});
}
/// Live site_events row count.
///
/// Re-read after every cap-trim batch so the trim's progress check sees concurrent
/// recorder writes, not just its own deletes. virtual for the backstop tests.
///
/// Number of rows currently in site_events.
internal virtual long GetSiteEventsRowCount() =>
_eventLogger.WithConnection(connection => ReadPragmaLong(connection, "SELECT COUNT(*) FROM site_events"));
///
/// One purge tick's shared delete allowance, drawn down by the retention purge and then the
/// storage-cap trim.
///
///
/// Shared rather than per-phase on purpose: what the ceiling bounds is the CDC oplog burst a
/// TICK produces on a replicated node, and two independently-capped phases would let one tick
/// emit twice the intended maximum. The cap trim therefore runs on whatever the retention
/// purge left, and picks up on the accelerated catch-up tick if that was nothing.
///
/// Rows this tick may delete; 0 or less means unlimited.
private sealed class PurgeBudget(int ceiling)
{
private readonly bool _unlimited = ceiling <= 0;
/// Rows deleted so far this tick.
public int Deleted { get; private set; }
///
/// Set when a phase ran out of budget with work still to do — the signal that the caller
/// should schedule an accelerated catch-up tick. Never set when the ceiling is unlimited.
///
public bool StoppedAtCeiling { get; set; }
///
/// The number of rows the next DELETE statement may take: ,
/// or what remains of the budget if that is smaller. 0 means the budget is spent.
///
/// The phase's own statement batch size.
/// Rows the next statement may delete.
public int NextSlice(int batchSize) =>
_unlimited ? batchSize : Math.Max(0, Math.Min(batchSize, ceiling - Deleted));
/// Charges against the budget.
/// Rows the statement actually deleted.
public void Record(int rows) => Deleted += rows;
}
}