Skip to content

Commit a08db3a

Browse files
Merge pull request #360 from MPCoreDeveloper/perf/bulk-contiguous-update
perf: single-pass contiguous UPDATE for plaintext fixed-width tables
2 parents fa6f235 + 164d791 commit a08db3a

5 files changed

Lines changed: 521 additions & 0 deletions

File tree

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

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ namespace SharpCoreDB.DataStructures;
22

33
using System;
44
using System.Collections.Generic;
5+
using System.Globalization;
56
using System.Runtime.CompilerServices;
67
using System.Threading;
78
using System.Buffers;
@@ -1931,6 +1932,16 @@ internal void UpdateMultiple(List<(string where, Dictionary<string, object> upda
19311932
// remove the stale record from all indexes (unloaded indexes would later be rebuilt
19321933
// from the file INCLUDING the stale record).
19331934
EnsureAllRegisteredIndexesLoaded();
1935+
1936+
// B8: single-pass contiguous UPDATE — when every operation is a `pk = <literal>` match on a
1937+
// plaintext fixed-width table with physically adjacent PK-ordered records, the old records
1938+
// are read as ONE contiguous byte range and patched in memory (no per-row pread). Strictly
1939+
// gated; any mismatch falls back to the generic per-row loop below.
1940+
if (TryBulkUpdateContiguousFixedWidth(engine, operations))
1941+
{
1942+
return;
1943+
}
1944+
19341945
int appendedInBatch = 0; // only appends create stale versions that need compaction
19351946

19361947
foreach (var (where, updates) in operations)
@@ -2316,6 +2327,222 @@ oldHashValues is not null &&
23162327
}
23172328
}
23182329

