Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
runtime `AreRecordsEncrypted` gate (so default-config plaintext databases benefit without
`NoEncryptMode`). Fixed-size hash-indexed SET columns are re-pointed with one lock per index
(`HashIndex.RemoveBatchKeys`/`AddBatchKeys`).
- **BTree separator-delete corruption fixed (correctness)** ÔÇö `BTree.Delete` removed separator keys
- **Bulk descending PK-delete on the generic DELETE path** - `IIndex` now offers `DeleteBulk`;
`BTree.DeleteBulk` sorts each batch in **descending key order** so consecutive removals run along
the rightmost leaf path (dramatically fewer internal-separator promotions than deleting in
arbitrary per-row resolution order). `DeleteRecordsCore` collects the batch's PK keys once and
removes them through `DeleteBulk` instead of per-key `Delete`. Correctness is unchanged (identical
key set, one visit per key). Fair-PK harness (`--pk`, AppendOnly legacy): DELETE stays ~69-72K
ops/s on strictly ascending batches (within noise), with the win concentrating on unordered key
sets (hash-filtered subselects, reverse/random batches) that previously paid a separator
promotion per jump. Regression test drives the generic bulk path on a legacy-layout table and
reopens to prove tombstones + PK-index rebuild do not resurrect rows.
- **BTree separator-delete corruption fixed (correctness)** - `BTree.Delete` removed separator keys
from internal nodes without repairing the child-pointer mapping, so sizable delete batches could
leave whole key ranges unreachable (and scans/COUNT(*) under-counted). Internal separators are
now replaced by their in-order successor from the right subtree's leftmost leaf (leaf underflow is
Expand Down
1 change: 1 addition & 0 deletions docs/performance/EXECUTION_PLAN_UPDATE_DELETE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
| #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 |
| B5-bulk (open) | **Bulk aflopende PK-delete** (`DeleteRecordsCore` verzamelt PK-sleutels eenmalig; `IIndex.DeleteBulk`/`BTree.DeleteBulk` sorteert aflopend → rechter-bladpad, minder separator-promoties) | correct (identieke keyset, één bezoek per key); fair-PK legacy-DELETE ~69-72K ops/s (binnen ruis op geordende batches) — winst bij ongeordende keysets |

**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.

Expand Down
25 changes: 25 additions & 0 deletions src/SharpCoreDB/DataStructures/BTree.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,31 @@ public bool Delete(TKey key)
return found;
}

/// <inheritdoc />
public void DeleteBulk(IEnumerable<TKey> keys)
{
var sorted = keys as TKey[] ?? keys.ToArray();
if (sorted.Length <= 1)
{
if (sorted.Length == 1)
{
Delete(sorted[0]);
}

return;
}

// Descending order: consecutive deletes hit the rightmost leaf path, so the batch
// stays in (close to) a single node chain and triggers far fewer separator promotions
// / rebalances than deleting in arbitrary per-row order. Correctness is unaffected:
// every key is deleted exactly once.
Array.Sort(sorted, Comparer<TKey>.Default);
for (int i = sorted.Length - 1; i >= 0; i--)
{
Delete(sorted[i]);
}
}

/// <inheritdoc />
public void Clear()
{
Expand Down
11 changes: 9 additions & 2 deletions src/SharpCoreDB/DataStructures/Table.CRUD.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2633,17 +2633,24 @@ private bool HasExplicitNamedIndex(string column)
Interlocked.Add(ref _pendingLogicalDeletes, recordsToDelete.Count);
}

// Primary-key B-tree cleanup.
// Primary-key B-tree cleanup: bulk-delete in descending key order (one pass through the
// rightmost leaf path instead of arbitrary per-row order → fewer separator promotions).
if (this.PrimaryKeyIndex >= 0)
{
var pkCol = this.Columns[this.PrimaryKeyIndex];
var pkKeys = new List<string>(recordsToDelete.Count);
foreach (var (_, row) in recordsToDelete)
{
if (row.TryGetValue(pkCol, out var pkValue) && pkValue != null)
{
this.Index.Delete(pkValue.ToString() ?? string.Empty);
pkKeys.Add(pkValue.ToString() ?? string.Empty);
}
}

if (pkKeys.Count > 0)
{
this.Index.DeleteBulk(pkKeys);
}
}

// Key-only hash-index cleanup: extract each indexed column's key once per row and
Expand Down
13 changes: 13 additions & 0 deletions src/SharpCoreDB/Interfaces/IIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,17 @@ public interface IIndex<TKey, TValue>
/// Clears all entries from the index (used for bulk index rebuild).
/// </summary>
void Clear();

/// <summary>
/// Deletes a batch of keys from the index. The default implementation deletes per key;
/// specialized indexes may reorder the batch (e.g. descending) to reduce rebalancing cost.
/// </summary>
/// <param name="keys">The keys to delete.</param>
void DeleteBulk(IEnumerable<TKey> keys)
{
foreach (var key in keys)
{
Delete(key);
}
}
}
48 changes: 48 additions & 0 deletions tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -277,4 +277,52 @@ public void BatchDelete_CommitTimeTombstones_SurviveReopenWithoutExplicitFlush()
Assert.Equal(200L, Convert.ToInt64(scan[^1]["id"]));
(db as IDisposable)?.Dispose();
}

[Fact]
public void LegacyLayoutBatchDelete_BulkPkRemove_StaysCorrectAcrossReopen()
{
// Regression for DeleteRecordsCore's PK cleanup: with fixed-width disabled the table uses
// the legacy layout, so an ascending `pk = literal` batch cannot take the contiguous
// fast path and flows through the generic loop + Index.DeleteBulk (descending order).
// Every deleted row must vanish from PK/hash lookups and from a reopened database.
IDatabase? db = _factory.Create(_dirPath, "pw", isReadOnly: false,
config: new DatabaseConfig { NoEncryptMode = true, AutoFixedWidthRecords = false });
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();

// Legacy layout must NOT engage the fixed-width contiguous fast path.
Assert.Equal(0, table.BulkContiguousDeleteBatches);

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'"));
Assert.Equal(1000, db.ExecuteQuery("SELECT id FROM docs").Count);
}
finally { (db as IDisposable)?.Dispose(); }

// Reopen: tombstones keep the deleted rows gone; the PK index rebuilt from the file must
// not resurrect any of them.
db = CreateDb();
try
{
var scan = db.ExecuteQuery("SELECT id FROM docs ORDER BY id");
Assert.Equal(1000, scan.Count);
Assert.Equal(1001L, Convert.ToInt64(scan[0]["id"]));
Assert.Equal(2000L, Convert.ToInt64(scan[^1]["id"]));
Assert.Empty(db.ExecuteQuery("SELECT id FROM docs WHERE id = 500"));
Assert.Empty(db.ExecuteQuery("SELECT id FROM docs WHERE name = 'user500'"));
}
finally { (db as IDisposable)?.Dispose(); }
}
}
Loading