diff --git a/src/SharpCoreDB/DataStructures/Table.CRUD.cs b/src/SharpCoreDB/DataStructures/Table.CRUD.cs index d328f09c..7be42a7d 100644 --- a/src/SharpCoreDB/DataStructures/Table.CRUD.cs +++ b/src/SharpCoreDB/DataStructures/Table.CRUD.cs @@ -1946,6 +1946,13 @@ internal void UpdateMultiple(List<(string where, Dictionary upda // from the file INCLUDING the stale record). EnsureAllRegisteredIndexesLoaded(); + // Whole-file snapshot for the per-row fastPatch reads below (same guard as the DELETE + // path): reading the small plaintext file once replaces one pread pair per updated row. + // A position that already has a buffered in-place overwrite in this transaction is + // NEVER served from the snapshot (its disk bytes would be stale) — it falls back to the + // per-record read, which honors the write-behind buffer. + byte[]? wholeFile = TryLoadWholeFileForRowAccess(); + // B8: single-pass contiguous UPDATE — when every operation is a `pk = ` match on a // plaintext fixed-width table with physically adjacent PK-ordered records, the old records // 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 upda var fastSearch = this.Index.Search(pkWhereVal?.ToString() ?? string.Empty); if (fastSearch.Found) { - var fastData = engine.Read(Name, fastSearch.Value); + byte[]? fastData; + if (fastPatch && wholeFile != null && + (this.storage is null || !this.storage.HasBufferedOverwriteAt(DataFile, fastSearch.Value))) + { + fastData = TrySlicePayloadFromFile(wholeFile, fastSearch.Value); + } + else + { + fastData = engine.Read(Name, fastSearch.Value); + } + if (fastData != null) { rows = fastPatch @@ -2036,7 +2053,17 @@ internal void UpdateMultiple(List<(string where, Dictionary upda rows = []; foreach (var pos in hashIndex.LookupPositionsUnsafe(key)) { - var data = engine.Read(Name, pos); + byte[]? data; + if (fastPatch && wholeFile != null && + (this.storage is null || !this.storage.HasBufferedOverwriteAt(DataFile, pos))) + { + data = TrySlicePayloadFromFile(wholeFile, pos); + } + else + { + data = engine.Read(Name, pos); + } + if (data != null) { if (fastPatch) @@ -3064,7 +3091,7 @@ internal void DeleteMultipleKeys(List<(string Column, string Literal)> condition // A1: when the (plaintext, legacy variable-length) data file is small enough, read it // ONCE and resolve every target from memory instead of one pread pair per deleted row. - byte[]? wholeFile = TryLoadWholeFileForDeleteResolution(); + byte[]? wholeFile = TryLoadWholeFileForRowAccess(); var recordsToDelete = new List<(long storagePosition, Dictionary row)>(); @@ -3317,7 +3344,7 @@ private int[] BuildDeleteKeyColumns() // per deleted row. Guarded to plaintext legacy variable-length files below this size. private const long WholeFileDeleteResolutionLimitBytes = 32 * 1024 * 1024; - private byte[]? TryLoadWholeFileForDeleteResolution() + private byte[]? TryLoadWholeFileForRowAccess() { if (_fixedWidthRecords || this.storage is null || @@ -3351,6 +3378,29 @@ this.storage is null || return DeserializeDeleteKeyRow(wholeFile.AsSpan((int)position + 4, recordLength), wanted); } + /// + /// Copies the raw (plaintext) payload of the record at out of a + /// whole-file snapshot. Returns null when the position/length is not fully contained in the + /// snapshot (caller falls back to the per-record read). + /// + private byte[]? TrySlicePayloadFromFile(byte[] wholeFile, long position) + { + if (position < 0 || position + 4 > wholeFile.Length) + { + return null; + } + + int recordLength = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(wholeFile.AsSpan((int)position, 4)); + if (recordLength <= 0 || position + 4 + recordLength > wholeFile.Length) + { + return null; + } + + var payload = new byte[recordLength]; + wholeFile.AsSpan((int)position + 4, recordLength).CopyTo(payload); + return payload; + } + /// /// Shared B8/B9 probe: resolves each key's record position through the PK B-tree, requires the /// positions to be physically adjacent at the fixed-width stride, reads the whole contiguous diff --git a/src/SharpCoreDB/Interfaces/IStorage.cs b/src/SharpCoreDB/Interfaces/IStorage.cs index dd317aa7..d617bece 100644 --- a/src/SharpCoreDB/Interfaces/IStorage.cs +++ b/src/SharpCoreDB/Interfaces/IStorage.cs @@ -120,6 +120,15 @@ bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) => /// bool HasBufferedOverwrite(string path) => false; + /// + /// True when an in-place overwrite is currently buffered for exactly + /// of . Used by whole-file row-resolution fast paths (DELETE/UPDATE) to + /// decide whether a disk snapshot slice of a position is fresh or must go through the + /// per-record read path (which honors the write-behind buffer). Defaults to false (mock/ + /// alternative storages have no write-behind buffer to bypass). + /// + bool HasBufferedOverwriteAt(string path, long offset) => false; + /// /// Appends multiple binary data blocks to a file in a single batch operation (used for batch inserts). /// diff --git a/src/SharpCoreDB/Services/Storage.Append.cs b/src/SharpCoreDB/Services/Storage.Append.cs index 576cea8f..d8b8a93e 100644 --- a/src/SharpCoreDB/Services/Storage.Append.cs +++ b/src/SharpCoreDB/Services/Storage.Append.cs @@ -566,6 +566,10 @@ public bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) public bool HasBufferedOverwrite(string path) => !bufferedOverwrites.IsEmpty && bufferedOverwrites.ContainsKey(path); + /// + public bool HasBufferedOverwriteAt(string path, long offset) => + bufferedOverwrites.TryGetValue(path, out var overwrites) && overwrites.ContainsKey(offset); + /// public bool AreRecordsEncrypted(string path) => UseRecordEncryption && FileHasEncryptedHeader(path);