2330+
/// <summary>
2331+
/// B8: number of UPDATE batches processed by the contiguous single-pass fast path (diagnostics
2332+
/// used by tests to prove the path engages; zero means every batch fell back to the generic loop).
2333+
/// </summary>
2334+
public long BulkContiguousUpdateBatches => Interlocked.Read(ref _bulkContiguousUpdateBatches);
2335+
2336+
private long _bulkContiguousUpdateBatches;
2337+
2338+
/// <summary>
2339+
/// B8: single-pass contiguous UPDATE fast path for plaintext fixed-width tables. Requires every
2340+
/// operation to be a simple <c>pk = &lt;numeric literal&gt;</c> match on the primary key, with keys
2341+
/// strictly increasing AND records physically adjacent in the data file (the fixed-width layout
2342+
/// makes the on-disk stride constant: <c>[4-byte length][FixedSize payload]</c>). When all
2343+
/// conditions hold the target records are read as one contiguous byte range, patched in memory
2344+
/// (field-level in-place patch, exactly like the generic row path) and written back through the
2345+
/// same buffered same-length overwrite. Any mismatch returns <see langword="false"/> so the caller
2346+
/// falls back to the generic per-row loop — no records are touched before the range is verified.
2347+
/// </summary>
2348+
private bool TryBulkUpdateContiguousFixedWidth(
2349+
IStorageEngine engine,
2350+
List<(string where, Dictionary<string, object> updates)> operations)
2351+
{
2352+
int count = operations.Count;
2353+
if (count < 2)
2354+
{
2355+
return false;
2356+
}
2357+
2358+
// Narrow, conservative gate: fixed-width columnar table with an explicit PK, plaintext
2359+
// records only (a raw contiguous read must equal the logical record bytes), no buffered
2360+
// overwrites for this file, and no CHECK constraints (mirrors the generic fastPatch gate).
2361+
if (!_fixedWidthRecords ||
2362+
StorageMode != StorageMode.Columnar ||
2363+
this.PrimaryKeyIndex < 0 ||
2364+
this.TableCheckConstraints.Count > 0 ||
2365+
HasColumnCheckConstraints() ||
2366+
this.storage is null ||
2367+
this._config is not { NoEncryptMode: true } ||
2368+
this.storage.HasBufferedOverwrite(DataFile))
2369+
{
2370+
return false;
2371+
}
2372+
2373+
var pkName = this.Columns[this.PrimaryKeyIndex];
2374+
var layout = GetFixedWidthLayout();
2375+
long stride = 4L + layout.FixedSize;
2376+
2377+
var positions = new long[count];
2378+
var keys = new string[count];
2379+
var repointColumns = new List<int>?[count];
2380+
long parsedPrev = 0;
2381+
2382+
for (int i = 0; i < count; i++)
2383+
{
2384+
var (where, updates) = operations[i];
2385+
if (string.IsNullOrEmpty(where) ||
2386+
!TryParseSimpleWhereClause(where, out var whereCol, out var whereVal) ||
2387+
!string.Equals(whereCol, pkName, StringComparison.OrdinalIgnoreCase) ||
2388+
whereVal is null)
2389+
{
2390+
return false;
2391+
}
2392+
2393+
var keyStr = whereVal.ToString();
2394+
if (string.IsNullOrEmpty(keyStr))
2395+
{
2396+
return false;
2397+
}
2398+
2399+
foreach (var updateKey in updates.Keys)
2400+
{
2401+
// Resolve the column; unknown columns must fall back so the generic loop surfaces the error.
2402+
int colIdx = -1;
2403+
for (int c = 0; c < this.Columns.Count; c++)
2404+
{
2405+
if (this.Columns[c].Equals(updateKey, StringComparison.OrdinalIgnoreCase))
2406+
{
2407+
colIdx = c;
2408+
break;
2409+
}
2410+
}
2411+
2412+
if (colIdx < 0)
2413+
{
2414+
return false;
2415+
}
2416+
2417+
// Updating the PK itself must fall back (the PK B-tree position must be re-pointed).
2418+
if (colIdx == this.PrimaryKeyIndex)
2419+
{
2420+
return false;
2421+
}
2422+
2423+
// Hash-indexed SET columns need their entries re-pointed after the in-place write.
2424+
// Fixed-size values decode from the raw slot cheaply; variable-length (TEXT/BLOB)
2425+
// indexed columns would need an overflow-arena read, so they fall back to the
2426+
// generic per-row loop.
2427+
if (this.hashIndexes.ContainsKey(this.Columns[colIdx]))
2428+
{
2429+
if (layout.IsVariable[colIdx])
2430+
{
2431+
return false;
2432+
}
2433+
2434+
(repointColumns[i] ??= new List<int>(2)).Add(colIdx);
2435+
}
2436+
}
2437+
2438+
// Strictly increasing numeric keys keep the physical records adjacent for fixed-width.
2439+
if (!long.TryParse(keyStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out long key))
2440+
{
2441+
return false;
2442+
}
2443+
2444+
if (i > 0 && key <= parsedPrev)
2445+
{
2446+
return false;
2447+
}
2448+
2449+
parsedPrev = key;
2450+
keys[i] = keyStr;
2451+
}
2452+
2453+
// Resolve the first record position through the PK B-tree, then require every later key to
2454+
// sit at the expected contiguous offset (physical adjacency). Verifying every position keeps
2455+
// the gate sound even when earlier appends/deletes shifted records.
2456+
var first = this.Index.Search(keys[0]);
2457+
if (!first.Found)
2458+
{
2459+
return false;
2460+
}
2461+
2462+
long basePosition = first.Value;
2463+
positions[0] = basePosition;
2464+
long expected = basePosition;
2465+
for (int i = 1; i < count; i++)
2466+
{
2467+
expected += stride;
2468+
var search = this.Index.Search(keys[i]);
2469+
if (!search.Found || search.Value != expected)
2470+
{
2471+
return false;
2472+
}
2473+
2474+
positions[i] = expected;
2475+
}
2476+
2477+
// Read the whole contiguous span in ONE range read (plaintext records only — enforced above).
2478+
long totalBytes = stride * count;
2479+
if (totalBytes <= 0 || totalBytes > int.MaxValue)
2480+
{
2481+
return false;
2482+
}
2483+
2484+
var raw = this.storage.ReadBytesRange(DataFile, basePosition, (int)totalBytes);
2485+
if (raw is null || raw.Length < totalBytes)
2486+
{
2487+
return false;
2488+
}
2489+
2490+
// Verify every 4-byte length prefix matches the fixed record size BEFORE touching anything.
2491+
for (int i = 0; i < count; i++)
2492+
{
2493+
int prefix = BinaryPrimitives.ReadInt32LittleEndian(raw.AsSpan((int)(i * stride), 4));
2494+
if (prefix != layout.FixedSize)
2495+
{
2496+
return false;
2497+
}
2498+
}
2499+
2500+
// Patch and write each record in place (buffered by the storage layer; flushed at commit).
2501+
for (int i = 0; i < count; i++)
2502+
{
2503+
var payload = new byte[layout.FixedSize];
2504+
raw.AsSpan((int)(i * stride) + 4, layout.FixedSize).CopyTo(payload);
2505+
2506+
var patched = TryOverwriteFixedWidthInPlace(payload, operations[i].updates);
2507+
if (patched is null || !engine.TryUpdateInPlaceSameLength(Name, positions[i], patched))
2508+
{
2509+
return false;
2510+
}
2511+
2512+
// Re-point hash-index entries for every fixed-size indexed SET column (mirrors the
2513+
// generic fastPatch path: old value decoded from the pre-write record, new value at the
2514+
// same position).
2515+
var repoints = repointColumns[i];
2516+
if (repoints is { Count: > 0 })
2517+
{
2518+
foreach (var colIdx in repoints)
2519+
{
2520+
var colName = this.Columns[colIdx];
2521+
if (!this.hashIndexes.TryGetValue(colName, out var hashIdx))
2522+
{
2523+
continue;
2524+
}
2525+
2526+
var slot = payload.AsSpan(layout.Offsets[colIdx], layout.SlotSizes[colIdx]);
2527+
var oldVal = ReadTypedValueFromSpan(slot, this.ColumnTypes[colIdx], out _);
2528+
if (oldVal is not null)
2529+
{
2530+
hashIdx.Remove(oldVal, positions[i]);
2531+
}
2532+
2533+
var newVal = operations[i].updates[colName];
2534+
if (newVal is not null)
2535+
{
2536+
hashIdx.Add(newVal, positions[i]);
2537+
}
2538+
}
2539+
}
2540+
}
2541+
2542+
Interlocked.Increment(ref _bulkContiguousUpdateBatches);
2543+
return true;
2544+
}
2545+
23192546
/// <summary>
23202547
/// True when any column carries a CHECK expression (the batch fast-patch path is disabled in
23212548
/// that case because a CHECK may read non-updated columns).

