diff --git a/src/SharpCoreDB/DataStructures/Table.CRUD.cs b/src/SharpCoreDB/DataStructures/Table.CRUD.cs index 93a95fac..fdaee9fd 100644 --- a/src/SharpCoreDB/DataStructures/Table.CRUD.cs +++ b/src/SharpCoreDB/DataStructures/Table.CRUD.cs @@ -1927,6 +1927,22 @@ internal void UpdateMultiple(List<(string where, Dictionary upda this.TableCheckConstraints.Count == 0 && !HasColumnCheckConstraints(); + // The raw-byte patch writes the record in place without re-pointing hash indexes, so + // when the update touches a hash-indexed column those entries must be re-pointed + // explicitly (old key removed, new key added at the same position) after the write. + bool touchesHashIndexedColumn = false; + if (this.hashIndexes.Count > 0) + { + foreach (var updateKey in updates.Keys) + { + if (this.hashIndexes.ContainsKey(updateKey)) + { + touchesHashIndexedColumn = true; + break; + } + } + } + // Resolve matching rows as (storage position, row, raw bytes) so the columnar // write path can patch fields in place even when the table has no primary key. // The position comes from the hash index / PK lookup already performed here; in @@ -2039,8 +2055,34 @@ internal void UpdateMultiple(List<(string where, Dictionary upda ? TryOverwriteFixedWidthInPlace(rawData, updates) : TryOverwriteFieldsInPlaceActual(rawData, updates); - if (patched is not null && engine.TryUpdateInPlace(Name, rowPosition, patched)) + if (patched is not null && engine.TryUpdateInPlaceSameLength(Name, rowPosition, patched)) { + // The record was overwritten in place; when the update changed a + // hash-indexed column, re-point its entries (old key decoded from the + // pre-write row bytes, new key added at the same position). Non-indexed + // updates skip this entirely. + if (touchesHashIndexedColumn) + { + var oldRow = DeserializeRow(rawData); + if (oldRow is not null) + { + foreach (var (colName, hashIdx) in this.hashIndexes) + { + if (!updates.TryGetValue(colName, out var newVal) || newVal is null) + { + continue; + } + + if (oldRow.TryGetValue(colName, out var oldVal) && oldVal is not null) + { + hashIdx.Remove(oldVal, rowPosition); + } + + hashIdx.Add(newVal, rowPosition); + } + } + } + continue; } diff --git a/src/SharpCoreDB/Interfaces/IStorage.cs b/src/SharpCoreDB/Interfaces/IStorage.cs index abf8965f..03b27a39 100644 --- a/src/SharpCoreDB/Interfaces/IStorage.cs +++ b/src/SharpCoreDB/Interfaces/IStorage.cs @@ -104,6 +104,15 @@ public interface IStorage /// bool OverwriteRecordAt(string path, long offset, byte[] data); + /// + /// Like for the case where the caller guarantees + /// has the same byte length as the stored record payload (an in-place + /// field patch built from the existing row). Implementations may skip the length-prefix + /// read/verification; the default routes to . + /// + bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) => + OverwriteRecordAt(path, offset, data); + /// /// Appends multiple binary data blocks to a file in a single batch operation (used for batch inserts). /// diff --git a/src/SharpCoreDB/Interfaces/IStorageEngine.cs b/src/SharpCoreDB/Interfaces/IStorageEngine.cs index 49a7a11e..6c2262cc 100644 --- a/src/SharpCoreDB/Interfaces/IStorageEngine.cs +++ b/src/SharpCoreDB/Interfaces/IStorageEngine.cs @@ -55,6 +55,16 @@ public interface IStorageEngine : IDisposable /// bool TryUpdateInPlace(string tableName, long storageReference, byte[] newData); + /// + /// Same contract as for the common case where the caller has + /// already read the existing record and guarantees has the exact same + /// byte length as the stored payload (e.g. an in-place field patch built from the existing row). + /// Engines that can use the guarantee skip the extra length-prefix read; the default routes to + /// so existing implementations keep working unchanged. + /// + bool TryUpdateInPlaceSameLength(string tableName, long storageReference, byte[] newData) => + TryUpdateInPlace(tableName, storageReference, newData); + /// /// Deletes a record at the specified storage reference. /// diff --git a/src/SharpCoreDB/Services/Storage.Append.cs b/src/SharpCoreDB/Services/Storage.Append.cs index 34534420..cb3b9e98 100644 --- a/src/SharpCoreDB/Services/Storage.Append.cs +++ b/src/SharpCoreDB/Services/Storage.Append.cs @@ -465,27 +465,44 @@ public bool OverwriteRecordAt(string path, long offset, byte[] data) } // B7: inside a transaction, buffer the overwrite (write-behind) instead of writing to - // disk per row. Only records already flushed to disk (offset below the buffered-appends - // boundary) can be overwritten in place; still-buffered records fall back to append. - // Because nothing is written to disk until commit, the original bytes remain intact and - // rollback needs no undo data. + // disk per row; outside one, write it immediately. Nothing is written to disk before + // commit in the transactional case, so rollback needs no undo data. + return BufferOrWriteOverwriteInPlace(path, offset, record); + } + catch (IOException) + { + return false; + } + } + + /// + /// B7: buffers (inside a transaction) or writes (outside one) an in-place overwrite of a + /// length-prefixed record whose payload is (already encrypted when + /// applicable). The caller guarantees the new payload length equals the stored payload length, + /// so no length-prefix read/verification is needed. + /// + private bool BufferOrWriteOverwriteInPlace(string path, long offset, byte[] record) + { + bool inTransaction = IsInTransaction; + int recordLength = record.Length; + + try + { if (inTransaction) { + // Only records already flushed to disk (offset below the buffered-appends boundary) + // can be overwritten in place; still-buffered records fall back to append. if (!bufferedFileBaseLengths.TryGetValue(path, out long baseLength)) { baseLength = File.Exists(path) ? new FileInfo(path).Length : 0; bufferedFileBaseLengths[path] = baseLength; } - if (offset + 4 + existingLength > baseLength) + if (offset + 4 + recordLength > baseLength) { return false; } - byte[] newRecord = new byte[4 + record.Length]; - BinaryPrimitives.WriteInt32LittleEndian(newRecord, record.Length); - record.CopyTo(newRecord.AsSpan(4)); - lock (appendLock) { if (!bufferedOverwrites.TryGetValue(path, out var overwrites)) @@ -494,22 +511,15 @@ public bool OverwriteRecordAt(string path, long offset, byte[] data) bufferedOverwrites[path] = overwrites; } - overwrites[offset] = newRecord; - } - - // Invalidate app-level page cache (mirrors AppendBytes). - if (this.pageCache != null) - { - int pageId = ComputePageId(path, offset); - this.pageCache.EvictPage(pageId); + overwrites[offset] = record; } - - return true; } - - // Outside a transaction: overwrite the record on disk immediately. - BinaryPrimitives.WriteInt32LittleEndian(lengthBuffer, recordLength); - WriteRecordInPlace(path, offset, lengthBuffer, record); + else + { + Span lengthBuffer = stackalloc byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(lengthBuffer, recordLength); + WriteRecordInPlace(path, offset, lengthBuffer, record); + } } catch (IOException) { @@ -526,7 +536,27 @@ public bool OverwriteRecordAt(string path, long offset, byte[] data) return true; } + /// + /// Overwrites a length-prefixed record in place at when the caller + /// guarantees the new plaintext payload has the same length as the stored one (e.g. an in-place + /// field patch built from the existing record bytes). Skips the length-prefix read/verification + /// that performs — one less per-row syscall in the batch-DML + /// hot path. + /// + /// The table data file path. + /// The physical file offset of the record's 4-byte length prefix. + /// The plaintext record data to write (same length as the stored payload). + /// True when the record was overwritten/buffered in place. + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + bool encryptWrites = ShouldEncryptWrites(path); + byte[] record = EncryptRecord(data, encryptWrites); + + return BufferOrWriteOverwriteInPlace(path, offset, record); + } /// [MethodImpl(MethodImplOptions.AggressiveOptimization)] @@ -765,9 +795,11 @@ private void FlushBufferedOverwrites() try { - foreach (var (offset, newRecord) in overwrites) + Span lengthPrefix = stackalloc byte[4]; + foreach (var (offset, record) in overwrites) { - WriteRecordInPlace(path, offset, newRecord.AsSpan(0, 4), newRecord.AsSpan(4)); + BinaryPrimitives.WriteInt32LittleEndian(lengthPrefix, record.Length); + WriteRecordInPlace(path, offset, lengthPrefix, record); } } catch (IOException) @@ -784,18 +816,17 @@ private void FlushBufferedOverwrites() public byte[]? ReadBytesFrom(string path, long offset) { // B7: inside a transaction, a buffered in-place overwrite takes precedence over the disk - // version (the overwrite is written to disk only at commit). + // version (the overwrite is written to disk only at commit). The buffer holds the payload + // only (its length is the record's stored length). if (!bufferedOverwrites.IsEmpty && bufferedOverwrites.TryGetValue(path, out var buffered) && buffered.TryGetValue(offset, out var newRecord) && newRecord.Length > 0) { - int bufferedLength = BinaryPrimitives.ReadInt32LittleEndian(newRecord); - if (bufferedLength > 0 && bufferedLength <= MaxRecordSize && - newRecord.Length >= 4 + bufferedLength) + if (newRecord.Length <= MaxRecordSize) { - byte[] bufferedPayload = new byte[bufferedLength]; - Buffer.BlockCopy(newRecord, 4, bufferedPayload, 0, bufferedLength); + byte[] bufferedPayload = new byte[newRecord.Length]; + Buffer.BlockCopy(newRecord, 0, bufferedPayload, 0, newRecord.Length); if (UseRecordEncryption && FileHasEncryptedHeader(path)) { diff --git a/src/SharpCoreDB/Storage/Engines/AppendOnlyEngine.cs b/src/SharpCoreDB/Storage/Engines/AppendOnlyEngine.cs index c9f34758..bac79e4e 100644 --- a/src/SharpCoreDB/Storage/Engines/AppendOnlyEngine.cs +++ b/src/SharpCoreDB/Storage/Engines/AppendOnlyEngine.cs @@ -132,6 +132,26 @@ public bool TryUpdateInPlace(string tableName, long storageReference, byte[] new return overwritten; } + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public bool TryUpdateInPlaceSameLength(string tableName, long storageReference, byte[] newData) + { + ArgumentNullException.ThrowIfNull(newData); + + // Caller guarantees newData has the same payload length as the stored record (in-place field + // patch), so the storage layer skips the length-prefix read/verification. + var filePath = GetTableFilePath(tableName); + bool overwritten = storage.OverwriteRecordAtSameLength(filePath, storageReference, newData); + + if (overwritten) + { + Interlocked.Increment(ref totalUpdates); + Interlocked.Add(ref bytesWritten, newData.Length); + } + + return overwritten; + } + ///