Skip to content

Commit 2ef8363

Browse files
author
MPCoreDeveloper
committed
fix(#344): compressed single-file reopen crash - propagate Compressed block flag on rewrites + read row-cache via ReadBlockAsync
A table row-cache block that was rewritten while it already existed (auto-flush as JSON grows past the compression threshold, or grow/realloc) was stored compressed WITHOUT the per-block Compressed flag, so on reopen the raw Brotli/GZip bytes were handed to the JSON parser (JsonException '0x0B is an invalid start of a value'). WriteBlockAsync now recomputes the flag for every write, and SingleFileTable.EnsureCacheLoaded reads through ReadBlockAsync (transparent decrypt+decompress). Regression tests added for compression and compression+encryption reopen+SELECT.
1 parent 10bc1cd commit 2ef8363

7 files changed

Lines changed: 97 additions & 22 deletions

File tree

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<div align="center">
44

5-
**Last updated: v2.0.0-preview.2 · Maintained by [@MPCoreDeveloper](https://github.com/MPCoreDeveloper)**
5+
**Last updated: v2.0.0-preview.3 · Maintained by [@MPCoreDeveloper](https://github.com/MPCoreDeveloper)**
66

77
</div>
88

‎docs/CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7171
publishes with `PublishAot=true` and runs exit 0 including the single-file full-vacuum path.
7272
- **Follow-up:** full VACUUM now reloads the block registry / FSM / WAL after the file swap so
7373
in-memory offsets match the compacted file (fixes stale-offset writes after vacuum).
74+
- **Issue #344 — compressed single-file (`.scdb`) databases crashed on reopen + SELECT with
75+
`JsonException: '0x0B' is an invalid start of a value`**:
76+
- `WriteBlockAsync` only stamped the per-block `Compressed` flag for brand-new blocks; a table
77+
row-cache block that was rewritten while it already existed (auto-flush as the JSON grows past
78+
the compression threshold, or a grow/realloc) was stored compressed **without** the flag, so on
79+
reopen the raw Brotli/GZip bytes were handed to the JSON parser;
80+
- `SingleFileTable.EnsureCacheLoaded` now reads the row-cache block through `ReadBlockAsync`
81+
(which decrypts and decompresses transparently) instead of `GetReadStream` (which returns raw
82+
on-disk bytes when encryption is off);
83+
- regression tests `DatabaseFactory_Compression_GrowingTable_ReopenSelectShouldSurvive` and
84+
`DatabaseFactory_CompressionPlusEncryption_GrowingTable_ReopenSelectShouldSurvive` cover the
85+
reopen + SELECT path for compression alone and compression + encryption.
7486

7587
## [2.0.0] - 2026-08-28
7688

‎docs/INDEX.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SharpCoreDB Documentation Index
22

3-
**Status:** Active documentation set (`v2.0.0-preview.2`)
3+
**Status:** Active documentation set (`v2.0.0-preview.3`)
44

55
## 0. Manual (start here)
66

‎src/SharpCoreDB/NuGet.README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
# SharpCoreDB v2.0.0-preview.2 - Performance-First Database Engine
1+
# SharpCoreDB v2.0.0-preview.3 - Performance-First Database Engine
22

33
**High-Performance Embedded AND Networked Database for .NET 10**
44

55
SharpCoreDB is a modern, encrypted, file-based database engine with SQL support, built for production applications. Now available as both embedded database and network server.
66

77
[![SonarCloud Quality Gate](https://img.shields.io/sonar/quality_gate/MPCoreDeveloper_SharpCoreDB?server=https%3A%2F%2Fsonarcloud.io&logo=sonarcloud)](https://sonarcloud.io/dashboard?id=MPCoreDeveloper_SharpCoreDB)
88

9-
## What's New in v2.0.0-preview.2
9+
## What's New in v2.0.0-preview.3
1010

1111
### 🚀 Performance-first engine
1212
- v2.0 closes the v1.x benchmark gap: point reads **beat SQLite** on the default engine, batch INSERTs
@@ -203,7 +203,7 @@ We welcome contributions! Check the repository for contribution guidelines.
203203

204204
---
205205

206-
**Latest Version:** 2.0.0-preview.2 (August 30, 2026)
206+
**Latest Version:** 2.0.0-preview.3 (August 30, 2026)
207207
**Target:** .NET 10 / C# 14
208208
**Tests:** 1,600+ (100% passing)
209209
**Status:** ✅ Preview 2 — performance-first v2.0 line

‎src/SharpCoreDB/SingleFileTable.cs‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -655,19 +655,18 @@ private void EnsureCacheLoaded()
655655
return;
656656
}
657657

658-
using var stream = _storageProvider.GetReadStream(_dataBlockName);
659-
if (stream is null)
658+
// Read through ReadBlockAsync so encryption AND block-level compression (#344)
659+
// are both transparently handled. GetReadStream returns the raw on-disk bytes
660+
// when encryption is off, which would hand compressed data (Brotli/GZip marker
661+
// bytes) to the JSON parser on reopen — breaking SELECT after reopen.
662+
var jsonBytes = _storageProvider.ReadBlockAsync(_dataBlockName, CancellationToken.None).GetAwaiter().GetResult();
663+
if (jsonBytes is null || jsonBytes.Length == 0)
660664
{
661665
_rowCache = [];
662666
_cacheLoaded = true;
663667
return;
664668
}
665669

666-
// Read the stream into bytes and trim trailing null bytes
667-
using var memoryStream = new MemoryStream();
668-
stream.CopyTo(memoryStream);
669-
var jsonBytes = memoryStream.ToArray();
670-
671670
// Trim trailing null bytes
672671
var endIndex = jsonBytes.Length;
673672
while (endIndex > 0 && jsonBytes[endIndex - 1] == 0)

‎src/SharpCoreDB/Storage/SingleFileStorageProvider.cs‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,19 @@ public async Task WriteBlockAsync(string blockName, ReadOnlyMemory<byte> data, C
794794
{
795795
// Calculate required pages
796796
var requiredPages = (data.Length + _header.PageSize - 1) / _header.PageSize;
797-
797+
798+
// ✅ Compression (#344): the Compressed flag must reflect the state of THIS write.
799+
// A previous write may have stored the block compressed or uncompressed; the flag is
800+
// recomputed here so an updated (rewritten/grown) block always carries the correct
801+
// marker. The earlier code only set the flag for brand-new blocks, so an existing
802+
// block that got compressed lost its Compressed bit and reopen read raw Brotli/GZip
803+
// bytes as JSON (JsonException "invalid start of a value").
804+
uint flags = (uint)BlockFlags.Dirty;
805+
if (isCompressed)
806+
{
807+
flags |= (uint)BlockFlags.Compressed;
808+
}
809+
798810
ulong offset;
799811
BlockEntry entry;
800812

@@ -806,14 +818,14 @@ public async Task WriteBlockAsync(string blockName, ReadOnlyMemory<byte> data, C
806818
{
807819
// Fits in existing space
808820
offset = existingEntry.Offset;
809-
entry = existingEntry with { Length = (ulong)data.Length, Flags = existingEntry.Flags | (uint)BlockFlags.Dirty };
821+
entry = existingEntry with { Length = (ulong)data.Length, Flags = flags };
810822
}
811823
else
812824
{
813825
// Need more space: free old, allocate new
814826
_freeSpaceManager.FreePages(existingEntry.Offset, (int)existingPages);
815827
offset = _freeSpaceManager.AllocatePages(requiredPages);
816-
entry = existingEntry with { Offset = offset, Length = (ulong)data.Length, Flags = (uint)BlockFlags.Dirty };
828+
entry = existingEntry with { Offset = offset, Length = (ulong)data.Length, Flags = flags };
817829
}
818830
}
819831
else
@@ -835,13 +847,6 @@ public async Task WriteBlockAsync(string blockName, ReadOnlyMemory<byte> data, C
835847
offset = registryEnd;
836848
}
837849

838-
// ✅ Compression: set the Compressed flag if this block was compressed.
839-
var flags = (uint)BlockFlags.Dirty;
840-
if (isCompressed)
841-
{
842-
flags |= (uint)BlockFlags.Compressed;
843-
}
844-
845850
entry = new BlockEntry
846851
{
847852
BlockType = (uint)Scdb.BlockType.TableData,

‎tests/SharpCoreDB.Tests/Storage/SingleFileCompressionTests.cs‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,65 @@ public void DatabaseFactory_WithCompression_ShouldRoundtripTableData()
449449
DisposeDatabase(db2);
450450
}
451451

452+
[Fact]
453+
public void DatabaseFactory_Compression_GrowingTable_ReopenSelectShouldSurvive()
454+
{
455+
// REGRESSION (#344): a table whose row-cache block is rewritten while it already
456+
// exists lost its Compressed flag, so reopening and running SELECT parsed raw
457+
// Brotli bytes as JSON (JsonException "'0x0B' is an invalid start of a value").
458+
var factory = BuildFactory();
459+
var options = CreateCompressedOptions(BlockCompressionMode.Brotli);
460+
461+
// Auto-flush rewrites the row-cache block as it grows past the compression
462+
// threshold — exercising the existing-block rewrite path that lost the flag.
463+
var db = factory.CreateWithOptions(_testDbPath, "unused", options);
464+
db.ExecuteSQL("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, payload TEXT)");
465+
for (int i = 0; i < 500; i++)
466+
{
467+
db.ExecuteSQL($"INSERT INTO t VALUES ({i}, 'user{i}', '{new string('x', 32)}')");
468+
}
469+
db.Flush();
470+
db.ForceSave();
471+
DisposeDatabase(db);
472+
473+
// Reopen and SELECT must survive with the data intact.
474+
var db2 = factory.CreateWithOptions(_testDbPath, "unused", options);
475+
var results = db2.ExecuteQuery("SELECT * FROM t ORDER BY id");
476+
Assert.Equal(500, results.Count);
477+
Assert.Equal("user42", results[42]["name"]?.ToString());
478+
Assert.Equal("user499", results[499]["name"]?.ToString());
479+
DisposeDatabase(db2);
480+
}
481+
482+
[Fact]
483+
public void DatabaseFactory_CompressionPlusEncryption_GrowingTable_ReopenSelectShouldSurvive()
484+
{
485+
// Same regression as above but with full at-rest encryption combined with
486+
// block compression (compression + encryption on the existing-block path).
487+
var factory = BuildFactory();
488+
var options = CreateCompressedOptions(BlockCompressionMode.Brotli);
489+
options.EnableEncryption = true;
490+
options.EncryptionKey = RandomNumberGenerator.GetBytes(32);
491+
492+
var db = factory.CreateWithOptions(_testDbPath, "unused", options);
493+
db.ExecuteSQL("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, payload TEXT)");
494+
for (int i = 0; i < 500; i++)
495+
{
496+
db.ExecuteSQL($"INSERT INTO t VALUES ({i}, 'user{i}', '{new string('y', 32)}')");
497+
}
498+
db.Flush();
499+
db.ForceSave();
500+
DisposeDatabase(db);
501+
502+
var db2 = factory.CreateWithOptions(_testDbPath, "unused", options);
503+
var results = db2.ExecuteQuery("SELECT * FROM t ORDER BY id");
504+
Assert.Equal(500, results.Count);
505+
Assert.Equal("user7", results[7]["name"]?.ToString());
506+
Assert.Equal("user499", results[499]["name"]?.ToString());
507+
DisposeDatabase(db2);
508+
}
509+
510+
452511
[Fact]
453512
public void DatabaseFactory_WrongCompressionModeOnReopen_ShouldThrow()
454513
{

0 commit comments

Comments
 (0)