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
44 changes: 43 additions & 1 deletion src/SharpCoreDB/DataStructures/Table.CRUD.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1927,6 +1927,22 @@
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)

Check warning on line 1936 in src/SharpCoreDB/DataStructures/Table.CRUD.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=AaBi-GkSXFiwd6DjSsV7&open=AaBi-GkSXFiwd6DjSsV7&pullRequest=355
{
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
Expand Down Expand Up @@ -2039,8 +2055,34 @@
? 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;
}

Expand Down
9 changes: 9 additions & 0 deletions src/SharpCoreDB/Interfaces/IStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ public interface IStorage
/// </summary>
bool OverwriteRecordAt(string path, long offset, byte[] data);

/// <summary>
/// Like <see cref="OverwriteRecordAt"/> for the case where the caller guarantees
/// <paramref name="data"/> 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 <see cref="OverwriteRecordAt"/>.
/// </summary>
bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) =>
OverwriteRecordAt(path, offset, data);

/// <summary>
/// Appends multiple binary data blocks to a file in a single batch operation (used for batch inserts).
/// </summary>
Expand Down
10 changes: 10 additions & 0 deletions src/SharpCoreDB/Interfaces/IStorageEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ public interface IStorageEngine : IDisposable
/// </summary>
bool TryUpdateInPlace(string tableName, long storageReference, byte[] newData);

/// <summary>
/// Same contract as <see cref="TryUpdateInPlace"/> for the common case where the caller has
/// already read the existing record and guarantees <paramref name="newData"/> 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
/// <see cref="TryUpdateInPlace"/> so existing implementations keep working unchanged.
/// </summary>
bool TryUpdateInPlaceSameLength(string tableName, long storageReference, byte[] newData) =>
TryUpdateInPlace(tableName, storageReference, newData);

/// <summary>
/// Deletes a record at the specified storage reference.
/// </summary>
Expand Down
93 changes: 62 additions & 31 deletions src/SharpCoreDB/Services/Storage.Append.cs
Original file line number Diff line number Diff line change
Expand Up @@ -465,27 +465,44 @@
}

// 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;
}
}

/// <summary>
/// B7: buffers (inside a transaction) or writes (outside one) an in-place overwrite of a
/// length-prefixed record whose payload is <paramref name="record"/> (already encrypted when
/// applicable). The caller guarantees the new payload length equals the stored payload length,
/// so no length-prefix read/verification is needed.
/// </summary>
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))
Expand All @@ -494,22 +511,15 @@
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<byte> lengthBuffer = stackalloc byte[4];
BinaryPrimitives.WriteInt32LittleEndian(lengthBuffer, recordLength);
WriteRecordInPlace(path, offset, lengthBuffer, record);
}
}
catch (IOException)
{
Expand All @@ -526,7 +536,27 @@
return true;
}

/// <summary>
/// Overwrites a length-prefixed record in place at <paramref name="offset"/> 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 <see cref="OverwriteRecordAt"/> performs — one less per-row syscall in the batch-DML
/// hot path.
/// </summary>
/// <param name="path">The table data file path.</param>
/// <param name="offset">The physical file offset of the record's 4-byte length prefix.</param>
/// <param name="data">The plaintext record data to write (same length as the stored payload).</param>
/// <returns>True when the record was overwritten/buffered in place.</returns>
[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);
}

/// <inheritdoc />
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
Expand Down Expand Up @@ -765,9 +795,11 @@

try
{
foreach (var (offset, newRecord) in overwrites)
Span<byte> 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)
Expand All @@ -781,21 +813,20 @@

/// <inheritdoc />
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public byte[]? ReadBytesFrom(string path, long offset)

Check failure on line 816 in src/SharpCoreDB/Services/Storage.Append.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=MPCoreDeveloper_SharpCoreDB&issues=AaBi-GnuXFiwd6DjSsV8&open=AaBi-GnuXFiwd6DjSsV8&pullRequest=355
{
// 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)

Check warning on line 826 in src/SharpCoreDB/Services/Storage.Append.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this if statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=MPCoreDeveloper_SharpCoreDB&issues=AaBi-GnuXFiwd6DjSsV9&open=AaBi-GnuXFiwd6DjSsV9&pullRequest=355
{
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))
{
Expand Down
20 changes: 20 additions & 0 deletions src/SharpCoreDB/Storage/Engines/AppendOnlyEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,26 @@ public bool TryUpdateInPlace(string tableName, long storageReference, byte[] new
return overwritten;
}

/// <inheritdoc />
[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;
}



/// <inheritdoc />
Expand Down
Loading