Skip to content

Commit 68d2a85

Browse files
author
MPCoreDeveloper
committed
fix(storage): reopen data-integrity fixes for 2.0.0.2
- directory .ovf reload: zero-length arena records are valid; keep scanning so rows after empty TEXT/BLOB values are not silently dropped on reopen (found via SharpCoreDB.CQRS outbox integration tests) - single-file (.scdb) fixed-width overflow arena: freed slots persist as negative-length tombstone markers tracked across sessions; zero-filled dead gaps misaligned the sequential loader and dropped later values on reopen - legacy variable-length columnar DELETE: purge every remaining record of a deleted key at delete time so older stale UPDATE versions cannot win the reopen index rebuild and resurrect the row (1.x upgrade path) - tests: empty-value reopen regressions, 4-variant reopen round-trip matrix, legacy delete-after-update regression; docs: CHANGELOG [2.0.0.2] Fixed
1 parent cdbef86 commit 68d2a85

7 files changed

Lines changed: 589 additions & 3 deletions

File tree

‎docs/CHANGELOG.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
196196
at 0.69ÔÇô0.85├ù of SQLite). Full report:
197197
`docs/benchmarks/AVX512_2026-09-01.md` (+ raw per-run `.md`/`.json` in `docs/benchmarks/avx512-2026-09-01/`).
198198

199+
### Fixed
200+
201+
- **Fixed-width overflow arena silently dropped later rows after an empty value on reopen**
202+
- the per-table `.ovf` loader (`Storage.ReadAllRecords`) treated a valid zero-length block (written
203+
for an empty TEXT/BLOB value, e.g. the CQRS outbox `last_error`/`next_attempt_utc` columns) as the
204+
end of the file. After a reopen every arena block written after such an empty value was never
205+
loaded, so later rows came back with empty string/BLOB fields and later in-place UPDATE payloads
206+
read as empty. The persisted `.dat`/`.ovf` bytes were intact - only the reload scan stopped early.
207+
- Fixed by parsing length-0 records as valid empty records and continuing the scan in both
208+
`Storage.ReadAllRecords` and the default `IStorage.ReadAllRecords`.
209+
- Reproduced and verified via the SharpCoreDB.CQRS outbox integration tests
210+
(`GetUnpublishedAsync` scheduled-future exclusion, `RecordFailureAsync` retry metadata,
211+
`RequeueDeadLetterAsync` attempt reset) - all previously failed, all green again. Regression
212+
tests `DirectoryFixedWidthDefaultTests.DefaultConfig_EmptyTextValue_DoesNotHideLaterRowsAfterReopen`
213+
and `..._UpdateToEmptyAndReopen_KeepsAllRowsIntact`.
214+
215+
- **Single-file (.scdb) fixed-width overflow arena lost values after a reopen with freed blocks**
216+
- freed arena blocks were serialized as zero-filled gaps; the sequential arena loader misreads the
217+
first dead region as a corrupt/truncated stream and silently drops every later block, so updated
218+
and later values came back empty after a reopen (same writer/reader edge-value class as the
219+
directory-mode fix above, found by the new reopen round-trip matrix). Fixed by persisting freed
220+
slots as negative-length tombstone markers that are tracked across sessions (`_deadSlots`) and
221+
skipped on load - the byte stream stays aligned and dead space is reclaimed by the existing
222+
copy-on-compact pass.
223+
224+
- **New reopen round-trip matrix (`ReopenRoundTripMatrixTests`)** - four storage variants
225+
(directory fixed-width default, directory legacy variable-length, single-file JSON,
226+
single-file fixed-width) run insert/update/delete cycles with empty TEXT values interleaved with
227+
non-empty ones across three reopen + content-verification rounds.
228+
229+
- **Legacy (1.x-upgrade) variable-length delete-after-update resurrection fixed (critical)** - on a
230+
directory-mode table without fixed-width records (`AutoFixedWidthRecords = false`, i.e. 1.x /
231+
pre-B7 databases that have not been migrated), an UPDATE appends a new version and the durable
232+
DELETE tombstone only marked the newest version. An older stale version of the key then won the
233+
reopen index rebuild ("keep latest position") and the deleted row reappeared after a reopen -
234+
any 1.x database upgraded to 2.0 was exposed to this on the normal update-then-delete workflow.
235+
Fixed by purging every remaining (non-tombstoned) record of a deleted key at DELETE time for
236+
legacy columnar tables (single buffered scan for plaintext files, storage-layer fallback for
237+
per-record encrypted files). Fixed-width tables (the 2.0 default for new PK tables) were
238+
unaffected. Regression: `ReopenRoundTripMatrixTests.DirectoryLegacy_UpdateThenDelete_DoesNotResurrectAfterReopen`
239+
and the round-trip matrix legacy variant now exercises delete-after-update across reopen.
240+
241+
Full core suite 1777 tests, 0 failed; SharpCoreDB.CQRS.Tests 64/64.
242+
199243
## [2.0.0.1] - 2026-09-01
200244

