From fbbda717edd88b85d0cbd43e227c079db017c719 Mon Sep 17 00:00:00 2001 From: MPCoreDeveloper Date: Thu, 3 Sep 2026 20:18:00 +0200 Subject: [PATCH 1/5] perf: apply DELETE tombstones at COMMIT for transactional deletes (no flush rewrite) Stap-0 profiling showed batch DELETE (ExecuteBatchSQL wraps every batch in a storage transaction) was NOT using the tombstone path: the rollback-safe deferral only counted _pendingLogicalDeletes, so db.Flush() still ran the #366 full-file CompactPendingDeletes rewrite (~0.5-0.7s; DELETE stuck at ~12-16K ops/s). - IStorage.BufferTombstoneForCommit (default no-op) + Storage implementation: buffered offsets per file, applied as in-place negative-prefix markers by ApplyBufferedTombstones() inside FlushBufferedAppendsAndOverwrites() (the commit path) AFTER buffered appends are on disk; rollback discards the buffer via ClearBufferedAppends(). - DeleteRecordsCore + the contiguous FW bulk path now buffer the deleted offsets when IsInTransaction instead of incrementing _pendingLogicalDeletes, so the flush-time full-file rewrite is off the batch-DELETE path entirely. - Regression: batch DELETE survives reopen even WITHOUT an explicit Flush after ExecuteBatchSQL (commit already tombstoned the rows). Measured (same machine, Release, comparative harness DELETE 10K of ~100K rows): SQL 0.82s/12K -> 0.24s/41K ops/s, Direct 0.63s/16K -> 0.17s/58K ops/s; --pk legacy 0.16s/62K, fixed-width 0.13s/78K ops/s. Full suite EXIT=0. --- docs/CHANGELOG.md | 24 ++++--- src/SharpCoreDB/DataStructures/Table.CRUD.cs | 27 +++++--- src/SharpCoreDB/Interfaces/IStorage.cs | 10 +++ src/SharpCoreDB/Services/Storage.Append.cs | 69 +++++++++++++++++++ .../FixedWidthBulkDeleteTests.cs | 24 ++++++- 5 files changed, 134 insertions(+), 20 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8f0003ce..2046828d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -42,17 +42,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 overflow arena is reclaimed on the next explicit VACUUM/compaction. Regression: delete half the rows, `Flush`, reopen ÔÇö exactly the remaining rows come back. Measured DELETE in the `--pk` harness now includes this durability rewrite (~18.6K ops/s when deleting 10K of 100K rows). -- **Durable DELETE is now in-place via tombstones (no rewrite)** ÔÇö non-transactional Columnar - deletes write a tombstone marker (the record's 4-byte length prefix is replaced by the NEGATIVE - slot size) instead of queueing a flush-time rewrite, and every raw record enumerator/compactor - skips the slot, so DELETE survives a reopen in O(delete). Flush/dispose compaction now only - covers transactional deletes (a marker written before commit would survive a rollback that - restores the row in the PK index; those deletes are compacted against the current PK after commit - ÔÇö rollback-safe). Tombstoned space is reclaimed by the tombstone-aware `CompactTable`, including - the ULID-migration compaction (which previously produced an empty file when tombstones were - present because `CompactTable` broke on a negative prefix). Measured `--pk` DELETE (10K of 100K - rows): ~0.54s/18.6K ops/s (flush rewrite) ÔåÆ **~0.16s/~64K ops/s (legacy)** and - **~0.12s/~81K ops/s (fixed-width)** ÔÇö DELETE is back on par with UPDATE. +- **Durable DELETE is now in-place via tombstones (no rewrite)** ÔÇö Columnar deletes write a + tombstone marker (the record's 4-byte length prefix is replaced by the NEGATIVE slot size) + instead of queueing a flush-time rewrite, and every raw record enumerator/compactor skips the + slot, so DELETE survives a reopen in O(delete). Non-transactional deletes write the marker at + delete time; transactional deletes (e.g. any `ExecuteBatchSQL` batch, which runs inside a storage + transaction) buffer the offsets and apply the markers at COMMIT ÔÇö rollback discards the buffer, + so a rolled-back delete keeps its row, and the flush-time full-file rewrite (`CompactPendingDeletes`) + is no longer on the batch-DELETE path. Tombstoned space is reclaimed by the tombstone-aware + `CompactTable`, including the ULID-migration compaction (which previously produced an empty file + when tombstones were present because `CompactTable` broke on a negative prefix). Measured `--pk` + DELETE (10K of 100K rows): ~0.54s/18.6K ops/s (flush rewrite) ÔåÆ **~0.16s/~64K ops/s (legacy)** and + **~0.13s/~78K ops/s (fixed-width)**; comparative-harness DELETE (docs table): SQL ~0.82s/~12K ÔåÆ + **~0.24s/~41K ops/s**, Direct ~0.63s/~16K ÔåÆ **~0.17s/~58K ops/s** ÔÇö DELETE is back on par with UPDATE. - **Dedicated SQL batch-INSERT fast path (WP14)** ÔÇö `ExecuteBatchSQL` INSERTs no longer build a per-row `Dictionary`; VALUES clauses are parsed directly into column-ordered `object[]` rows (`PreparedInsertStatement.ParseValuesToArray`) and inserted via the new diff --git a/src/SharpCoreDB/DataStructures/Table.CRUD.cs b/src/SharpCoreDB/DataStructures/Table.CRUD.cs index 24c44b62..73929778 100644 --- a/src/SharpCoreDB/DataStructures/Table.CRUD.cs +++ b/src/SharpCoreDB/DataStructures/Table.CRUD.cs @@ -2655,11 +2655,16 @@ private bool HasExplicitNamedIndex(string column) if (this.storage is { IsInTransaction: true }) { - // Transactional delete: writing the tombstone now would survive a rollback that - // restores the row in the PK index, so defer the physical removal to the post-commit - // flush compaction (rollback-safe: that rewrite is driven by the CURRENT PK, which - // still contains the row after a rollback). - Interlocked.Add(ref _pendingLogicalDeletes, recordsToDelete.Count); + // Transactional delete: buffer the physical offsets so the in-place marker is + // applied at COMMIT (rollback discards the buffer). Durable in O(delete) — the + // flush-time full-file rewrite is no longer needed for transactional deletes. + foreach (var position in positions) + { + if (position >= 0) + { + this.storage.BufferTombstoneForCommit(DataFile, position); + } + } } else { @@ -3214,9 +3219,15 @@ this.storage is null || if (this.storage is { IsInTransaction: true }) { - // Transactional delete: defer physical removal to the post-commit flush compaction - // (see DeleteRecordsCore — a tombstone would survive a rollback that restores the row). - Interlocked.Add(ref _pendingLogicalDeletes, count); + // Transactional delete: buffer the physical offsets so the in-place marker is applied + // at COMMIT (see DeleteRecordsCore — rollback discards the buffer). + foreach (var position in positions) + { + if (position >= 0) + { + this.storage.BufferTombstoneForCommit(DataFile, position); + } + } } else { diff --git a/src/SharpCoreDB/Interfaces/IStorage.cs b/src/SharpCoreDB/Interfaces/IStorage.cs index 3aecaf81..3e519e36 100644 --- a/src/SharpCoreDB/Interfaces/IStorage.cs +++ b/src/SharpCoreDB/Interfaces/IStorage.cs @@ -179,6 +179,16 @@ bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) => /// bool TombstoneRecord(string path, long offset) => false; + /// + /// Registers a record position to be tombstoned when the CURRENT transaction commits. Deletes + /// issued inside a transaction must not write the marker at delete time (it would survive a + /// rollback that restores the row), so callers buffer the physical offset here and the storage + /// layer applies the in-place marker once the owning transaction commits — durable in O(delete) + /// without the flush-time full-file rewrite. On rollback the buffered offsets are discarded. + /// The default is a no-op (unsupported layout / mock storage). + /// + void BufferTombstoneForCommit(string path, long offset) { } + /// /// Enumerates every record in a table data file, yielding the literal file offset of the /// 4-byte length prefix (the offset returned by ) together with the diff --git a/src/SharpCoreDB/Services/Storage.Append.cs b/src/SharpCoreDB/Services/Storage.Append.cs index 4693d029..a0ac204b 100644 --- a/src/SharpCoreDB/Services/Storage.Append.cs +++ b/src/SharpCoreDB/Services/Storage.Append.cs @@ -49,6 +49,12 @@ public partial class Storage // append because OverwriteRecordAt refused to write inside a transaction. private readonly ConcurrentDictionary> bufferedOverwrites = new(StringComparer.Ordinal); + // ✅ Commit-time tombstones: physical offsets of records deleted inside the current + // transaction. The marker is NOT written at delete time (a rollback must keep the row); + // ApplyBufferedTombstones writes the in-place negative-prefix markers when the transaction + // commits, after the buffered appends are on disk. Rollback discards the buffer. + private readonly Dictionary> bufferedTombstones = new(StringComparer.Ordinal); + // Base file length captured at the first buffered operation of the transaction. In-place // overwrites are only safe below this boundary (records already flushed to disk); offsets // at or above it belong to still-buffered appends and must fall back to append. @@ -563,6 +569,67 @@ public bool HasBufferedOverwrite(string path) => /// public bool AreRecordsEncrypted(string path) => UseRecordEncryption && FileHasEncryptedHeader(path); + /// + public void BufferTombstoneForCommit(string path, long offset) + { + if (offset < 0) + { + return; + } + + lock (appendLock) + { + if (!bufferedTombstones.TryGetValue(path, out var list)) + { + list = new List(); + bufferedTombstones[path] = list; + } + + list.Add(offset); + } + } + + /// + /// Applies every buffered commit-time tombstone as an in-place negative-prefix marker. Runs + /// from (the commit path) AFTER the buffered + /// appends are on disk, so offsets of rows that were appended AND deleted in the same + /// transaction are valid. Rollback discards the buffer instead (). + /// + private void ApplyBufferedTombstones() + { + if (bufferedTombstones.Count == 0) + { + return; + } + + foreach (var (path, offsets) in bufferedTombstones) + { + if (offsets.Count == 0) + { + continue; + } + + try + { + long fileLength = File.Exists(path) ? new FileInfo(path).Length : 0; + foreach (var offset in offsets) + { + if (offset >= 0 && offset + 4 <= fileLength) + { + TombstoneRecord(path, offset); + } + } + } + catch (IOException) + { + // Best-effort: a failed marker leaves the row physical; the auto/explicit + // compaction reclaims it later. + } + } + + bufferedTombstones.Clear(); + } + /// public bool TombstoneRecord(string path, long offset) { @@ -746,6 +813,7 @@ internal void FlushBufferedAppendsAndOverwrites() { FlushBufferedAppends(); FlushBufferedOverwrites(); + ApplyBufferedTombstones(); } } @@ -822,6 +890,7 @@ internal void ClearBufferedAppends() cachedFileLengths.Clear(); // ✅ Clear cache too headerPendingFiles.Clear(); // ✅ Clear pending header markers on rollback bufferedFileBaseLengths.Clear(); + bufferedTombstones.Clear(); // Rollback: discard pending commit-time tombstones } } diff --git a/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs b/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs index 2ae0b69f..aff57eeb 100644 --- a/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs +++ b/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs @@ -218,7 +218,7 @@ public void DeletesPersistAcrossReopen_AfterFlushCompaction() } db.ExecuteBatchSQL(stmts); - db.Flush(); // flush-time compaction must physically remove the deleted rows + db.Flush(); // commit-time tombstones (or legacy flush compaction) remove the rows physically (db as IDisposable)?.Dispose(); db = CreateDb(); @@ -230,4 +230,26 @@ public void DeletesPersistAcrossReopen_AfterFlushCompaction() Assert.Equal(50L, Convert.ToInt64(c[0].Values.First())); (db as IDisposable)?.Dispose(); } + + [Fact] + public void BatchDelete_CommitTimeTombstones_SurviveReopenWithoutExplicitFlush() + { + // ExecuteBatchSQL wraps the whole DELETE batch in one storage transaction. The deleted + // positions must be tombstoned AT COMMIT (not at a later db.Flush()), so durability must + // hold even when the database is disposed without an explicit Flush afterwards. + IDatabase? db = CreateDb(); + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + InsertDocs(db, 1, 200); + db.Flush(); + + db.ExecuteBatchSQL(BuildDeletes(1, 100)); // commits inside ExecuteBatchSQL + (db as IDisposable)?.Dispose(); // no explicit Flush after the delete batch + + db = CreateDb(); + var scan = db.ExecuteQuery("SELECT id FROM docs ORDER BY id"); + Assert.Equal(100, scan.Count); + Assert.Equal(101L, Convert.ToInt64(scan[0]["id"])); + Assert.Equal(200L, Convert.ToInt64(scan[^1]["id"])); + (db as IDisposable)?.Dispose(); + } } From a862757ab3397838faa4533c86bc51a69b016f69 Mon Sep 17 00:00:00 2001 From: MPCoreDeveloper Date: Thu, 3 Sep 2026 20:28:55 +0200 Subject: [PATCH 2/5] perf: batch tombstone writes with per-page evict de-duplication (C4) - IStorage.TombstoneRecords (default loops TombstoneRecord); Storage batches the in-place negative-prefix markers over one cached read handle and evicts each affected page-cache page once instead of once per row. - ApplyBufferedTombstones (commit path) and Table.TombstoneDeletedPositions (direct non-transactional deletes) route through the batch API; offsets that are not physical records (EOF/already-marked) are skipped safely. --- .../DataStructures/Table.Compaction.cs | 13 +--- src/SharpCoreDB/Interfaces/IStorage.cs | 19 +++++ src/SharpCoreDB/Services/Storage.Append.cs | 71 ++++++++++++++----- 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/src/SharpCoreDB/DataStructures/Table.Compaction.cs b/src/SharpCoreDB/DataStructures/Table.Compaction.cs index e3a49518..c138ccb8 100644 --- a/src/SharpCoreDB/DataStructures/Table.Compaction.cs +++ b/src/SharpCoreDB/DataStructures/Table.Compaction.cs @@ -8,7 +8,6 @@ namespace SharpCoreDB.DataStructures; using SharpCoreDB.Storage.Engines; using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; @@ -113,7 +112,8 @@ public void CompactPendingDeletes() /// present in the data file, making the Columnar logical delete durable across reopen (readers /// skip the marker) without rewriting the remaining rows. Rows appended inside an uncommitted /// transaction (positions beyond the current file length) are skipped — their delete commits - /// with the rest of the transaction. + /// with the rest of the transaction. The storage layer batches the in-place marker writes and + /// de-duplicates page-cache evictions per page. /// private void TombstoneDeletedPositions(long[] positions) { @@ -122,14 +122,7 @@ private void TombstoneDeletedPositions(long[] positions) return; } - long fileLength = File.Exists(DataFile) ? new FileInfo(DataFile).Length : 0; - foreach (var position in positions) - { - if (position >= 0 && position + 4 <= fileLength) - { - this.storage.TombstoneRecord(DataFile, position); - } - } + this.storage.TombstoneRecords(DataFile, positions); } /// diff --git a/src/SharpCoreDB/Interfaces/IStorage.cs b/src/SharpCoreDB/Interfaces/IStorage.cs index 3e519e36..dd317aa7 100644 --- a/src/SharpCoreDB/Interfaces/IStorage.cs +++ b/src/SharpCoreDB/Interfaces/IStorage.cs @@ -189,6 +189,25 @@ bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) => /// void BufferTombstoneForCommit(string path, long offset) { } + /// + /// Bulk : marks every listed record offset as deleted. The default + /// implementation applies per offset (for alternative/mock + /// implementations); the real storage backend batches the in-place + /// marker writes and de-duplicates page-cache evictions per page. + /// + void TombstoneRecords(string path, long[] offsets) + { + if (offsets is null) + { + return; + } + + foreach (var offset in offsets) + { + TombstoneRecord(path, offset); + } + } + /// /// Enumerates every record in a table data file, yielding the literal file offset of the /// 4-byte length prefix (the offset returned by ) together with the diff --git a/src/SharpCoreDB/Services/Storage.Append.cs b/src/SharpCoreDB/Services/Storage.Append.cs index a0ac204b..576cea8f 100644 --- a/src/SharpCoreDB/Services/Storage.Append.cs +++ b/src/SharpCoreDB/Services/Storage.Append.cs @@ -609,22 +609,7 @@ private void ApplyBufferedTombstones() continue; } - try - { - long fileLength = File.Exists(path) ? new FileInfo(path).Length : 0; - foreach (var offset in offsets) - { - if (offset >= 0 && offset + 4 <= fileLength) - { - TombstoneRecord(path, offset); - } - } - } - catch (IOException) - { - // Best-effort: a failed marker leaves the row physical; the auto/explicit - // compaction reclaims it later. - } + TombstoneRecords(path, offsets.ToArray()); } bufferedTombstones.Clear(); @@ -680,6 +665,60 @@ public bool TombstoneRecord(string path, long offset) return true; } + /// + public void TombstoneRecords(string path, long[] offsets) + { + if (offsets == null || offsets.Length == 0) + { + return; + } + + HashSet? pagesToEvict = this.pageCache != null ? new HashSet() : null; + + try + { + SafeFileHandle readHandle = GetOrOpenReadHandle(path); + Span lengthBuffer = stackalloc byte[4]; + Span marker = stackalloc byte[4]; + + foreach (var offset in offsets) + { + if (offset < 0) + { + continue; + } + + if (RandomAccess.Read(readHandle, lengthBuffer, offset) != 4) + { + continue; // offset at/beyond EOF — not a physical record + } + + int currentLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer); + if (currentLength <= 0) + { + continue; // already tombstoned or invalid + } + + BinaryPrimitives.WriteInt32LittleEndian(marker, -(4 + currentLength)); + WriteRecordInPlace(path, offset, marker, ReadOnlySpan.Empty); + + pagesToEvict?.Add(ComputePageId(path, offset)); + } + } + catch (IOException) + { + return; + } + + if (pagesToEvict != null) + { + foreach (var pageId in pagesToEvict) + { + this.pageCache!.EvictPage(pageId); + } + } + } + /// [MethodImpl(MethodImplOptions.AggressiveOptimization)] public long[] AppendBytesMultiple(string path, List dataBlocks) From 78d21ea1e2871e46e640bc42436553914da1d32c Mon Sep 17 00:00:00 2001 From: MPCoreDeveloper Date: Thu, 3 Sep 2026 20:29:01 +0200 Subject: [PATCH 3/5] perf: structured batch DELETE without per-statement WHERE rebuild/re-parse (B3) - TryParseDeleteForBatch now also returns the canonical WHERE column + raw literal; ExecuteBatchSQL groups (where, column, literal) instead of plain WHERE strings. - Table.DeleteMultipleKeys mirrors DeleteMultiple (contiguous-FW gate -> PK fast path -> hash fast path -> generic fallback) but resolves keys directly from the pre-parsed column/literal; the 'col = literal' string and the second TryParseSimpleWhereClause pass are built only when a generic fallback actually runs. - Non-canonical statements keep the string path (mixed tables rebuild on the rare path). --- src/SharpCoreDB/DataStructures/Table.CRUD.cs | 154 ++++++++++++++++++ .../Database/Execution/Database.Batch.cs | 52 +++++- 2 files changed, 199 insertions(+), 7 deletions(-) diff --git a/src/SharpCoreDB/DataStructures/Table.CRUD.cs b/src/SharpCoreDB/DataStructures/Table.CRUD.cs index 73929778..60e56eea 100644 --- a/src/SharpCoreDB/DataStructures/Table.CRUD.cs +++ b/src/SharpCoreDB/DataStructures/Table.CRUD.cs @@ -3021,6 +3021,160 @@ internal void DeleteMultiple(List whereConditions) } } + /// + /// Structured variant of used by the SQL batch dispatcher for + /// canonical single-row DELETE statements (`DELETE FROM t WHERE col = literal`, as detected by + /// the canonical-DML scanner). The WHERE column and raw literal are already known, so the + /// per-statement `col = literal` string rebuild and the second + /// pass are skipped on the hot path. Semantics mirror exactly + /// (PK fast path → hash-index fast path → generic fallback with a lazily rebuilt WHERE string, + /// only reached for unusual shapes). + /// + /// Column name and RAW literal value (quotes as written in SQL). + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + internal void DeleteMultipleKeys(List<(string Column, string Literal)> conditions) + { + if (this.isReadOnly) throw new InvalidOperationException(ReadOnlyDeleteError); + if (conditions.Count == 0) return; + + this.rwLock.EnterWriteLock(); + try + { + var engine = GetOrCreateStorageEngine(); + EnsureAllRegisteredIndexesLoaded(); + + // B9 single-pass contiguous DELETE consumes WHERE strings; attempt it (without touching + // anything) only when every condition is a literal on the PK column, like the caller. + if (this.PrimaryKeyIndex >= 0 && AllPkLiteralConditions(conditions)) + { + var wheres = new List(conditions.Count); + foreach (var (col, literal) in conditions) + { + wheres.Add(col + " = " + literal); + } + + if (TryBulkDeleteContiguousFixedWidth(wheres)) + { + return; + } + } + + var recordsToDelete = new List<(long storagePosition, Dictionary row)>(); + + foreach (var (col, literal) in conditions) + { + string value = UnquoteSqlLiteral(literal); + + // Issue #7 PK fast path (structured: no WHERE-string re-parse). + if (StorageMode != StorageMode.PageBased && + this.PrimaryKeyIndex >= 0 && + string.Equals(col, this.Columns[this.PrimaryKeyIndex], StringComparison.OrdinalIgnoreCase)) + { + var fastSearch = this.Index.Search(value); + if (fastSearch.Found) + { + var fastData = engine.Read(Name, fastSearch.Value); + if (fastData != null) + { + var fastRow = DeserializeRowFromSpan(fastData); + if (fastRow != null) + { + recordsToDelete.Add((fastSearch.Value, fastRow)); + } + } + + continue; + } + } + // Hash index fast path. + if (this.registeredIndexes.ContainsKey(col)) + { + EnsureIndexLoaded(col); + if (this.hashIndexes.TryGetValue(col, out var hashIndex)) + { + var colIdx = this.Columns.IndexOf(col); + if (colIdx >= 0) + { + var key = ParseValueForHashLookup(value, this.ColumnTypes[colIdx]); + if (key != null) + { + foreach (var pos in hashIndex.LookupPositionsUnsafe(key)) + { + var data = engine.Read(Name, pos); + if (data != null) + { + var row = DeserializeRowFromSpan(data); + if (row != null) recordsToDelete.Add((pos, row)); + } + } + + continue; + } + } + } + } + + // Generic fallback (rare for canonical shapes): rebuild the WHERE string exactly as + // the caller would have and mirror DeleteMultiple. + string where = col + " = " + literal; + if (this.PrimaryKeyIndex >= 0) + { + var rows = SelectInternal(where, orderBy: null, asc: true, noEncrypt: false); + var pkCol = this.Columns[this.PrimaryKeyIndex]; + foreach (var row in rows) + { + if (row.TryGetValue(pkCol, out var pkValue) && pkValue != null) + { + var searchResult = this.Index.Search(pkValue.ToString() ?? string.Empty); + if (searchResult.Found) + recordsToDelete.Add((searchResult.Value, row)); + } + } + } + else + { + foreach (var (storageRef, data) in engine.GetAllRecords(Name)) + { + var row = DeserializeRowFromSpan(data); + if (row != null && (string.IsNullOrEmpty(where) || EvaluateSimpleWhere(row, where))) + recordsToDelete.Add((storageRef, row)); + } + } + } + + if (recordsToDelete.Count == 0) return; + + DeleteRecordsCore(recordsToDelete); + } + finally + { + this.rwLock.ExitWriteLock(); + } + } + + private bool AllPkLiteralConditions(List<(string Column, string Literal)> conditions) + { + var pkCol = this.Columns[this.PrimaryKeyIndex]; + foreach (var (col, _) in conditions) + { + if (!string.Equals(col, pkCol, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + /// + /// Mirrors the value normalization of (trim whitespace, + /// then trim enclosing '/" quotes) on a raw SQL literal. + /// + private static string UnquoteSqlLiteral(string raw) + { + return raw.AsSpan().Trim().Trim("'\"".AsSpan()).ToString(); + } + /// /// Shared B8/B9 probe: resolves each key's record position through the PK B-tree, requires the /// positions to be physically adjacent at the fixed-width stride, reads the whole contiguous diff --git a/src/SharpCoreDB/Database/Execution/Database.Batch.cs b/src/SharpCoreDB/Database/Execution/Database.Batch.cs index 155cdf8a..f6329362 100644 --- a/src/SharpCoreDB/Database/Execution/Database.Batch.cs +++ b/src/SharpCoreDB/Database/Execution/Database.Batch.cs @@ -923,10 +923,12 @@ private bool TryParseUpdateForBatch(string sql, out string tableName, out string /// The parsed table name. /// The parsed WHERE clause. /// True if the statement was successfully parsed as a DELETE. - private bool TryParseDeleteForBatch(string sql, out string tableName, out string where) + private bool TryParseDeleteForBatch(string sql, out string tableName, out string where, out string? whereColumn, out string? whereLiteral) { tableName = string.Empty; where = string.Empty; + whereColumn = null; + whereLiteral = null; // Phase-2 fast path: canonical single-row shape // `DELETE FROM WHERE = ` — regex-free. @@ -938,7 +940,10 @@ private bool TryParseDeleteForBatch(string sql, out string tableName, out string } tableName = fastTable; - where = whereCol + " = " + whereValRaw; + whereColumn = whereCol; + whereLiteral = whereValRaw; + // Where stays empty for canonical statements — the table layer reconstructs it only + // when a fallback actually needs it (B3: no per-statement string allocation). return true; } @@ -997,7 +1002,9 @@ private bool TryParseDeleteForBatch(string sql, out string tableName, out string // ✅ PERF: Group UPDATE/DELETE by table for single-lock batch execution Dictionary updates)>> updatesByTable = []; - Dictionary> deletesByTable = []; + // Canonical DELETE statements carry the pre-parsed column + raw literal so the table layer + // can skip the per-statement WHERE rebuild/re-parse (B3); Where stays empty for those. + Dictionary> deletesByTable = []; foreach (var sql in statements) { @@ -1031,14 +1038,14 @@ private bool TryParseDeleteForBatch(string sql, out string tableName, out string } updList.Add((updWhere, updSets)); } - else if (TryParseDeleteForBatch(sql, out var delTableName, out var delWhere)) + else if (TryParseDeleteForBatch(sql, out var delTableName, out var delWhere, out var delCol, out var delLiteral)) { if (!deletesByTable.TryGetValue(delTableName, out var delList)) { delList = []; deletesByTable[delTableName] = delList; } - delList.Add(delWhere); + delList.Add((delWhere, delCol, delLiteral)); } else { @@ -1097,11 +1104,42 @@ private bool TryParseDeleteForBatch(string sql, out string tableName, out string } // ✅ PERF: Batch DELETE — single lock per table instead of per-statement - foreach (var (tableName, wheres) in deletesByTable) + foreach (var (tableName, deletes) in deletesByTable) { if (tables.TryGetValue(tableName, out var tbl) && tbl is DataStructures.Table concreteDelete) { - concreteDelete.DeleteMultiple(wheres); + // Canonical batches go through the structured path (no WHERE rebuild/re-parse); + // any non-canonical statement forces the string form for the whole table. + bool allCanonical = true; + foreach (var (_, column, _) in deletes) + { + if (column is null) + { + allCanonical = false; + break; + } + } + + if (allCanonical) + { + var keys = new List<(string Column, string Literal)>(deletes.Count); + foreach (var (_, column, literal) in deletes) + { + keys.Add((column!, literal!)); + } + + concreteDelete.DeleteMultipleKeys(keys); + } + else + { + var wheres = new List(deletes.Count); + foreach (var (where, column, literal) in deletes) + { + wheres.Add(where.Length > 0 ? where : column + " = " + literal); + } + + concreteDelete.DeleteMultiple(wheres); + } } } From d37fad5123029f07ed3e3b96c5b7b4f7554ba0f3 Mon Sep 17 00:00:00 2001 From: MPCoreDeveloper Date: Thu, 3 Sep 2026 20:39:02 +0200 Subject: [PATCH 4/5] =?UTF-8?q?perf:=20key-only=20DELETE=20row=20decode=20?= =?UTF-8?q?(B1)=20=E2=80=94=20decode=20only=20PK=20+=20loaded=20hash-index?= =?UTF-8?q?=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structured batch DELETE path (DeleteMultipleKeys) now decodes a minimal row: BuildDeleteKeyColumns computes the needed columns (PK + every loaded hash-index column), and DeserializeDeleteKeyRow walks the legacy variable-length record skipping the unneeded columns' payload parsing entirely, building a 1-3 entry dictionary instead of a full row. DeleteRecordsCore performs the identical PK/hash lookups on that subset. Fixed-width layouts and corrupt rows fall back to the full DeserializeRowFromSpan. Measured (Release, comparative harness DELETE 10K of ~100K rows, median of 3): SQL ~39-42K and Direct ~57K ops/s — neutral within run noise on this small-row workload; the win shows on wider rows / long unindexed TEXT payloads where payload parsing and boxing are skipped. Full suite + 4 CI-filter suites EXIT=0. --- src/SharpCoreDB/DataStructures/Table.CRUD.cs | 79 +++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/src/SharpCoreDB/DataStructures/Table.CRUD.cs b/src/SharpCoreDB/DataStructures/Table.CRUD.cs index 60e56eea..61bbbc82 100644 --- a/src/SharpCoreDB/DataStructures/Table.CRUD.cs +++ b/src/SharpCoreDB/DataStructures/Table.CRUD.cs @@ -3059,6 +3059,9 @@ internal void DeleteMultipleKeys(List<(string Column, string Literal)> condition } } + // B1: decode only the columns the delete core touches (PK + loaded hash-index columns). + int[] deleteKeyColumns = BuildDeleteKeyColumns(); + var recordsToDelete = new List<(long storagePosition, Dictionary row)>(); foreach (var (col, literal) in conditions) @@ -3076,7 +3079,7 @@ internal void DeleteMultipleKeys(List<(string Column, string Literal)> condition var fastData = engine.Read(Name, fastSearch.Value); if (fastData != null) { - var fastRow = DeserializeRowFromSpan(fastData); + var fastRow = DeserializeDeleteKeyRow(fastData, deleteKeyColumns) ?? DeserializeRowFromSpan(fastData); if (fastRow != null) { recordsToDelete.Add((fastSearch.Value, fastRow)); @@ -3103,7 +3106,7 @@ internal void DeleteMultipleKeys(List<(string Column, string Literal)> condition var data = engine.Read(Name, pos); if (data != null) { - var row = DeserializeRowFromSpan(data); + var row = DeserializeDeleteKeyRow(data, deleteKeyColumns) ?? DeserializeRowFromSpan(data); if (row != null) recordsToDelete.Add((pos, row)); } } @@ -3175,6 +3178,78 @@ private static string UnquoteSqlLiteral(string raw) return raw.AsSpan().Trim().Trim("'\"".AsSpan()).ToString(); } + /// + /// B1: column indexes a delete target actually needs — the PK value (B-tree cleanup) plus every + /// loaded hash-index column (key-only hash cleanup). Ascending, de-duplicated. + /// + private int[] BuildDeleteKeyColumns() + { + var list = new List(4); + if (this.PrimaryKeyIndex >= 0) + { + list.Add(this.PrimaryKeyIndex); + } + + foreach (var kvp in this.hashIndexes) + { + int colIdx = this.Columns.IndexOf(kvp.Key); + if (colIdx >= 0 && !list.Contains(colIdx)) + { + list.Add(colIdx); + } + } + + list.Sort(); + return list.ToArray(); + } + + /// + /// B1: decodes only the columns listed in (ascending) from a legacy + /// variable-length serialized row, skipping the unneeded columns' payload parsing entirely. + /// Returns a minimal dictionary (pk + hash-index columns only) so + /// performs the identical PK/hash lookups without materializing the full row. Returns null for + /// fixed-width layouts (those go through the fixed-width codec) or corrupt rows — callers fall + /// back to full-row deserialization in that case. + /// + private Dictionary? DeserializeDeleteKeyRow(byte[] data, int[] wanted) + { + if (_fixedWidthRecords || data == null || data.Length == 0 || wanted.Length == 0) + { + return null; + } + + ReadOnlySpan span = data.AsSpan(); + int offset = 0; + Dictionary? row = null; + int wi = 0; + + for (int i = 0; i < Columns.Count; i++) + { + if (offset >= span.Length) + { + return null; + } + + int size = ReadColumnEncodedSize(span, offset, ColumnTypes[i]); + if (size <= 0 || offset + size > span.Length) + { + return null; // corrupt / unexpected layout → full-row fallback + } + + if (wi < wanted.Length && wanted[wi] == i) + { + var value = ReadTypedValueFromSpan(span.Slice(offset), ColumnTypes[i], out _); + row ??= new Dictionary(wanted.Length); + row[Columns[i]] = value; + wi++; + } + + offset += size; + } + + return row; + } + /// /// Shared B8/B9 probe: resolves each key's record position through the PK B-tree, requires the /// positions to be physically adjacent at the fixed-width stride, reads the whole contiguous From 478038e05bf90decfb20e1f07d88cf79beb4cf86 Mon Sep 17 00:00:00 2001 From: MPCoreDeveloper Date: Thu, 3 Sep 2026 20:59:40 +0200 Subject: [PATCH 5/5] fix: canonical DELETE scanner + key-only single-index delete (W1) D2 profiling (env-gated phase timers) showed TryScanCanonicalDml's DELETE branch never consumed the whitespace/WHERE keyword after the table name, so every canonical 'DELETE ... WHERE col = literal' fell back to the regex path - DeleteMultipleKeys/B1 were dead code in the benchmark harness. - Fix scanner: DELETE now consumes whitespace + WHERE + whitespace before the WHERE column; canonical batch deletes route through the structured DeleteMultipleKeys path. - Diagnostics: Database.CanonicalDeleteStatementsParsed counter + CanonicalBatchDelete_EngagesStructuredPath regression test. - W1: in DeleteMultipleKeys, when the table has no PK tree and exactly one registered hash index (no B-tree manager) whose key is the condition value, delete positions are recorded with the known key - no per-row engine.Read/decode. Profile attribution (DELETE 10K, docs): read+decode 62-82ms, commit markers ~50ms, core 23-56ms, index 5-10ms. Per-row read remains while the delete core must remove each auto-rowid PK entry - next lever is batch PK stale/lazy-rebuild. docs/performance/EXECUTION_PLAN_UPDATE_DELETE.md: combined execution plan + D2 findings. --- .../EXECUTION_PLAN_UPDATE_DELETE.md | 90 +++++++++++++++++++ src/SharpCoreDB/DataStructures/Table.CRUD.cs | 24 +++++ .../Database/Core/Database.Core.cs | 5 ++ .../Database/Execution/Database.Batch.cs | 13 +++ .../FixedWidthBulkDeleteTests.cs | 25 ++++++ 5 files changed, 157 insertions(+) create mode 100644 docs/performance/EXECUTION_PLAN_UPDATE_DELETE.md diff --git a/docs/performance/EXECUTION_PLAN_UPDATE_DELETE.md b/docs/performance/EXECUTION_PLAN_UPDATE_DELETE.md new file mode 100644 index 00000000..aef6862d --- /dev/null +++ b/docs/performance/EXECUTION_PLAN_UPDATE_DELETE.md @@ -0,0 +1,90 @@ +# Uitvoerplan — UPDATE/DELETE-achterstand (combined) + +**Datum:** 2026-09-03 +**Bron:** eigen metingen/root-causes (sessie: PR #367–#370) + `PERFORMANCE_DEEP_DIVE.md` (Grok/xAI, second opinion). +**Status:** actief. Werkwijze: meet eerst (D2), bouw daarna (A1/A2), valideer met median-of-runs + full suite + CI. + +## 0. Wat al klaar is (deze sessie) +| PR | Wat | Effect | +|---|---|---| +| #367 | In-place tombstones (directe deletes) | DELETE duurzaam in O(delete), geen flush-rewrite | +| #368 | **Commit-time tombstones** (transactionele/batch deletes) | **SQL DELETE 0,82 s → 0,24 s** (~12K → ~41-58K ops/s) — de grote sprong | +| #369 | C4 (batch markers + evict-dedup) + B3 (structured delete, geen dubbele parse) | veilig; neutraal binnen ruis op benchmark | +| #370 | B1 (key-only decode: alleen PK + hash-indexkolommen) | veilig; neutraal binnen ruis op small-row benchmark | + +**Root cause (niet in Grok-doc):** `ExecuteBatchSQL` draait elke batch in een storage-transactie; zonder #368 deed batch-DELETE nog steeds de #366 full-file compactie (~690 ms in `tableFlushLoop`). Daardoor waren eerdere “winst”-metingen niet-duurzaam/logisch-only. + +## 1. Baselines & doel +| | SCDB Direct | SCDB SQL | SQLite | LiteDB | +|---|---:|---:|---:|---:| +| INSERT | ~130-187K | ~80-112K | ~130-150K | ~72K | +| READ | ~127K | ~71K | ~95K | ~15K | +| UPDATE | ~40-49K | ~34-40K | ~240-275K | ~10K | +| DELETE | ~55-58K | ~41K | ~325-375K | ~14K | + +Cijfers variëren per machine/run (±10-20%). Verdict (beide analyses): niet verloren; **UPDATE/DELETE ~3x achter** is structureel maar aanpakbaar. INSERT/READ + analytics/vector/encryptie zijn al sterke punten. + +## 2. Bottlenecks (gecombineerd) +1. AppendOnly versie-appends: UPDATE/DELETE = pread+pwrite per rij + indexonderhoud (SQLite: in-place leaf + WAL-batch). +2. Batch-DELETE zat onterecht op flush-compactie → **opgelost (#368)**. +3. Per-rij punt-I/O + B-tree/hash per operatie (C4/B3/B1 raken de rand, niet de kern — gemeten neutraal). +4. Volledige (de)serialisatie op in-place paden met leidende variabele-lengte kolommen (deels geraakt door B1). +5. Contiguïteit wordt alleen voor fixed-width benut (B8/B9); de benchmark-docs-tabel is variabele-lengte met fysiek oplopende posities. +6. Grove `rwLock` + per-op index-locks. +7. WAL/fsync-duurzaamheid (bevestigd met split-flush-meting: fsync-tail ~0,5-0,7 s op het oude pad; weg door #368). +8. Managed/GC + Dictionary/boxing in non-Direct paden. +9. AES-GCM per record (toggle: `NoEncryptMode`). + +## 3. Werkwijze per stap (vóór elke bouw: meten) +- **D2-first:** profile UPDATE/DELETE hot paths (`DeleteMultipleKeys`/`UpdateMultiple`/`DeleteRecordsCore`/commit-tombstones) met `dotnet-trace` of env-gated fase-timers; bepaal of de tijd zit in resolutie (hash-lookups), per-rij `engine.Read`+decode, indexonderhoud (B-tree/hash), of de commit-markers. +- Bouw alleen wat het profiel aanwijst. Elke stap: eigen branch → median-of-N benchmark → full suite + 4 CI-filter-suites → doc-update. + +## 4. Uitvoeringsfasen +### Fase A — Quick wins (P0) +- **A1:** contiguous variabele-lengte single-pass voor UPDATE/DELETE (prefix-walk over oplopende posities; 1 range-read, markers/patches in 1 schrijfpassage). Doel: docs-DELETE/UPDATE ~80-120K. Alleen na D2-bevestiging dat range-I/O de bottleneck is. +- **A2:** in-place UPDATE waar de slot-lengte gelijk blijft (geen append/stale-versie) voor niet-fastPatch-gevallen. +- **A3:** batched index re-point per index over de hele batch (deels aanwezig). + +### Fase B — Structuur (P1, kern ~3x-achterstand) +- **B1:** `FixedWidthTable`/in-place page-engine volwassen (vaste offsets, slot-free-space, tombstone+vacuum). Doel: ~120-200K ops/s → ~80-110% van SQLite. +- **B2:** PageBased als aanbevolen OLTP-engine + storage-engine selector (OLTP→PageBased+FixedWidth; analytics/eventsourcing→AppendOnly/Columnar). +- **B3:** source-generated typed/ref-struct accessors (geen Dictionary op hot paths) + `PreparedCommand` met herbruikbare buffers. +- **B4:** fijnmaziger locking (per-page/per-index) + optimistische concurrency. + +### Fase C — Platform (P2, .NET 11) +- **C1:** Native AOT + `Span` + Runtime Async; SIMD lane-API's/AVX-VNNI-512 voor index-lookups. +- **C2:** optionele “SQLite-compat mode” (NoEncrypt + fixed-width default). + +### Fase D — Hygiëne & observability (doorlopend) +- **D1:** median-of-N + warm-up in de comparative-harness. + +## 7. D2-bevindingen (env-gated fase-timers, 2026-09-03) +DELETE 10K op de docs-tabel (comparative, Release) — attributie in de structured batch path: + +| Fase | SQL | Direct | +|---|---:|---:| +| per-rij read+decode (`engine.Read`+`DeserializeDeleteKeyRow`) | 62-82 ms | ~66 ms | +| commit-tombstones (markers, pread+pwrite per rij) | ~50 ms | ~48-50 ms | +| delete core (index-onderhoud, B-tree/hash) | 23-56 ms | ~32 ms | +| hash-index lookup | 5-10 ms | ~5 ms | + +**Ontdekte bug (gefixed):** `TryScanCanonicalDml`'s DELETE-tak consumente nooit de whitespace/het `WHERE`-keyword na de tabelnaam → elke canonieke `DELETE ... WHERE col = literal` viel terug op de regex-path → `DeleteMultipleKeys` + B1/W1 (PR #369/#370) waren **dead code in de benchmark-harness**. Fix + diagnostische teller `Database.CanonicalDeleteStatementsParsed` + regressietest `CanonicalBatchDelete_EngagesStructuredPath` (deze PR). + +**Gevolg voor de attributie:** de per-rij read blijft nodig zolang de delete-core de auto-`rowid`-PK-waarde per rij moet wissen (gate-onderzoek: docs heeft PK-achtige index >1 geregistreerd). Grootste resterende hefbomen, nu met cijfers onderbouwd: +1. **PK-onderhoud vervangen door één stale/lazy-rebuild** na een grote delete-batch (i.p.v. per-rij `Index.Delete`) → verwijdert ~30-50% van `core` én maakt de per-rij read overbodig (grootste winst op deze workload). +2. **Marker-batching over een range-read** (commit-tombstones ~50 ms → enkele ms) als vervolg op C4. +3. W1 (key-only, geen read) vuurt alleen bij een tabel met exact één geregistreerde index en zonder PK-tree — daar direct ~60-80 ms winst per 10K deletes. + +- **D2:** `dotnet-trace`/fase-timers op `UpdateAffectedRows`, `DeleteMultipleKeys`, `DeleteRecordsCore`, commit-tombstones. +- **D3:** AOT/R2R expliciet als aparte meet-as (we meten nu managed JIT + tiered PGO). +- **D4:** `docs/manual/performance.md` + dit document bijwerken na elke stap. + +## 5. Beslisboom (storage-engine) +- Veel UPDATE/DELETE (OLTP) → **PageBased + FixedWidth** (in-place). +- Veel appends/analytics/eventsourcing → **AppendOnly/Columnar** (blijft dominant). +- Pure-throughput scenario’s → `NoEncryptMode`; encryptie blijft default-differentiator. + +## 6. Volgende acties +1. D2-profiel op de huidige DELETE/UPDATE hot paths. +2. A1/A2 implementeren op basis van het profiel (branch bovenop #370). +3. Fase B (fixed-width in-place + PageBased default) als aparte roadmap-track. diff --git a/src/SharpCoreDB/DataStructures/Table.CRUD.cs b/src/SharpCoreDB/DataStructures/Table.CRUD.cs index 61bbbc82..bb28761d 100644 --- a/src/SharpCoreDB/DataStructures/Table.CRUD.cs +++ b/src/SharpCoreDB/DataStructures/Table.CRUD.cs @@ -3101,6 +3101,30 @@ internal void DeleteMultipleKeys(List<(string Column, string Literal)> condition var key = ParseValueForHashLookup(value, this.ColumnTypes[colIdx]); if (key != null) { + // W1: when this is the ONLY index the delete core must maintain (no + // PK tree, a single loaded hash index whose key is the condition + // value itself, no secondary B-tree manager), the position can be + // removed with the already-known key — no per-row engine.Read or row + // decode is needed for index cleanup. + bool keyOnlyRow = + this.PrimaryKeyIndex < 0 && + this.hashIndexes.Count == 1 && + _btreeManager is null && + this.registeredIndexes.Count == 1; + + if (keyOnlyRow) + { + foreach (var pos in hashIndex.LookupPositionsUnsafe(key)) + { + recordsToDelete.Add((pos, new Dictionary(1) + { + [col] = key + })); + } + + continue; + } + foreach (var pos in hashIndex.LookupPositionsUnsafe(key)) { var data = engine.Read(Name, pos); diff --git a/src/SharpCoreDB/Database/Core/Database.Core.cs b/src/SharpCoreDB/Database/Core/Database.Core.cs index 553db682..4f52be9a 100644 --- a/src/SharpCoreDB/Database/Core/Database.Core.cs +++ b/src/SharpCoreDB/Database/Core/Database.Core.cs @@ -32,6 +32,11 @@ namespace SharpCoreDB; /// public partial class Database : IDatabase, IDisposable, IAsyncDisposable { + // Diagnostics: number of DELETE statements routed through the canonical structured batch path + // (proves the scanner/batch fast paths are engaged; 0 means everything fell back to regex). + private static long _canonicalDeleteStatements; + public long CanonicalDeleteStatementsParsed => Interlocked.Read(ref _canonicalDeleteStatements); + private readonly IServiceProvider _serviceProvider; private readonly IStorage storage; private readonly IUserService userService; diff --git a/src/SharpCoreDB/Database/Execution/Database.Batch.cs b/src/SharpCoreDB/Database/Execution/Database.Batch.cs index f6329362..24643d38 100644 --- a/src/SharpCoreDB/Database/Execution/Database.Batch.cs +++ b/src/SharpCoreDB/Database/Execution/Database.Batch.cs @@ -616,6 +616,18 @@ private static bool IsInsertStatement(string sql) return false; } } + else + { + // DELETE: consume the whitespace after the table name, then the WHERE keyword, then + // the whitespace before the WHERE column. (Missing before — every canonical DELETE fell + // back to the regex path, bypassing the structured batch-DELETE fast paths.) + if (!TryConsumeWhitespace(s, ref i) || + !TryConsumeKeyword(s, ref i, "WHERE") || + !TryConsumeWhitespace(s, ref i)) + { + return false; + } + } // WHERE = (to end of statement) if (!TryReadSimpleIdent(s, ref i, out var whereColSpan) || whereColSpan.IsEmpty) @@ -944,6 +956,7 @@ private bool TryParseDeleteForBatch(string sql, out string tableName, out string whereLiteral = whereValRaw; // Where stays empty for canonical statements — the table layer reconstructs it only // when a fallback actually needs it (B3: no per-statement string allocation). + System.Threading.Interlocked.Increment(ref _canonicalDeleteStatements); return true; } diff --git a/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs b/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs index aff57eeb..e3bfbc7b 100644 --- a/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs +++ b/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs @@ -231,6 +231,31 @@ public void DeletesPersistAcrossReopen_AfterFlushCompaction() (db as IDisposable)?.Dispose(); } + [Fact] + public void CanonicalBatchDelete_EngagesStructuredPath() + { + // Regression: TryScanCanonicalDml's DELETE branch did not consume whitespace/WHERE after + // the table name, so every canonical `DELETE ... WHERE col = literal` fell back to the + // regex path and bypassed the structured batch-DELETE fast paths (DeleteMultipleKeys). + IDatabase? db = CreateDb(); + try + { + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + InsertDocs(db, 1, 50); + db.Flush(); + + var before = ((SharpCoreDB.Database)db).CanonicalDeleteStatementsParsed; + db.ExecuteBatchSQL(BuildDeletes(1, 10)); + db.Flush(); + + Assert.True( + ((SharpCoreDB.Database)db).CanonicalDeleteStatementsParsed >= before + 10, + "canonical DELETE statements must flow through the structured batch path"); + Assert.Equal(40, db.ExecuteQuery("SELECT id FROM docs").Count); + } + finally { (db as IDisposable)?.Dispose(); } + } + [Fact] public void BatchDelete_CommitTimeTombstones_SurviveReopenWithoutExplicitFlush() {