Skip to content

Commit 26e36db

Browse files
author
MPCoreDeveloper
committed
perf: whole-file resolution for batch UPDATE fastPatch reads
UpdateMultiple loads the whole plaintext legacy variable-length file once (<=32MB) and serves the fastPatch raw-row reads from that snapshot (one slice copy per row) instead of a pread pair per updated row. Positions that already carry a buffered in-place overwrite in this transaction are detected via the new IStorage.HasBufferedOverwriteAt and always fall back to the per-record read (which honors the write-behind buffer), so in-batch repeated updates stay correct. Measured (Release, comparative UPDATE 10K of ~100K rows, median of 3): SQL ~35K -> ~47K ops/s, Direct ~49K -> ~66K ops/s.
1 parent d31e4be commit 26e36db

3 files changed

Lines changed: 67 additions & 4 deletions

File tree

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

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,6 +1946,13 @@ internal void UpdateMultiple(List<(string where, Dictionary<string, object> upda
19461946
// from the file INCLUDING the stale record).
19471947
EnsureAllRegisteredIndexesLoaded();
19481948

1949+
// Whole-file snapshot for the per-row fastPatch reads below (same guard as the DELETE
1950+
// path): reading the small plaintext file once replaces one pread pair per updated row.
1951+
// A position that already has a buffered in-place overwrite in this transaction is
1952+
// NEVER served from the snapshot (its disk bytes would be stale) — it falls back to the
1953+
// per-record read, which honors the write-behind buffer.
1954+
byte[]? wholeFile = TryLoadWholeFileForRowAccess();
1955+
19491956
// B8: single-pass contiguous UPDATE — when every operation is a `pk = <literal>` match on a
19501957
// plaintext fixed-width table with physically adjacent PK-ordered records, the old records
19511958
// are read as ONE contiguous byte range and patched in memory (no per-row pread). Strictly
@@ -2007,7 +2014,17 @@ internal void UpdateMultiple(List<(string where, Dictionary<string, object> upda
20072014
var fastSearch = this.Index.Search(pkWhereVal?.ToString() ?? string.Empty);
20082015
if (fastSearch.Found)
20092016
{
2010-
var fastData = engine.Read(Name, fastSearch.Value);
2017+
byte[]? fastData;
2018+
if (fastPatch && wholeFile != null &&
2019+
(this.storage is null || !this.storage.HasBufferedOverwriteAt(DataFile, fastSearch.Value)))
2020+
{
2021+
fastData = TrySlicePayloadFromFile(wholeFile, fastSearch.Value);
2022+
}
2023+
else
2024+
{
2025+
fastData = engine.Read(Name, fastSearch.Value);
2026+
}
2027+
20112028
if (fastData != null)
20122029
{
20132030
rows = fastPatch
@@ -2036,7 +2053,17 @@ internal void UpdateMultiple(List<(string where, Dictionary<string, object> upda
20362053
rows = [];
20372054
foreach (var pos in hashIndex.LookupPositionsUnsafe(key))
20382055
{
2039-
var data = engine.Read(Name, pos);
2056+
byte[]? data;
2057+
if (fastPatch && wholeFile != null &&
2058+
(this.storage is null || !this.storage.HasBufferedOverwriteAt(DataFile, pos)))
2059+
{
2060+
data = TrySlicePayloadFromFile(wholeFile, pos);
2061+
}
2062+
else
2063+
{
2064+
data = engine.Read(Name, pos);
2065+
}
2066+
20402067
if (data != null)
20412068
{
20422069
if (fastPatch)
@@ -3064,7 +3091,7 @@ internal void DeleteMultipleKeys(List<(string Column, string Literal)> condition
30643091

30653092
// A1: when the (plaintext, legacy variable-length) data file is small enough, read it
30663093
// ONCE and resolve every target from memory instead of one pread pair per deleted row.
3067-
byte[]? wholeFile = TryLoadWholeFileForDeleteResolution();
3094+
byte[]? wholeFile = TryLoadWholeFileForRowAccess();
30683095

30693096
var recordsToDelete = new List<(long storagePosition, Dictionary<string, object> row)>();
30703097

@@ -3317,7 +3344,7 @@ private int[] BuildDeleteKeyColumns()
33173344
// per deleted row. Guarded to plaintext legacy variable-length files below this size.
33183345
private const long WholeFileDeleteResolutionLimitBytes = 32 * 1024 * 1024;
33193346

3320-
private byte[]? TryLoadWholeFileForDeleteResolution()
3347+
private byte[]? TryLoadWholeFileForRowAccess()
33213348
{
33223349
if (_fixedWidthRecords ||
33233350
this.storage is null ||
@@ -3351,6 +3378,29 @@ this.storage is null ||
33513378
return DeserializeDeleteKeyRow(wholeFile.AsSpan((int)position + 4, recordLength), wanted);
33523379
}
33533380

3381+
/// <summary>
3382+
/// Copies the raw (plaintext) payload of the record at <paramref name="position"/> out of a
3383+
/// whole-file snapshot. Returns null when the position/length is not fully contained in the
3384+
/// snapshot (caller falls back to the per-record read).
3385+
/// </summary>
3386+
private byte[]? TrySlicePayloadFromFile(byte[] wholeFile, long position)
3387+
{
3388+
if (position < 0 || position + 4 > wholeFile.Length)
3389+
{
3390+
return null;
3391+
}
3392+
3393+
int recordLength = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(wholeFile.AsSpan((int)position, 4));
3394+
if (recordLength <= 0 || position + 4 + recordLength > wholeFile.Length)
3395+
{
3396+
return null;
3397+
}
3398+
3399+
var payload = new byte[recordLength];
3400+
wholeFile.AsSpan((int)position + 4, recordLength).CopyTo(payload);
3401+
return payload;
3402+
}
3403+
33543404
/// <summary>
33553405
/// Shared B8/B9 probe: resolves each key's record position through the PK B-tree, requires the
33563406
/// positions to be physically adjacent at the fixed-width stride, reads the whole contiguous

‎src/SharpCoreDB/Interfaces/IStorage.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) =>
120120
/// </summary>
121121
bool HasBufferedOverwrite(string path) => false;
122122

123+
/// <summary>
124+
/// True when an in-place overwrite is currently buffered for exactly <paramref name="offset"/>
125+
/// of <paramref name="path"/>. Used by whole-file row-resolution fast paths (DELETE/UPDATE) to
126+
/// decide whether a disk snapshot slice of a position is fresh or must go through the
127+
/// per-record read path (which honors the write-behind buffer). Defaults to false (mock/
128+
/// alternative storages have no write-behind buffer to bypass).
129+
/// </summary>
130+
bool HasBufferedOverwriteAt(string path, long offset) => false;
131+
123132
/// <summary>
124133
/// Appends multiple binary data blocks to a file in a single batch operation (used for batch inserts).
125134
/// </summary>

‎src/SharpCoreDB/Services/Storage.Append.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,10 @@ public bool OverwriteRecordAtSameLength(string path, long offset, byte[] data)
566566
public bool HasBufferedOverwrite(string path) =>
567567
!bufferedOverwrites.IsEmpty && bufferedOverwrites.ContainsKey(path);
568568

569+
/// <inheritdoc />
570+
public bool HasBufferedOverwriteAt(string path, long offset) =>
571+
bufferedOverwrites.TryGetValue(path, out var overwrites) && overwrites.ContainsKey(offset);
572+
569573
/// <inheritdoc />
570574
public bool AreRecordsEncrypted(string path) => UseRecordEncryption && FileHasEncryptedHeader(path);
571575

0 commit comments

Comments
 (0)