Skip to content

Commit 116fc30

Browse files
author
MPCoreDeveloper
committed
perf(v2.0): #6 in-place UPDATE for columnar/append-only storage - fixed-width updates no longer append new versions
1 parent e204cfe commit 116fc30

9 files changed

Lines changed: 422 additions & 33 deletions

File tree

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

Lines changed: 104 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,8 +1177,7 @@ byte[] SerializeFullRow()
11771177
{
11781178
var rowData = SerializeFullRow();
11791179

1180-
// Columnar: Append new version (old ref becomes stale)
1181-
// Get old position from primary key index
1180+
// Get old position from primary key index.
11821181
long oldPosition = -1;
11831182
if (this.PrimaryKeyIndex >= 0)
11841183
{
@@ -1190,28 +1189,65 @@ byte[] SerializeFullRow()
11901189
}
11911190
}
11921191

1193-
// Insert new version
1194-
long newPosition = engine.Insert(Name, rowData);
1195-
1196-
// Update indexes to point to new position
1197-
if (this.PrimaryKeyIndex >= 0)
1192+
// Issue #6: in-place UPDATE — overwrite the record in its existing slot when
1193+
// the new record fits (fixed-width rows, or variable-width rows whose stored
1194+
// length is unchanged). No new version is appended, the storage reference and
1195+
// the PK index stay valid, and no stale version is left for compaction.
1196+
if (oldPosition >= 0 && engine.TryUpdateInPlace(Name, oldPosition, rowData))
11981197
{
1199-
var pkVal = row[this.Columns[this.PrimaryKeyIndex]]?.ToString() ?? string.Empty;
1200-
this.Index.Insert(pkVal, newPosition);
1201-
}
1198+
// Position unchanged: move hash entries in place (values may have changed).
1199+
foreach (var kvp in this.hashIndexes)
1200+
{
1201+
if (oldHashKeys != null && oldHashKeys.TryGetValue(kvp.Key, out var oldKey))
1202+
{
1203+
kvp.Value.Remove(oldKey, oldPosition);
1204+
}
12021205

1203-
// Update hash indexes (key-only removal of the old value)
1204-
foreach (var kvp in this.hashIndexes)
1205-
{
1206-
if (oldPosition >= 0 && oldHashKeys != null && oldHashKeys.TryGetValue(kvp.Key, out var oldKey))
1206+
kvp.Value.Add(row, oldPosition);
1207+
}
1208+
1209+
// Re-point the PK index only when the PK value itself changed.
1210+
if (this.PrimaryKeyIndex >= 0)
12071211
{
1208-
kvp.Value.Remove(oldKey, oldPosition); // Remove old ref
1212+
var newPkVal = row[this.Columns[this.PrimaryKeyIndex]]?.ToString() ?? string.Empty;
1213+
if (!string.Equals(newPkVal, oldPkValue, StringComparison.Ordinal))
1214+
{
1215+
if (!string.IsNullOrEmpty(oldPkValue))
1216+
{
1217+
this.Index.Delete(oldPkValue);
1218+
}
1219+
1220+
if (!string.IsNullOrEmpty(newPkVal))
1221+
{
1222+
this.Index.Insert(newPkVal, oldPosition);
1223+
}
1224+
}
12091225
}
1210-
kvp.Value.Add(row, newPosition); // Add new ref
12111226
}
1227+
else
1228+
{
1229+
// Columnar fallback: append new version (old ref becomes stale) + re-point indexes.
1230+
long newPosition = engine.Insert(Name, rowData);
12121231

1213-
// ✅ NEW: Track updates for compaction
1214-
Interlocked.Increment(ref _updatedRowCount);
1232+
if (this.PrimaryKeyIndex >= 0)
1233+
{
1234+
var pkVal = row[this.Columns[this.PrimaryKeyIndex]]?.ToString() ?? string.Empty;
1235+
this.Index.Insert(pkVal, newPosition);
1236+
}
1237+
1238+
foreach (var kvp in this.hashIndexes)
1239+
{
1240+
if (oldPosition >= 0 && oldHashKeys != null && oldHashKeys.TryGetValue(kvp.Key, out var oldKey))
1241+
{
1242+
kvp.Value.Remove(oldKey, oldPosition); // Remove old ref
1243+
}
1244+
1245+
kvp.Value.Add(row, newPosition); // Add new ref
1246+
}
1247+
1248+
// ✅ Track updates for compaction (only the append path creates stale versions).
1249+
Interlocked.Increment(ref _updatedRowCount);
1250+
}
12151251
}
12161252
else // PageBased
12171253
{
@@ -1436,27 +1472,62 @@ internal void UpdateMultiple(List<(string where, Dictionary<string, object> upda
14361472
oldPosition = searchResult.Value;
14371473
}
14381474

1439-
long newPosition = engine.Insert(Name, rowData);
1440-
1441-
if (this.PrimaryKeyIndex >= 0)
1475+
// Issue #6: in-place UPDATE — overwrite the record in its existing slot
1476+
// when the new record fits; the storage reference and PK index stay valid.
1477+
if (oldPosition >= 0 && engine.TryUpdateInPlace(Name, oldPosition, rowData))
14421478
{
1443-
var pkVal = row[this.Columns[this.PrimaryKeyIndex]]?.ToString() ?? string.Empty;
1444-
this.Index.Delete(pkVal);
1445-
this.Index.Insert(pkVal, newPosition);
1446-
}
1479+
// Position unchanged: move hash entries in place (values may have changed).
1480+
foreach (var hashIndex in this.hashIndexes)
1481+
{
1482+
if (oldPosition >= 0 &&
1483+
oldHashValues is not null &&
1484+
oldHashValues.TryGetValue(hashIndex.Key, out var oldKey) &&
1485+
oldKey is not null)
1486+
{
1487+
hashIndex.Value.Remove(oldKey, oldPosition);
1488+
}
1489+
1490+
if (row.TryGetValue(hashIndex.Key, out var newKey) && newKey is not null)
1491+
hashIndex.Value.Add(newKey, oldPosition);
1492+
}
14471493

1448-
foreach (var hashIndex in this.hashIndexes)
1494+
// Re-point the PK index only when the PK value itself changed.
1495+
if (this.PrimaryKeyIndex >= 0)
1496+
{
1497+
var newPkVal = row[this.Columns[this.PrimaryKeyIndex]]?.ToString() ?? string.Empty;
1498+
if (!string.Equals(newPkVal, oldPkValue?.ToString(), StringComparison.Ordinal))
1499+
{
1500+
if (!string.IsNullOrEmpty(oldPkValue?.ToString()))
1501+
this.Index.Delete(oldPkValue!.ToString()!);
1502+
if (!string.IsNullOrEmpty(newPkVal))
1503+
this.Index.Insert(newPkVal, oldPosition);
1504+
}
1505+
}
1506+
}
1507+
else
14491508
{
1450-
if (oldPosition >= 0 &&
1451-
oldHashValues is not null &&
1452-
oldHashValues.TryGetValue(hashIndex.Key, out var oldKey) &&
1453-
oldKey is not null)
1509+
long newPosition = engine.Insert(Name, rowData);
1510+
1511+
if (this.PrimaryKeyIndex >= 0)
14541512
{
1455-
hashIndex.Value.Remove(oldKey, oldPosition);
1513+
var pkVal = row[this.Columns[this.PrimaryKeyIndex]]?.ToString() ?? string.Empty;
1514+
this.Index.Delete(pkVal);
1515+
this.Index.Insert(pkVal, newPosition);
14561516
}
14571517

1458-
if (row.TryGetValue(hashIndex.Key, out var newKey) && newKey is not null)
1459-
hashIndex.Value.Add(newKey, newPosition);
1518+
foreach (var hashIndex in this.hashIndexes)
1519+
{
1520+
if (oldPosition >= 0 &&
1521+
oldHashValues is not null &&
1522+
oldHashValues.TryGetValue(hashIndex.Key, out var oldKey) &&
1523+
oldKey is not null)
1524+
{
1525+
hashIndex.Value.Remove(oldKey, oldPosition);
1526+
}
1527+
1528+
if (row.TryGetValue(hashIndex.Key, out var newKey) && newKey is not null)
1529+
hashIndex.Value.Add(newKey, newPosition);
1530+
}
14601531
}
14611532

14621533
updatedInBatch++;

‎src/SharpCoreDB/Interfaces/IStorage.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ public interface IStorage
9595
/// <returns>The offset where the data was appended.</returns>
9696
long AppendBytes(string path, byte[] data);
9797

98+
/// <summary>
99+
/// Overwrites a length-prefixed record in place at <paramref name="offset"/> (in-place UPDATE).
100+
/// Returns true only when the new (encrypted) record fits the existing slot — i.e. the stored
101+
/// length is unchanged, so every following record stays at a valid offset. When the lengths
102+
/// differ the caller must fall back to <see cref="AppendBytes"/>. Not available inside a
103+
/// transaction (buffered appends + rollback are append-only by design).
104+
/// </summary>
105+
bool OverwriteRecordAt(string path, long offset, byte[] data);
106+
98107
/// <summary>
99108
/// Appends multiple binary data blocks to a file in a single batch operation (used for batch inserts).
100109
/// </summary>

‎src/SharpCoreDB/Interfaces/IStorageEngine.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ public interface IStorageEngine : IDisposable
4646
/// </returns>
4747
long Update(string tableName, long storageReference, byte[] newData);
4848

49+
/// <summary>
50+
/// Attempts to overwrite a record in place at the given storage reference — no relocation,
51+
/// no new version, so the reference and every index entry stay valid. Returns true when the
52+
/// write succeeded in place; false when the new record does not fit the existing slot (or the
53+
/// engine does not support in-place updates), in which case callers must fall back to
54+
/// <see cref="Update"/>.
55+
/// </summary>
56+
bool TryUpdateInPlace(string tableName, long storageReference, byte[] newData);
57+
4958
/// <summary>
5059
/// Deletes a record at the specified storage reference.
5160
/// </summary>

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,79 @@ public long AppendBytes(string path, byte[] data)
329329

330330
return position;
331331
}
332+
/// <summary>
333+
/// Overwrites a length-prefixed record in place at <paramref name="offset"/> (in-place UPDATE).
334+
/// Returns true only when the new (encrypted) record fits the existing slot — i.e. the stored
335+
/// length is unchanged, so every following record stays at a valid offset. When the lengths
336+
/// differ the caller must fall back to <see cref="AppendBytes"/>. Not available inside a
337+
/// transaction (buffered appends + rollback are append-only by design).
338+
/// </summary>
339+
/// <param name="path">The table data file path.</param>
340+
/// <param name="offset">The physical file offset of the record's 4-byte length prefix.</param>
341+
/// <param name="data">The plaintext record data to write.</param>
342+
/// <returns>True when the record was overwritten in place; false when it did not fit.</returns>
343+
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
344+
public bool OverwriteRecordAt(string path, long offset, byte[] data)
345+
{
346+
ArgumentNullException.ThrowIfNull(data);
347+
348+
// In-place overwrites of already-flushed records cannot be buffered/rolled back with the
349+
// append-only transaction machinery — fall back to append semantics in a transaction.
350+
if (IsInTransaction)
351+
{
352+
return false;
353+
}
354+
355+
bool encryptWrites = ShouldEncryptWrites(path);
356+
byte[] record = EncryptRecord(data, encryptWrites);
357+
int recordLength = record.Length;
358+
359+
try
360+
{
361+
using var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read, 4096, FileOptions.WriteThrough);
362+
if (fs.Length < offset + 4)
363+
{
364+
return false;
365+
}
366+
367+
// Read the existing record's length prefix at the offset (ciphertext length for
368+
// encrypted files, plaintext length otherwise — identical to AppendBytes).
369+
fs.Position = offset;
370+
Span<byte> lengthBuffer = stackalloc byte[4];
371+
if (fs.Read(lengthBuffer) != 4)
372+
{
373+
return false;
374+
}
375+
376+
int existingLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBuffer);
377+
if (existingLength != recordLength)
378+
{
379+
return false;
380+
}
381+
382+
// Overwrite length prefix + payload in place; the file length is unchanged so all
383+
// following records keep their offsets.
384+
fs.Position = offset;
385+
BinaryPrimitives.WriteInt32LittleEndian(lengthBuffer, recordLength);
386+
fs.Write(lengthBuffer);
387+
fs.Write(record.AsSpan());
388+
}
389+
catch (IOException)
390+
{
391+
return false;
392+
}
393+
394+
// Invalidate app-level page cache (mirrors AppendBytes).
395+
if (this.pageCache != null)
396+
{
397+
int pageId = ComputePageId(path, offset);
398+
this.pageCache.EvictPage(pageId);
399+
}
400+
401+
return true;
402+
}
403+
404+
332405

