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
8 changes: 6 additions & 2 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **DELETE now survives a reopen (durability)** ÔÇö Columnar deletes were logical only (index removal),
so the on-load PK-index rebuild resurrected deleted rows from the untouched `.dat`. Logically
deleted rows are now counted (`_pendingLogicalDeletes`) and physically compacted at flush/dispose
(`Table.CompactPendingDeletes`, outside a transaction, Columnar tables with a PK). Regression:
delete half the rows, `Flush`, reopen ÔÇö exactly the remaining rows come back.
(`Table.CompactPendingDeletes`, outside a transaction, Columnar tables with a PK). Flush
compaction rewrites only the data file (live PK positions via B-tree traversal, single-pass index
rebuild) so the cost is proportional to the remaining rows (~0.4s for a 90K-live table); the
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).
- **Dedicated SQL batch-INSERT fast path (WP14)** ÔÇö `ExecuteBatchSQL` INSERTs no longer build a
per-row `Dictionary<string, object>`; VALUES clauses are parsed directly into column-ordered
`object[]` rows (`PreparedInsertStatement.ParseValuesToArray`) and inserted via the new
Expand Down
132 changes: 104 additions & 28 deletions src/SharpCoreDB/DataStructures/Table.Compaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@
/// Physically removes rows that were logically deleted since the last flush (Columnar tables
/// with a primary key) so DELETE survives a reopen — the on-load PK-index rebuild would otherwise
/// resurrect them from the untouched <c>.dat</c>. Runs synchronously at flush/dispose, outside a
/// transaction, when any logical deletes are pending.
/// transaction, when any logical deletes are pending. Data-file only: the overflow arena is left
/// untouched (its space is reclaimed by a later explicit compaction/VACUUM) so the flush stays
/// proportional to the rewritten live rows.
/// </summary>
public void CompactPendingDeletes()
{
Expand All @@ -71,13 +73,35 @@
return; // defer until the transaction commits (the next flush will run again)
}

rwLock.EnterWriteLock();
try
{
CompactStorage();
var engine = GetOrCreateStorageEngine();
if (engine is not AppendOnlyEngine appendEngine)
{
return;
}

var activePositions = new List<long>();
if (this.Index is BTree<string, long> pkTree)
{
foreach (var (_, position) in pkTree.InOrderTraversal())
{
activePositions.Add(position);
}
}
else
{
return; // no enumerable PK tree — cannot rewrite safely; keep logical deletes
}

appendEngine.CompactTable(Name, activePositions);
RebuildAllIndexesFromFile();
}
finally
{
Interlocked.Exchange(ref _pendingLogicalDeletes, 0);
rwLock.ExitWriteLock();
}
}

Expand Down Expand Up @@ -108,24 +132,30 @@

if (PrimaryKeyIndex >= 0)
{
// Collect all positions from primary key index.
// ✅ FIX (1.9.5): Include the hidden _rowid column when present — otherwise rows in
// tables with an internal ULID primary key cannot be resolved to storage positions
// and compaction would drop every row.
var pkColumn = Columns[PrimaryKeyIndex];
var allRows = HasInternalRowId
? SelectIncludingRowId(where: null, orderBy: null, asc: true, noEncrypt: false)
: Select();

foreach (var row in allRows)
if (this.Index is BTree<string, long> pkTree)
{
// Collect the live (key → position) pairs straight from the PK B-tree — no row
// materialization and no per-row re-search. Covers the hidden _rowid PK too,
// because every live row has an entry in the tree.
foreach (var (_, position) in pkTree.InOrderTraversal())
{
activePositions.Add(position);
}
}
else
{
if (row.TryGetValue(pkColumn, out var pkValue) && pkValue != null)
// Non-BTree index fallback: resolve positions through the current rows.
var pkColumn = Columns[PrimaryKeyIndex];
var allRows = SelectIncludingRowId(where: null, orderBy: null, asc: true, noEncrypt: false);
foreach (var row in allRows)
{
var pkStr = pkValue.ToString() ?? string.Empty;
var searchResult = Index.Search(pkStr);
if (searchResult.Found)
if (row.TryGetValue(pkColumn, out var pkValue) && pkValue != null)
{
activePositions.Add(searchResult.Value);
var searchResult = Index.Search(pkValue.ToString() ?? string.Empty);
if (searchResult.Found)
{
activePositions.Add(searchResult.Value);
}
}
}
}
Expand All @@ -150,17 +180,13 @@
// Reset counters
Interlocked.Exchange(ref _deletedRowCount, 0);
Interlocked.Exchange(ref _updatedRowCount, 0);

// Rebuild primary key index with new positions
// Note: After compaction, positions change! We need to rebuild the index.
RebuildPrimaryKeyIndex();

// Rebuild hash indexes
foreach (var col in loadedIndexes.ToList())
{
RebuildHashIndex(col);
}

Interlocked.Exchange(ref _pendingLogicalDeletes, 0);

// Rebuild the PK B-tree and every loaded hash index in ONE file pass (positions change
// after compaction; a per-index rescan would re-read + re-decode the whole file once
// per index, which is pathological on large tables).
RebuildAllIndexesFromFile();

return new CompactionStats
{
BytesReclaimed = bytesReclaimed,
Expand Down Expand Up @@ -235,6 +261,56 @@
private static byte[]? RepointVariableSlots(byte[] record, FixedWidthRecordLayout layout, Dictionary<long, long> mapping)
=> FixedWidthCodec.RepointVariableSlots(record, layout, mapping);

/// <summary>
/// Rebuilds the PK B-tree and every loaded hash index from the rewritten data file in ONE pass:
/// each record is read and decoded once and feeds all indexes, instead of rescanning the whole
/// file (with full deserialization, including overflow-arena reads) once per index.
/// </summary>
private void RebuildAllIndexesFromFile()
{
var engine = GetOrCreateStorageEngine();

if (PrimaryKeyIndex >= 0)
{
Index = new BTree<string, long>();
}

foreach (var hashIndex in hashIndexes.Values)
{
hashIndex.Clear();
}

var loadedHashIndexes = new List<HashIndex>();
foreach (var kvp in hashIndexes)

Check warning on line 284 in src/SharpCoreDB/DataStructures/Table.Compaction.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Loops should be simplified using the "Where" LINQ method

See more on https://sonarcloud.io/project/issues?id=MPCoreDeveloper_SharpCoreDB&issues=AaBnylCOBRb6xlOCM0q-&open=AaBnylCOBRb6xlOCM0q-&pullRequest=366
{
if (loadedIndexes.Contains(kvp.Key))
{
loadedHashIndexes.Add(kvp.Value);
}
}

foreach (var (position, data) in engine.GetAllRecords(Name))
{
var row = DeserializeRow(data);
if (row is null)
{
continue;
}

if (PrimaryKeyIndex >= 0 &&
row.TryGetValue(Columns[PrimaryKeyIndex], out var pkValue) &&
pkValue != null)
{
Index.Insert(pkValue.ToString() ?? string.Empty, position);
}

foreach (var hashIndex in loadedHashIndexes)
{
hashIndex.Add(row, position);
}
}
}

/// <summary>
/// Rebuilds the primary key index after compaction.
/// Positions change after compaction, so we need to rescan the file.
Expand Down
Loading