Skip to content

Commit 478038e

Browse files
author
MPCoreDeveloper
committed
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.
1 parent d37fad5 commit 478038e

5 files changed

Lines changed: 157 additions & 0 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Uitvoerplan — UPDATE/DELETE-achterstand (combined)
2+
3+
**Datum:** 2026-09-03
4+
**Bron:** eigen metingen/root-causes (sessie: PR #367–#370) + `PERFORMANCE_DEEP_DIVE.md` (Grok/xAI, second opinion).
5+
**Status:** actief. Werkwijze: meet eerst (D2), bouw daarna (A1/A2), valideer met median-of-runs + full suite + CI.
6+
7+
## 0. Wat al klaar is (deze sessie)
8+
| PR | Wat | Effect |
9+
|---|---|---|
10+
| #367 | In-place tombstones (directe deletes) | DELETE duurzaam in O(delete), geen flush-rewrite |
11+
| #368 | **Commit-time tombstones** (transactionele/batch deletes) | **SQL DELETE 0,82 s → 0,24 s** (~12K → ~41-58K ops/s) — de grote sprong |
12+
| #369 | C4 (batch markers + evict-dedup) + B3 (structured delete, geen dubbele parse) | veilig; neutraal binnen ruis op benchmark |
13+
| #370 | B1 (key-only decode: alleen PK + hash-indexkolommen) | veilig; neutraal binnen ruis op small-row benchmark |
14+
15+
**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.
16+
17+
## 1. Baselines & doel
18+
| | SCDB Direct | SCDB SQL | SQLite | LiteDB |
19+
|---|---:|---:|---:|---:|
20+
| INSERT | ~130-187K | ~80-112K | ~130-150K | ~72K |
21+
| READ | ~127K | ~71K | ~95K | ~15K |
22+
| UPDATE | ~40-49K | ~34-40K | ~240-275K | ~10K |
23+
| DELETE | ~55-58K | ~41K | ~325-375K | ~14K |
24+
25+
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.
26+
27+
## 2. Bottlenecks (gecombineerd)
28+
1. AppendOnly versie-appends: UPDATE/DELETE = pread+pwrite per rij + indexonderhoud (SQLite: in-place leaf + WAL-batch).
29+
2. Batch-DELETE zat onterecht op flush-compactie → **opgelost (#368)**.
30+
3. Per-rij punt-I/O + B-tree/hash per operatie (C4/B3/B1 raken de rand, niet de kern — gemeten neutraal).
31+
4. Volledige (de)serialisatie op in-place paden met leidende variabele-lengte kolommen (deels geraakt door B1).
32+
5. Contiguïteit wordt alleen voor fixed-width benut (B8/B9); de benchmark-docs-tabel is variabele-lengte met fysiek oplopende posities.
33+
6. Grove `rwLock` + per-op index-locks.
34+
7. WAL/fsync-duurzaamheid (bevestigd met split-flush-meting: fsync-tail ~0,5-0,7 s op het oude pad; weg door #368).
35+
8. Managed/GC + Dictionary/boxing in non-Direct paden.
36+
9. AES-GCM per record (toggle: `NoEncryptMode`).
37+
38+
## 3. Werkwijze per stap (vóór elke bouw: meten)
39+
- **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.
40+
- Bouw alleen wat het profiel aanwijst. Elke stap: eigen branch → median-of-N benchmark → full suite + 4 CI-filter-suites → doc-update.
41+
42+
## 4. Uitvoeringsfasen
43+
### Fase A — Quick wins (P0)
44+
- **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.
45+
- **A2:** in-place UPDATE waar de slot-lengte gelijk blijft (geen append/stale-versie) voor niet-fastPatch-gevallen.
46+
- **A3:** batched index re-point per index over de hele batch (deels aanwezig).
47+
48+
### Fase B — Structuur (P1, kern ~3x-achterstand)
49+
- **B1:** `FixedWidthTable`/in-place page-engine volwassen (vaste offsets, slot-free-space, tombstone+vacuum). Doel: ~120-200K ops/s → ~80-110% van SQLite.
50+
- **B2:** PageBased als aanbevolen OLTP-engine + storage-engine selector (OLTP→PageBased+FixedWidth; analytics/eventsourcing→AppendOnly/Columnar).
51+
- **B3:** source-generated typed/ref-struct accessors (geen Dictionary op hot paths) + `PreparedCommand` met herbruikbare buffers.
52+
- **B4:** fijnmaziger locking (per-page/per-index) + optimistische concurrency.
53+
54+
### Fase C — Platform (P2, .NET 11)
55+
- **C1:** Native AOT + `Span<T>` + Runtime Async; SIMD lane-API's/AVX-VNNI-512 voor index-lookups.
56+
- **C2:** optionele “SQLite-compat mode” (NoEncrypt + fixed-width default).
57+
58+
### Fase D — Hygiëne & observability (doorlopend)
59+
- **D1:** median-of-N + warm-up in de comparative-harness.
60+
61+
## 7. D2-bevindingen (env-gated fase-timers, 2026-09-03)
62+
DELETE 10K op de docs-tabel (comparative, Release) — attributie in de structured batch path:
63+
64+
| Fase | SQL | Direct |
65+
|---|---:|---:|
66+
| per-rij read+decode (`engine.Read`+`DeserializeDeleteKeyRow`) | 62-82 ms | ~66 ms |
67+
| commit-tombstones (markers, pread+pwrite per rij) | ~50 ms | ~48-50 ms |
68+
| delete core (index-onderhoud, B-tree/hash) | 23-56 ms | ~32 ms |
69+
| hash-index lookup | 5-10 ms | ~5 ms |
70+
71+
**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).
72+
73+
**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:
74+
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).
75+
2. **Marker-batching over een range-read** (commit-tombstones ~50 ms → enkele ms) als vervolg op C4.
76+
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.
77+
78+
- **D2:** `dotnet-trace`/fase-timers op `UpdateAffectedRows`, `DeleteMultipleKeys`, `DeleteRecordsCore`, commit-tombstones.
79+
- **D3:** AOT/R2R expliciet als aparte meet-as (we meten nu managed JIT + tiered PGO).
80+
- **D4:** `docs/manual/performance.md` + dit document bijwerken na elke stap.
81+
82+
## 5. Beslisboom (storage-engine)
83+
- Veel UPDATE/DELETE (OLTP) → **PageBased + FixedWidth** (in-place).
84+
- Veel appends/analytics/eventsourcing → **AppendOnly/Columnar** (blijft dominant).
85+
- Pure-throughput scenario’s → `NoEncryptMode`; encryptie blijft default-differentiator.
86+
87+
## 6. Volgende acties
88+
1. D2-profiel op de huidige DELETE/UPDATE hot paths.
89+
2. A1/A2 implementeren op basis van het profiel (branch bovenop #370).
90+
3. Fase B (fixed-width in-place + PageBased default) als aparte roadmap-track.

‎src/SharpCoreDB/DataStructures/Table.CRUD.cs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3101,6 +3101,30 @@ internal void DeleteMultipleKeys(List<(string Column, string Literal)> condition
31013101
var key = ParseValueForHashLookup(value, this.ColumnTypes[colIdx]);
31023102
if (key != null)
31033103
{
3104+
// W1: when this is the ONLY index the delete core must maintain (no
3105+
// PK tree, a single loaded hash index whose key is the condition
3106+
// value itself, no secondary B-tree manager), the position can be
3107+
// removed with the already-known key — no per-row engine.Read or row
3108+
// decode is needed for index cleanup.
3109+
bool keyOnlyRow =
3110+
this.PrimaryKeyIndex < 0 &&
3111+
this.hashIndexes.Count == 1 &&
3112+
_btreeManager is null &&
3113+
this.registeredIndexes.Count == 1;
3114+
3115+
if (keyOnlyRow)
3116+
{
3117+
foreach (var pos in hashIndex.LookupPositionsUnsafe(key))
3118+
{
3119+
recordsToDelete.Add((pos, new Dictionary<string, object>(1)
3120+
{
3121+
[col] = key
3122+
}));
3123+
}
3124+
3125+
continue;
3126+
}
3127+
31043128
foreach (var pos in hashIndex.LookupPositionsUnsafe(key))
31053129
{
31063130
var data = engine.Read(Name, pos);

‎src/SharpCoreDB/Database/Core/Database.Core.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ namespace SharpCoreDB;
3232
/// </summary>
3333
public partial class Database : IDatabase, IDisposable, IAsyncDisposable
3434
{
35+
// Diagnostics: number of DELETE statements routed through the canonical structured batch path
36+
// (proves the scanner/batch fast paths are engaged; 0 means everything fell back to regex).
37+
private static long _canonicalDeleteStatements;
38+
public long CanonicalDeleteStatementsParsed => Interlocked.Read(ref _canonicalDeleteStatements);
39+
3540
private readonly IServiceProvider _serviceProvider;
3641
private readonly IStorage storage;
3742
private readonly IUserService userService;

‎src/SharpCoreDB/Database/Execution/Database.Batch.cs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,18 @@ private static bool IsInsertStatement(string sql)
616616
return false;
617617
}
618618
}
619+
else
620+
{
621+
// DELETE: consume the whitespace after the table name, then the WHERE keyword, then
622+
// the whitespace before the WHERE column. (Missing before — every canonical DELETE fell
623+
// back to the regex path, bypassing the structured batch-DELETE fast paths.)
624+
if (!TryConsumeWhitespace(s, ref i) ||
625+
!TryConsumeKeyword(s, ref i, "WHERE") ||
626+
!TryConsumeWhitespace(s, ref i))
627+
{
628+
return false;
629+
}
630+
}
619631

620632
// WHERE <col> = <literal> (to end of statement)
621633
if (!TryReadSimpleIdent(s, ref i, out var whereColSpan) || whereColSpan.IsEmpty)
@@ -944,6 +956,7 @@ private bool TryParseDeleteForBatch(string sql, out string tableName, out string
944956
whereLiteral = whereValRaw;
945957
// Where stays empty for canonical statements — the table layer reconstructs it only
946958
// when a fallback actually needs it (B3: no per-statement string allocation).
959+
System.Threading.Interlocked.Increment(ref _canonicalDeleteStatements);
947960
return true;
948961
}
949962

‎tests/SharpCoreDB.Tests/FixedWidthBulkDeleteTests.cs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,31 @@ public void DeletesPersistAcrossReopen_AfterFlushCompaction()
231231
(db as IDisposable)?.Dispose();
232232
}
233233

234+
[Fact]
235+
public void CanonicalBatchDelete_EngagesStructuredPath()
236+
{
237+
// Regression: TryScanCanonicalDml's DELETE branch did not consume whitespace/WHERE after
238+
// the table name, so every canonical `DELETE ... WHERE col = literal` fell back to the
239+
// regex path and bypassed the structured batch-DELETE fast paths (DeleteMultipleKeys).
240+
IDatabase? db = CreateDb();
241+
try
242+
{
243+
db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)");
244+
InsertDocs(db, 1, 50);
245+
db.Flush();
246+
247+
var before = ((SharpCoreDB.Database)db).CanonicalDeleteStatementsParsed;
248+
db.ExecuteBatchSQL(BuildDeletes(1, 10));
249+
db.Flush();
250+
251+
Assert.True(
252+
((SharpCoreDB.Database)db).CanonicalDeleteStatementsParsed >= before + 10,
253+
"canonical DELETE statements must flow through the structured batch path");
254+
Assert.Equal(40, db.ExecuteQuery("SELECT id FROM docs").Count);
255+
}
256+
finally { (db as IDisposable)?.Dispose(); }
257+
}
258+
234259
[Fact]
235260
public void BatchDelete_CommitTimeTombstones_SurviveReopenWithoutExplicitFlush()
236261
{

0 commit comments

Comments
 (0)