333406
/// <inheritdoc />
334407
[MethodImpl(MethodImplOptions.AggressiveOptimization)]

‎src/SharpCoreDB/Storage/Engines/AppendOnlyEngine.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,27 @@ public long Update(string tableName, long storageReference, byte[] newData)
112112

113113
return newReference;
114114
}
115+
/// <inheritdoc />
116+
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
117+
public bool TryUpdateInPlace(string tableName, long storageReference, byte[] newData)
118+
{
119+
ArgumentNullException.ThrowIfNull(newData);
120+
121+
// Overwrites [length][data] at the existing offset when the new record fits the slot
122+
// (same stored length) — no new version, so the reference and all index entries stay valid.
123+
var filePath = GetTableFilePath(tableName);
124+
bool overwritten = storage.OverwriteRecordAt(filePath, storageReference, newData);
125+
126+
if (overwritten)
127+
{
128+
Interlocked.Increment(ref totalUpdates);
129+
Interlocked.Add(ref bytesWritten, newData.Length);
130+
}
131+
132+
return overwritten;
133+
}
134+
135+
115136

116137
/// <inheritdoc />
117138
[MethodImpl(MethodImplOptions.AggressiveInlining)]

‎src/SharpCoreDB/Storage/Engines/PageBasedEngine.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,15 @@ public long Update(string tableName, long storageReference, byte[] newData)
174174
: EncodeStorageReference(newPage.Value, newRecordId.SlotIndex);
175175
}
176176

177+
/// <inheritdoc />
178+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
179+
public bool TryUpdateInPlace(string tableName, long storageReference, byte[] newData)
180+
{
181+
// PageBasedEngine.Update already keeps the storage reference for in-place and
182+
// within-page updates; only a cross-page relocation changes the reference.
183+
return Update(tableName, storageReference, newData) == storageReference;
184+
}
185+
177186
/// <inheritdoc />
178187
[MethodImpl(MethodImplOptions.AggressiveInlining)]
179188
public void Delete(string tableName, long storageReference)

‎src/SharpCoreDB/Storage/Scdb/PageBasedAdapter.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,15 @@ public long Update(string tableName, long storageReference, byte[] newData)
189189
}
190190
}
191191

192+
/// <inheritdoc/>
193+
public bool TryUpdateInPlace(string tableName, long storageReference, byte[] newData)
194+
{
195+
// The adapter updates records within a page; the slot pointer moves but the
196+
// storage reference stays valid (relocation only occurs across pages for a record
197+
// that grows past the page size).
198+
return Update(tableName, storageReference, newData) == storageReference;
199+
}
200+
192201
/// <inheritdoc/>
193202
public void Delete(string tableName, long storageReference)
194203
{

0 commit comments

Comments
 (0)