diff --git a/src/SharpCoreDB/DataStructures/BTree.cs b/src/SharpCoreDB/DataStructures/BTree.cs index 85ee0c17..6cf4fae4 100644 --- a/src/SharpCoreDB/DataStructures/BTree.cs +++ b/src/SharpCoreDB/DataStructures/BTree.cs @@ -333,8 +333,60 @@ private bool DeleteFromNode(Node node, TKey key) if (i < node.keysCount && CompareKeys(key, node.keysArray[i]) == 0) { - // Key found in this node — RemoveKeyAt already shifts both keys AND values + if (node.IsLeaf) + { + // Leaf: values live here — remove the entry. Leaf underflow is harmless (no child + // pointers depend on leaf occupancy), so no rebalancing is required for correctness. + RemoveKeyAt(node, i); + return true; + } + + // Internal (separator) node: values live in leaves, so this node only routes ranges. + // Removing the separator outright would leave the child-pointer ↔ separator mapping + // inconsistent (keys between the deleted separator and the next one would become + // unreachable), so the separator is replaced by its in-order successor taken from the + // right subtree's leftmost leaf, and that leaf entry is then deleted recursively. + var successorChild = node.childrenArray[i + 1]; + while (!successorChild.IsLeaf) + { + successorChild = successorChild.childrenArray[0]; + } + + if (successorChild.keysCount > 0) + { + node.keysArray[i] = successorChild.keysArray[0]; + if (i < node.valuesCount) + { + node.valuesArray[i] = successorChild.valuesArray[0]; + } + + return DeleteFromNode(node.childrenArray[i + 1], node.keysArray[i]); + } + + // The right subtree is empty (fully drained) — fall back to the left subtree's maximum + // when it still holds entries. + var predecessorChild = node.childrenArray[i]; + while (!predecessorChild.IsLeaf) + { + predecessorChild = predecessorChild.childrenArray[predecessorChild.childrenCount - 1]; + } + + if (predecessorChild.keysCount > 0) + { + int predPos = predecessorChild.keysCount - 1; + node.keysArray[i] = predecessorChild.keysArray[predPos]; + if (i < node.valuesCount) + { + node.valuesArray[i] = predecessorChild.valuesArray[predPos]; + } + + return DeleteFromNode(node.childrenArray[i], node.keysArray[i]); + } + + // Both neighbour subtrees are drained — drop the separator together with its empty + // right child so the child pointer count stays consistent with the key count. RemoveKeyAt(node, i); + RemoveChildAt(node, i + 1); return true; } else if (!node.IsLeaf) @@ -345,6 +397,23 @@ private bool DeleteFromNode(Node node, TKey key) return false; } + private static void RemoveChildAt(Node node, int pos) + { + if (pos < 0 || pos >= node.childrenCount) + { + return; + } + + var span = node.childrenArray.AsSpan(); + if (pos < node.childrenCount - 1) + { + span.Slice(pos + 1, node.childrenCount - pos - 1).CopyTo(span.Slice(pos, node.childrenCount - pos - 1)); + } + + node.childrenArray[node.childrenCount - 1] = default!; + node.childrenCount--; + } + private static void RemoveKeyAt(Node node, int pos) { if (pos < 0 || pos >= node.keysCount) return; diff --git a/src/SharpCoreDB/DataStructures/Table.CRUD.cs b/src/SharpCoreDB/DataStructures/Table.CRUD.cs index df21f7eb..0234bcc8 100644 --- a/src/SharpCoreDB/DataStructures/Table.CRUD.cs +++ b/src/SharpCoreDB/DataStructures/Table.CRUD.cs @@ -2450,53 +2450,14 @@ this.storage is null || keys[i] = keyStr; } - // Resolve the first record position through the PK B-tree, then require every later key to - // sit at the expected contiguous offset (physical adjacency). Verifying every position keeps - // the gate sound even when earlier appends/deletes shifted records. - var first = this.Index.Search(keys[0]); - if (!first.Found) - { - return false; - } - - long basePosition = first.Value; - positions[0] = basePosition; - long expected = basePosition; - for (int i = 1; i < count; i++) - { - expected += stride; - var search = this.Index.Search(keys[i]); - if (!search.Found || search.Value != expected) - { - return false; - } - - positions[i] = expected; - } - - // Read the whole contiguous span in ONE range read (plaintext records only — enforced above). - long totalBytes = stride * count; - if (totalBytes <= 0 || totalBytes > int.MaxValue) + // Resolve every target record's position through the PK B-tree and read + verify the whole + // contiguous span (one range read) — shared by the UPDATE and DELETE contiguous fast paths. + var raw = TryReadContiguousFixedWidthRecords(keys, stride, layout, positions); + if (raw is null) { return false; } - var raw = this.storage.ReadBytesRange(DataFile, basePosition, (int)totalBytes); - if (raw is null || raw.Length < totalBytes) - { - return false; - } - - // Verify every 4-byte length prefix matches the fixed record size BEFORE touching anything. - for (int i = 0; i < count; i++) - { - int prefix = BinaryPrimitives.ReadInt32LittleEndian(raw.AsSpan((int)(i * stride), 4)); - if (prefix != layout.FixedSize) - { - return false; - } - } - // Patch and write each record in place (buffered by the storage layer; flushed at commit). for (int i = 0; i < count; i++) { @@ -2890,6 +2851,16 @@ internal void DeleteMultiple(List whereConditions) // Load every registered hash index before the delete loop (same reason as // CollectDeleteRecords: stale file records must be removed from every index). EnsureAllRegisteredIndexesLoaded(); + + // B9: single-pass contiguous DELETE — mirror of the UPDATE fast path: when every + // condition is a strictly ascending `pk = ` match on a plaintext fixed-width + // table with physically adjacent records, the rows are removed from every index in one + // pass (no per-row pread or full-row deserialization). Falls back to the generic loop. + if (TryBulkDeleteContiguousFixedWidth(whereConditions)) + { + return; + } + var recordsToDelete = new List<(long storagePosition, Dictionary row)>(); foreach (var where in whereConditions) @@ -2990,6 +2961,206 @@ internal void DeleteMultiple(List whereConditions) } } + /// + /// 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 + /// span through the storage layer's cached handle and verifies every 4-byte length prefix. + /// Returns the raw span bytes, or so the caller falls back to the generic + /// per-row loop — nothing is modified before this succeeds. + /// + private byte[]? TryReadContiguousFixedWidthRecords( + string[] keys, + long stride, + FixedWidthRecordLayout layout, + long[] positions) + { + int count = keys.Length; + + var first = this.Index.Search(keys[0]); + if (!first.Found) + { + return null; + } + + long basePosition = first.Value; + positions[0] = basePosition; + long expected = basePosition; + for (int i = 1; i < count; i++) + { + expected += stride; + var search = this.Index.Search(keys[i]); + if (!search.Found || search.Value != expected) + { + return null; + } + + positions[i] = expected; + } + + long totalBytes = stride * count; + if (totalBytes <= 0 || totalBytes > int.MaxValue) + { + return null; + } + + var raw = this.storage.ReadBytesRange(DataFile, basePosition, (int)totalBytes); + if (raw is null || raw.Length < totalBytes) + { + return null; + } + + for (int i = 0; i < count; i++) + { + int prefix = BinaryPrimitives.ReadInt32LittleEndian(raw.AsSpan((int)(i * stride), 4)); + if (prefix != layout.FixedSize) + { + return null; + } + } + + return raw; + } + + /// + /// B9: number of DELETE batches processed by the contiguous single-pass fast path (diagnostics + /// used by tests to prove the path engages; zero means every batch fell back to the generic loop). + /// + public long BulkContiguousDeleteBatches => Interlocked.Read(ref _bulkContiguousDeleteBatches); + + private long _bulkContiguousDeleteBatches; + + /// + /// B9: single-pass contiguous DELETE for plaintext fixed-width tables (mirror of the UPDATE fast + /// path). Requires strictly ascending pk = <numeric literal> conditions whose records are + /// physically adjacent; when the shape holds, the target records are read as one contiguous byte + /// range and every PK / loaded hash-index entry is removed in one pass (the physical rows are + /// reclaimed lazily by compaction exactly like the generic delete path). Any mismatch returns + /// and the caller falls back to the generic per-condition loop — nothing + /// is removed before the range is verified. + /// + private bool TryBulkDeleteContiguousFixedWidth(List whereConditions) + { + int count = whereConditions.Count; + if (count < 2) + { + return false; + } + + // Identical safety gate to the UPDATE fast path: fixed-width columnar table with an explicit + // PK, plaintext records only, and no buffered overwrites (a raw range read must equal the + // logical record bytes). DeleteMultiple loads every registered hash index before calling this. + if (!_fixedWidthRecords || + StorageMode != StorageMode.Columnar || + this.PrimaryKeyIndex < 0 || + this.storage is null || + this._config is not { NoEncryptMode: true } || + this.storage.HasBufferedOverwrite(DataFile)) + { + return false; + } + + var pkName = this.Columns[this.PrimaryKeyIndex]; + var layout = GetFixedWidthLayout(); + long stride = 4L + layout.FixedSize; + + var positions = new long[count]; + var keys = new string[count]; + long parsedPrev = 0; + + for (int i = 0; i < count; i++) + { + var where = whereConditions[i]; + if (string.IsNullOrEmpty(where) || + !TryParseSimpleWhereClause(where, out var whereCol, out var whereVal) || + !string.Equals(whereCol, pkName, StringComparison.OrdinalIgnoreCase) || + whereVal is null) + { + return false; + } + + var keyStr = whereVal.ToString(); + if (string.IsNullOrEmpty(keyStr) || + !long.TryParse(keyStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out long key)) + { + return false; + } + + if (i > 0 && key <= parsedPrev) + { + return false; + } + + parsedPrev = key; + keys[i] = keyStr; + } + + // Resolve every target record's position through the PK B-tree and read + verify the whole + // contiguous span (one range read) — shared by the UPDATE and DELETE contiguous fast paths. + var raw = TryReadContiguousFixedWidthRecords(keys, stride, layout, positions); + if (raw is null) + { + return false; + } + + // Remove PK entries (the keys are the WHERE literals) and then every loaded hash-index + // entry, decoding only the indexed columns from the raw fixed-width records (no full-row + // deserialization). Variable values resolve through the overflow arena, mirroring the + // fixed-width codec used by the generic path. + for (int i = 0; i < count; i++) + { + this.Index.Delete(keys[i]); + } + + var arena = GetOverflowArena(); + foreach (var (colName, hashIdx) in this.hashIndexes) + { + int colIdx = -1; + for (int c = 0; c < this.Columns.Count; c++) + { + if (this.Columns[c].Equals(colName, StringComparison.OrdinalIgnoreCase)) + { + colIdx = c; + break; + } + } + + if (colIdx < 0) + { + continue; + } + + var type = this.ColumnTypes[colIdx]; + var decoded = new object?[count]; + for (int i = 0; i < count; i++) + { + var payload = raw.AsSpan((int)(i * stride) + 4, layout.FixedSize); + var slot = payload.Slice(layout.Offsets[colIdx], layout.SlotSizes[colIdx]); + if (layout.IsVariable[colIdx]) + { + if (slot[0] == 0) + { + decoded[i] = null; + continue; + } + + var blockOffset = BinaryPrimitives.ReadInt32LittleEndian(slot[1..]); + var block = arena.Read(blockOffset); + decoded[i] = block is null ? null : DecodeVariablePayload(type, block); + } + else + { + decoded[i] = ReadTypedValueFromSpan(slot, type, out _); + } + } + + hashIdx.RemoveBatchKeys(decoded, positions); + } + + Interlocked.Add(ref _cachedRowCount, -count); + Interlocked.Increment(ref _bulkContiguousDeleteBatches); + return true; + } + /// /// Finds a single row by primary key value, bypassing SQL parsing entirely. /// Uses B-tree PK index for O(log n) lookup + single storage read. diff --git a/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs b/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs new file mode 100644 index 00000000..9bdff64b --- /dev/null +++ b/tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs @@ -0,0 +1,168 @@ +// +// Copyright (c) 2026 MPCoreDeveloper. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// +namespace SharpCoreDB.Tests; + +using Microsoft.Extensions.DependencyInjection; +using SharpCoreDB.DataStructures; +using SharpCoreDB.Interfaces; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Xunit; + +/// +/// B9: single-pass contiguous DELETE fast path (Table.TryBulkDeleteContiguousFixedWidth). Strictly +/// ascending `pk = literal` DELETEs on a plaintext fixed-width table with physically adjacent +/// records remove every PK/hash-index entry in one pass (no per-row pread or full-row +/// deserialization). Any other shape falls back to the generic loop and stays correct. +/// +public sealed class FixedWidthBulkDeleteTests : IDisposable +{ + private readonly DatabaseFactory _factory; + private readonly string _dirPath; + + public FixedWidthBulkDeleteTests() + { + var services = new ServiceCollection(); + services.AddSharpCoreDB(); + _factory = services.BuildServiceProvider().GetRequiredService(); + _dirPath = Path.Combine(Path.GetTempPath(), $"SCDB_BulkDel_{Guid.NewGuid():N}"); + } + + public void Dispose() + { + try { if (Directory.Exists(_dirPath)) Directory.Delete(_dirPath, true); } catch { } + } + + private IDatabase CreateDb(bool noEncrypt = true) => _factory.Create(_dirPath, "pw", isReadOnly: false, + config: new DatabaseConfig { NoEncryptMode = noEncrypt }); + + private static Table TableOf(IDatabase db, string tableName) + { + Assert.True(db.TryGetTable(tableName, out var t)); + return Assert.IsType(t); + } + + private static void InsertDocs(IDatabase db, int fromId, int toId) + { + var stmts = new List(Math.Abs(toId - fromId) + 1); + for (int i = fromId; i <= toId; i++) + { + stmts.Add(string.Format(CultureInfo.InvariantCulture, + "INSERT INTO docs VALUES ({0}, 'user{0}', {1})", i, i * 0.5)); + } + + db.ExecuteBatchSQL(stmts); + } + + private static List BuildDeletes(int fromId, int toId) + { + var stmts = new List(Math.Abs(toId - fromId) + 1); + for (int i = fromId; i <= toId; i++) + { + stmts.Add(string.Format(CultureInfo.InvariantCulture, "DELETE FROM docs WHERE id = {0}", i)); + } + + return stmts; + } + + [Fact] + public void ContiguousAscendingDeletes_EngageBulkPath_AndRemoveEveryIndex() + { + var db = CreateDb(); + try + { + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + db.ExecuteSQL("CREATE INDEX idx_docs_name ON docs(name)"); + InsertDocs(db, 1, 2000); + db.Flush(); + + var table = TableOf(db, "docs"); + Assert.Equal(0, table.BulkContiguousDeleteBatches); + + db.ExecuteBatchSQL(BuildDeletes(1, 1000)); + db.Flush(); + Assert.Equal(1, table.BulkContiguousDeleteBatches); + + // Deleted rows are gone from the PK and hash-index paths; live rows stay reachable. + Assert.Empty(db.ExecuteQuery("SELECT id FROM docs WHERE id = 500")); + Assert.Empty(db.ExecuteQuery("SELECT id FROM docs WHERE name = 'user500'")); + Assert.Single(db.ExecuteQuery("SELECT id FROM docs WHERE id = 1500")); + Assert.Single(db.ExecuteQuery("SELECT id FROM docs WHERE name = 'user1500'")); + + // A second contiguous DELETE batch over the remaining rows engages again. + db.ExecuteBatchSQL(BuildDeletes(1001, 2000)); + db.Flush(); + Assert.Equal(2, table.BulkContiguousDeleteBatches); + Assert.Empty(db.ExecuteQuery("SELECT id FROM docs WHERE id = 1500")); + Assert.Empty(db.ExecuteQuery("SELECT id FROM docs WHERE name = 'user1500'")); + } + finally { (db as IDisposable)?.Dispose(); } + } + + [Fact] + public void DescendingUpdateBatch_FallsBackToGenericLoop_AndAppliesEveryRow() + { + var db = CreateDb(); + try + { + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + InsertDocs(db, 1, 1000); + db.Flush(); + + var table = TableOf(db, "docs"); + var stmts = new List(1000); + for (int i = 1000; i >= 1; i--) + { + stmts.Add(string.Format(CultureInfo.InvariantCulture, "UPDATE docs SET score = 7.5 WHERE id = {0}", i)); + } + + // Descending order must refuse the contiguous fast path AND still apply every row via + // the generic loop (regression: large descending batches used to apply nothing). + db.ExecuteBatchSQL(stmts); + db.Flush(); + Assert.Equal(0, table.BulkContiguousUpdateBatches); + + for (int i = 1; i <= 1000; i += 137) + { + var rows = db.ExecuteQuery("SELECT score FROM docs WHERE id = @id", + new Dictionary { ["@id"] = i }); + Assert.Single(rows); + Assert.Equal(7.5, Convert.ToDouble(rows[0]["score"])); + } + } + finally { (db as IDisposable)?.Dispose(); } + } + + [Fact] + public void GappedDeletes_FallBackToGenericLoop_AndStayCorrect() + { + var db = CreateDb(); + try + { + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + InsertDocs(db, 1, 800); + db.Flush(); + + var table = TableOf(db, "docs"); + var stmts = new List(400); + for (int i = 1; i <= 800; i += 2) + { + stmts.Add(string.Format(CultureInfo.InvariantCulture, "DELETE FROM docs WHERE id = {0}", i)); + } + + db.ExecuteBatchSQL(stmts); + db.Flush(); + Assert.Equal(0, table.BulkContiguousDeleteBatches); + + Assert.Empty(db.ExecuteQuery("SELECT id FROM docs WHERE id = 3")); + Assert.Single(db.ExecuteQuery("SELECT id FROM docs WHERE id = 2")); + Assert.Empty(db.ExecuteQuery("SELECT id FROM docs WHERE name = 'user3'")); + Assert.Single(db.ExecuteQuery("SELECT id FROM docs WHERE name = 'user2'")); + } + finally { (db as IDisposable)?.Dispose(); } + } +}