From 7ab07bb4aeccaf45ef3ad4a14ec158aedad01733 Mon Sep 17 00:00:00 2001 From: MPCoreDeveloper Date: Thu, 3 Sep 2026 11:51:58 +0200 Subject: [PATCH] perf: run the contiguous UPDATE/DELETE fast paths on plaintext default-config tables The bulk fast paths previously required DatabaseConfig.NoEncryptMode, although per-record encryption is opt-in (EnableAtRestRecordEncryption): default-config databases already store plaintext records. Gate on the storage layer's runtime AreRecordsEncrypted (UseRecordEncryption && file carries the encrypted magic header) instead of the config flag, so default-config users get the single-pass UPDATE/DELETE path too; genuinely encrypted tables still fall back to the generic loop. --- src/SharpCoreDB/DataStructures/Table.CRUD.cs | 14 ++++---- src/SharpCoreDB/Interfaces/IStorage.cs | 7 ++++ src/SharpCoreDB/Services/Storage.Append.cs | 3 ++ .../FixedWidthBulkUpdateTests.cs | 33 +++++++++++++++++-- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/SharpCoreDB/DataStructures/Table.CRUD.cs b/src/SharpCoreDB/DataStructures/Table.CRUD.cs index 0234bcc8..71b02178 100644 --- a/src/SharpCoreDB/DataStructures/Table.CRUD.cs +++ b/src/SharpCoreDB/DataStructures/Table.CRUD.cs @@ -2356,15 +2356,16 @@ private bool TryBulkUpdateContiguousFixedWidth( } // Narrow, conservative gate: fixed-width columnar table with an explicit PK, plaintext - // records only (a raw contiguous read must equal the logical record bytes), no buffered - // overwrites for this file, and no CHECK constraints (mirrors the generic fastPatch gate). + // records only (no per-record encryption magic — a raw contiguous read must equal the + // logical record bytes), no buffered overwrites for this file, and no CHECK constraints + // (mirrors the generic fastPatch gate). if (!_fixedWidthRecords || StorageMode != StorageMode.Columnar || this.PrimaryKeyIndex < 0 || this.TableCheckConstraints.Count > 0 || HasColumnCheckConstraints() || this.storage is null || - this._config is not { NoEncryptMode: true } || + this.storage.AreRecordsEncrypted(DataFile) || this.storage.HasBufferedOverwrite(DataFile)) { return false; @@ -3047,13 +3048,14 @@ private bool TryBulkDeleteContiguousFixedWidth(List whereConditions) } // Identical safety gate to the UPDATE fast path: fixed-width columnar table with an explicit - // PK, plaintext records only, and no buffered overwrites (a raw range read must equal the - // logical record bytes). DeleteMultiple loads every registered hash index before calling this. + // PK, plaintext records only (no per-record encryption magic — a raw range read must equal + // the logical record bytes), and no buffered overwrites. DeleteMultiple loads every + // registered hash index before calling this. if (!_fixedWidthRecords || StorageMode != StorageMode.Columnar || this.PrimaryKeyIndex < 0 || this.storage is null || - this._config is not { NoEncryptMode: true } || + this.storage.AreRecordsEncrypted(DataFile) || this.storage.HasBufferedOverwrite(DataFile)) { return false; diff --git a/src/SharpCoreDB/Interfaces/IStorage.cs b/src/SharpCoreDB/Interfaces/IStorage.cs index 94b4a70d..71184cd9 100644 --- a/src/SharpCoreDB/Interfaces/IStorage.cs +++ b/src/SharpCoreDB/Interfaces/IStorage.cs @@ -163,6 +163,13 @@ bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) => /// byte[]? ReadBytesRange(string path, long offset, int length) => null; + /// + /// True when the file at stores per-record encrypted (ciphertext) + /// payloads (it carries the encrypted-table magic header). Raw range reads must never be used on + /// such files; the default returns false (plaintext / legacy layouts). + /// + bool AreRecordsEncrypted(string path) => false; + /// /// Enumerates every record in a table data file, yielding the literal file offset of the /// 4-byte length prefix (the offset returned by ) together with the diff --git a/src/SharpCoreDB/Services/Storage.Append.cs b/src/SharpCoreDB/Services/Storage.Append.cs index 07311664..a02d9cca 100644 --- a/src/SharpCoreDB/Services/Storage.Append.cs +++ b/src/SharpCoreDB/Services/Storage.Append.cs @@ -560,6 +560,9 @@ public bool OverwriteRecordAtSameLength(string path, long offset, byte[] data) public bool HasBufferedOverwrite(string path) => !bufferedOverwrites.IsEmpty && bufferedOverwrites.ContainsKey(path); + /// + public bool AreRecordsEncrypted(string path) => UseRecordEncryption && FileHasEncryptedHeader(path); + /// [MethodImpl(MethodImplOptions.AggressiveOptimization)] public long[] AppendBytesMultiple(string path, List dataBlocks) diff --git a/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs b/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs index c669e785..52b46696 100644 --- a/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs +++ b/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs @@ -42,6 +42,9 @@ public void Dispose() private IDatabase CreateDb(bool noEncrypt = true) => _factory.Create(_dirPath, "pw", isReadOnly: false, config: new DatabaseConfig { NoEncryptMode = noEncrypt }); + private IDatabase CreateEncryptedDb() => _factory.Create(_dirPath, "pw", isReadOnly: false, + config: new DatabaseConfig { NoEncryptMode = false, EnableAtRestRecordEncryption = true }); + private static Table TableOf(IDatabase db, string tableName) { Assert.True(db.TryGetTable(tableName, out var t)); @@ -209,9 +212,11 @@ public void IndexedColumnUpdate_FallsBack_AndRepointsIndex() } [Fact] - public void EncryptedOrDefaultConfig_FallsBack_AndStaysCorrect() + public void DefaultPlaintextConfig_EngagesBulkPath_AndStaysCorrect() { - var db = CreateDb(noEncrypt: false); // default config: the gate requires NoEncryptMode + // Default config (no NoEncryptMode) stores plaintext records unless per-record encryption + // is opted in, so the runtime magic-header gate now admits the fast path here too. + var db = CreateDb(noEncrypt: false); try { db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); @@ -221,7 +226,29 @@ public void EncryptedOrDefaultConfig_FallsBack_AndStaysCorrect() var table = TableOf(db, "docs"); db.ExecuteBatchSQL(BuildUpdates(1, 300, "score = 4.25")); db.Flush(); - Assert.Equal(0, table.BulkContiguousUpdateBatches); + Assert.Equal(1, table.BulkContiguousUpdateBatches); + + var rows = db.ExecuteQuery("SELECT score FROM docs WHERE id = 300"); + Assert.Single(rows); + Assert.Equal(4.25, Convert.ToDouble(rows[0]["score"])); + } + finally { (db as IDisposable)?.Dispose(); } + } + + [Fact] + public void PerRecordEncryption_FallsBackToGenericLoop_AndStaysCorrect() + { + var db = CreateEncryptedDb(); + try + { + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + InsertDocs(db, 1, 300); + db.Flush(); + + var table = TableOf(db, "docs"); + db.ExecuteBatchSQL(BuildUpdates(1, 300, "score = 4.25")); + db.Flush(); + Assert.Equal(0, table.BulkContiguousUpdateBatches); // ciphertext records → generic loop var rows = db.ExecuteQuery("SELECT score FROM docs WHERE id = 300"); Assert.Single(rows);