201245
### Fixed

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

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2704,6 +2704,16 @@ private bool HasExplicitNamedIndex(string column)
27042704
{
27052705
// Durable DELETE: physically mark the removed records so a reopen skips them.
27062706
TombstoneDeletedPositions(positions);
2707+
2708+
// Legacy variable-length (non-fixed-width) columnar tables keep older stale versions
2709+
// of a key in the file (UPDATE appends a new version). Tombstoning only the newest
2710+
// version would let an older stale version win the reopen index rebuild ("keep the
2711+
// latest position per key") and resurrect the deleted row — purge the remaining
2712+
// records that carry a deleted key too.
2713+
if (StorageMode == StorageMode.Columnar && !_fixedWidthRecords && PrimaryKeyIndex >= 0)
2714+
{
2715+
TombstoneRemainingVersionsOfDeletedKeys(recordsToDelete);
2716+
}
27072717
}
27082718

27092719
TryAutoCompact();
@@ -2712,6 +2722,171 @@ private bool HasExplicitNamedIndex(string column)
27122722
Interlocked.Add(ref _cachedRowCount, -recordsToDelete.Count);
27132723
}
27142724

2725+
/// <summary>
2726+
/// Legacy (variable-length) columnar tables append a new row version on every UPDATE, leaving
2727+
/// older stale versions of the same key in the data file. A durable DELETE tombstones only the
2728+
/// newest version; an older stale version would then win the reopen index rebuild and resurrect
2729+
/// the deleted row. This scans the file once and tombstones every remaining (non-tombstoned)
2730+
/// record whose primary key was just deleted. Fixed-width tables are unaffected (their UPDATEs
2731+
/// are in-place overwrites, so at most one live version per key exists).
2732+
/// </summary>
2733+
private void TombstoneRemainingVersionsOfDeletedKeys(List<(long storagePosition, Dictionary<string, object> row)> recordsToDelete)
2734+
{
2735+
if (this.storage is null || recordsToDelete.Count == 0)
2736+
{
2737+
return;
2738+
}
2739+
2740+
var pkCol = this.Columns[this.PrimaryKeyIndex];
2741+
var deletedKeys = new HashSet<string>(StringComparer.Ordinal);
2742+
foreach (var (_, row) in recordsToDelete)
2743+
{
2744+
if (row.TryGetValue(pkCol, out var pkValue) && pkValue != null)
2745+
{
2746+
deletedKeys.Add(pkValue.ToString() ?? string.Empty);
2747+
}
2748+
}
2749+
2750+
if (deletedKeys.Count == 0 || !File.Exists(DataFile))
2751+
{
2752+
return;
2753+
}
2754+
2755+
List<long>? remainingPositions = null;
2756+
if (!this.storage.AreRecordsEncrypted(DataFile))
2757+
{
2758+
remainingPositions = ScanLegacyPlaintextRemainingKeys(DataFile, pkCol, deletedKeys);
2759+
}
2760+
2761+
remainingPositions ??= ScanLegacyRemainingKeysViaStorage(DataFile, pkCol, deletedKeys);
2762+
if (remainingPositions is { Count: > 0 })
2763+
{
2764+
TombstoneDeletedPositions(remainingPositions.ToArray());
2765+
}
2766+
}
2767+
2768+
/// <summary>
2769+
/// Single buffered pass over a plaintext legacy data file: parse the length-prefixed record
2770+
/// stream inline (skipping tombstone markers) and decode only the PK column. Returns the
2771+
/// physical offsets of records whose PK is in <paramref name="deletedKeys"/>, or
2772+
/// <see langword="null"/> when the raw layout could not be parsed safely (the caller then falls
2773+
/// back to the storage-layer scan, which understands per-record encryption).
2774+
/// </summary>
2775+
private List<long>? ScanLegacyPlaintextRemainingKeys(string dataFile, string pkCol, HashSet<string> deletedKeys)
2776+
{
2777+
var matches = new List<long>();
2778+
try
2779+
{
2780+
using var fs = new FileStream(
2781+
dataFile, FileMode.Open, FileAccess.Read,
2782+
FileShare.ReadWrite | FileShare.Delete, 65536, FileOptions.SequentialScan);
2783+
2784+
Span<byte> lengthBuf = stackalloc byte[4];
2785+
long position = 0;
2786+
while (fs.Position < fs.Length)
2787+
{
2788+
if (fs.Read(lengthBuf) < 4)
2789+
{
2790+
break;
2791+
}
2792+
2793+
int length = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(lengthBuf);
2794+
if (length < 0)
2795+
{
2796+
int slotSize = -length;
2797+
if (slotSize < 4 || position + slotSize > fs.Length)
2798+
{
2799+
break;
2800+
}
2801+
2802+
fs.Seek(slotSize - 4, SeekOrigin.Current);
2803+
position += slotSize;
2804+
continue;
2805+
}
2806+
2807+
if (length == 0)
2808+
{
2809+
// Valid zero-length record (empty payload): nothing to read, keep scanning.
2810+
position += 4;
2811+
continue;
2812+
}
2813+
2814+
if (position + 4 + length > fs.Length)
2815+
{
2816+
break;
2817+
}
2818+
2819+
byte[] recordData = new byte[length];
2820+
if (fs.Read(recordData) < length)
2821+
{
2822+
break;
2823+
}
2824+
2825+
if (TryReadPrimaryKeyFromLegacyRecord(recordData, pkCol, out var pkStr) && deletedKeys.Contains(pkStr))
2826+
{
2827+
matches.Add(position);
2828+
}
2829+
2830+
position += 4 + length;
2831+
}
2832+
}
2833+
catch (IOException)
2834+
{
2835+
return null;
2836+
}
2837+
2838+
return matches;
2839+
}
2840+
2841+
/// <summary>
2842+
/// Storage-layer scan used for encrypted per-record data files (and as the fallback when the
2843+
/// plaintext raw scan is unavailable): iterates the records through
2844+
/// <c>storage.ReadAllRecords</c>, which decrypts payloads and already skips tombstone markers.
2845+
/// </summary>
2846+
private List<long> ScanLegacyRemainingKeysViaStorage(string dataFile, string pkCol, HashSet<string> deletedKeys)
2847+
{
2848+
var matches = new List<long>();
2849+
foreach (var (recordOffset, recordData) in this.storage!.ReadAllRecords(dataFile))
2850+
{
2851+
if (TryReadPrimaryKeyFromLegacyRecord(recordData, pkCol, out var pkStr) && deletedKeys.Contains(pkStr))
2852+
{
2853+
matches.Add(recordOffset);
2854+
}
2855+
}
2856+
2857+
return matches;
2858+
}
2859+
2860+
/// <summary>
2861+
/// Walks a legacy variable-length record and returns the value of the primary-key column
2862+
/// (the same layout walk used by the reopen index rebuild).
2863+
/// </summary>
2864+
private bool TryReadPrimaryKeyFromLegacyRecord(byte[] recordData, string pkCol, out string? pkValue)
2865+
{
2866+
pkValue = null;
2867+
try
2868+
{
2869+
int offset = 0;
2870+
for (int i = 0; i < Columns.Count && offset < recordData.Length; i++)
2871+
{
2872+
var value = ReadTypedValueFromSpan(recordData.AsSpan(offset), ColumnTypes[i], out int bytesRead);
2873+
if (i == PrimaryKeyIndex && value != null)
2874+
{
2875+
pkValue = value.ToString();
2876+
return true;
2877+
}
2878+
2879+
offset += bytesRead;
2880+
}
2881+
}
2882+
catch
2883+
{
2884+
// Corrupt / unexpected record — mirror the index-rebuild tolerance.
2885+
}
2886+
2887+
return false;
2888+
}
2889+
27152890
/// <summary>
27162891
/// Deletes rows from the table that match the WHERE condition.
27172892
/// Routes through storage engine with different semantics:

‎src/SharpCoreDB/Interfaces/IStorage.cs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,17 @@ void TombstoneRecords(string path, long[] offsets)
247247
while (position + 4 <= data.Length)
248248
{
249249
int length = BitConverter.ToInt32(data, (int)position);
250-
if (length <= 0 || length > MaxRecordSizeLocal || position + 4 + length > data.Length)
250+
if (length == 0)
251+
{
252+
// Valid zero-length record (e.g. an overflow-arena block for an empty TEXT/BLOB
253+
// value). Yield an empty payload and keep scanning so later records/blocks are
254+
// not silently dropped on reload.
255+
yield return (position, []);
256+
position += 4;
257+
continue;
258+
}
259+
260+
if (length < 0 || length > MaxRecordSizeLocal || position + 4 + length > data.Length)
251261
{
252262
yield break;
253263
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1346,7 +1346,18 @@ private bool TryFlushBufferedOverwritesBatched(string path, Dictionary<long, byt
13461346
continue;
13471347
}
13481348

1349-
if (length <= 0 || length > MaxRecordSize || position + 4 + length > fileLength)
1349+
if (length == 0)
1350+
{
1351+
// Valid empty record (a zero-length payload — e.g. an overflow-arena block written
1352+
// for an empty TEXT/BLOB value). There are no payload bytes to read, but the block
1353+
// occupies a real offset, so yield an empty payload and keep scanning. Treating it
1354+
// as the end-of-file would silently drop every later record/block on reload.
1355+
yield return (position, []);
1356+
position += 4;
1357+
continue;
1358+
}
1359+
1360+
if (length > MaxRecordSize || position + 4 + length > fileLength)
13501361
{
13511362
yield break; // Invalid or incomplete record tail
13521363
}

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ public sealed class SingleFileOverflowArena : IOverflowArena
2121
private readonly Dictionary<long, byte[]> _blocks = new();
2222
private readonly Dictionary<int, List<long>> _freeByLength = new();
2323
private readonly Dictionary<string, long> _contentIndex = new(System.StringComparer.Ordinal);
24+
// Dead (freed) block slots that must be re-emitted as tombstone markers on every serialize so
25+
// the byte stream stays aligned for the sequential deserializer. Populated on load (markers)
26+
// and on Free; cleared when a slot is reused in place or the arena is compacted.
27+
private readonly Dictionary<long, int> _deadSlots = new();
2428
private long _nextOffset;
2529
private int _blockReuses;
2630

@@ -101,6 +105,7 @@ public long Write(byte[] payload)
101105
_freeByLength.Remove(payload.Length);
102106
}
103107

108+
_deadSlots.Remove(offset); // reused in place: no longer a dead slot
104109
_blocks[offset] = payload;
105110
_contentIndex[contentKey] = offset;
106111
_blockReuses++;
@@ -138,6 +143,7 @@ public void Free(long offset)
138143
}
139144

