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; } }