‎src/SharpCoreDB/Interfaces/IStorage.cs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ public interface IStorage
113113
bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) =>
114114
OverwriteRecordAt(path, offset, data);
115115

116+
/// <summary>
117+
/// True when an in-place overwrite is currently buffered for <paramref name="path"/> (the
118+
/// transaction write-behind buffer overlays reads of those offsets). Callers that bypass the
119+
/// per-record read path must check this first so they never read stale disk bytes.
120+
/// </summary>
121+
bool HasBufferedOverwrite(string path) => false;
122+
116123
/// <summary>
117124
/// Appends multiple binary data blocks to a file in a single batch operation (used for batch inserts).
118125
/// </summary>
@@ -148,6 +155,14 @@ bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) =>
148155
/// <returns>The read data, or null if file does not exist or position is invalid.</returns>
149156
byte[]? ReadBytesAt(string path, long position, int maxLength, bool noEncrypt);
150157

158+
/// <summary>
159+
/// Reads a raw contiguous byte range starting at <paramref name="offset"/> using the storage
160+
/// layer's cached file handle (no per-call handle open). Used by the fixed-width contiguous
161+
/// UPDATE fast path, which only engages on plaintext files. Implementations that cannot serve a
162+
/// raw range (encrypted layouts, mocks) return null so the caller falls back to per-record reads.
163+
/// </summary>
164+
byte[]? ReadBytesRange(string path, long offset, int length) => null;
165+
151166
/// <summary>
152167
/// Enumerates every record in a table data file, yielding the literal file offset of the
153168
/// 4-byte length prefix (the offset returned by <see cref="AppendBytes"/>) together with the

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,10 @@ public bool OverwriteRecordAtSameLength(string path, long offset, byte[] data)
556556
return BufferOrWriteOverwriteInPlace(path, offset, record);
557557
}
558558

559+
/// <inheritdoc />
560+
public bool HasBufferedOverwrite(string path) =>
561+
!bufferedOverwrites.IsEmpty && bufferedOverwrites.ContainsKey(path);
562+
559563
/// <inheritdoc />
560564
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
561565
public long[] AppendBytesMultiple(string path, List<byte[]> dataBlocks)

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
namespace SharpCoreDB.Services;
77

8+
using Microsoft.Win32.SafeHandles;
89
using System;
910
using System.IO;
1011
using System.Runtime.CompilerServices;
@@ -59,6 +60,48 @@ public partial class Storage
5960
return ReadBytesAt(path, position, maxLength, false);
6061
}
6162

63+
/// <inheritdoc />
64+
public byte[]? ReadBytesRange(string path, long offset, int length)
65+
{
66+
if (length <= 0 || length > 512 * 1024 * 1024)
67+
{
68+
return null;
69+
}
70+
71+
SafeFileHandle handle;
72+
try
73+
{
74+
handle = GetOrOpenReadHandle(path);
75+
}
76+
catch
77+
{
78+
_readHandleCache.TryRemove(path, out _);
79+
try
80+
{
81+
handle = GetOrOpenReadHandle(path);
82+
}
83+
catch
84+
{
85+
return null;
86+
}
87+
}
88+
89+
var buffer = new byte[length];
90+
int total = 0;
91+
while (total < length)
92+
{
93+
int read = RandomAccess.Read(handle, buffer.AsSpan(total), offset + total);
94+
if (read <= 0)
95+
{
96+
return null;
97+
}
98+
99+
total += read;
100+
}
101+
102+
return buffer;
103+
}
104+
62105
/// <summary>
63106
/// Loads a page from disk into a byte array for caching.
64107
/// </summary>

0 commit comments

Comments
 (0)