140145
offsets.Add(offset);
146+
_deadSlots[offset] = 4 + payload.Length;
141147
}
142148

143149
/// <summary>Serializes all blocks (live and freed) as a contiguous <c>[length][payload]</c> stream.</summary>
@@ -155,6 +161,18 @@ public byte[] Serialize()
155161
payload.CopyTo(buffer, (int)offset + 4);
156162
}
157163

164+
// Dead slots (freed blocks from this session or loaded from a previous one) are written as
165+
// tombstone markers (negative length = total slot span to skip). Without them a dead region
166+
// would be serialized as a zero-filled gap that misaligns the sequential deserializer and
167+
// drops every later block on reload.
168+
foreach (var (offset, slotSize) in _deadSlots)
169+
{
170+
if (offset >= 0 && offset + 4 <= buffer.Length)
171+
{
172+
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan((int)offset, 4), -slotSize);
173+
}
174+
}
175+
158176
return buffer;
159177
}
160178

@@ -175,7 +193,32 @@ public static SingleFileOverflowArena Deserialize(byte[]? data)
175193
while (position + 4 <= data.Length)
176194
{
177195
int length = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan((int)position, 4));
178-
if (length < 0 || position + 4 + length > data.Length)
196+
if (length < 0)
197+
{
198+
// Tombstone marker: the negative value encodes the whole slot span to skip
199+
// (freed overflow blocks are serialized as markers, not zero-filled gaps).
200+
int slotSize = -length;
201+
if (slotSize < 4 || position + slotSize > data.Length)
202+
{
203+
break; // truncated / corrupt
204+
}
205+
206+
// Track the dead slot so this flush re-emits the marker (a marker consumed on load
207+
// would otherwise come back as a zero-filled gap on the next serialize).
208+
arena._deadSlots[position] = slotSize;
209+
int deadPayloadLength = slotSize - 4;
210+
if (!arena._freeByLength.TryGetValue(deadPayloadLength, out var deadOffsets))
211+
{
212+
deadOffsets = [];
213+
arena._freeByLength[deadPayloadLength] = deadOffsets;
214+
}
215+
216+
deadOffsets.Add(position);
217+
position += slotSize;
218+
continue;
219+
}
220+
221+
if (position + 4 + length > data.Length)
179222
{
180223
break; // truncated / corrupt
181224
}
@@ -213,6 +256,7 @@ public Dictionary<long, long> Compact(IReadOnlyCollection<long> activeOffsets)
213256

214257
_blocks.Clear();
215258
_freeByLength.Clear();
259+
_deadSlots.Clear();
216260
_contentIndex.Clear();
217261
foreach (var (newOffset, payload) in newBlocks)
218262
{

0 commit comments

Comments
 (0)