Skip to content

Commit c1e7350

Browse files
author
MPCoreDeveloper
committed
fix(#345): make SingleFile metadata region sizes configurable and file extension byte-based
1 parent 864625e commit c1e7350

5 files changed

Lines changed: 205 additions & 11 deletions

File tree

‎docs/serialization/SERIALIZATION_AND_STORAGE_GUIDE.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,8 +532,10 @@ public ulong AllocatePages(int count)
532532
if (startPage == ulong.MaxValue)
533533
{
534534
// 2. No space found? Extend file exponentially
535+
// Minimum extension is byte-based (issue #345): ~10 MB regardless of PageSize.
536+
var minExtensionPages = Math.Max(1, (int)(MIN_EXTENSION_BYTES / _pageSize));
535537
var extensionSize = Math.Max(
536-
MIN_EXTENSION_PAGES, // 2560 pages = 10MB (Phase 3)
538+
minExtensionPages,
537539
Math.Max(count, currentSize / EXTENSION_GROWTH_FACTOR)
538540
);
539541

‎src/SharpCoreDB/DatabaseOptions.cs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,26 @@ public sealed class DatabaseOptions
9797
/// </summary>
9898
public int CompressionThreshold { get; set; } = 256;
9999

100+
/// <summary>
101+
/// Number of pages allocated for the SingleFile Block Registry.
102+
/// Each block entry is 96 bytes; the default 4 pages @ 4KB supports ~170 blocks.
103+
/// Increase for databases that store many named blocks (default: 4, backward compatible).
104+
/// </summary>
105+
public int BlockRegistrySizePages { get; set; } = 4;
106+
107+
/// <summary>
108+
/// Number of pages allocated for the SingleFile Free Space Map (FSM).
109+
/// The FSM tracks one bit per data page; the default 4 pages @ 4KB supports ~512 MB
110+
/// of file data. Increase for databases expected to grow beyond that (default: 4, backward compatible).
111+
/// </summary>
112+
public int FsmSizePages { get; set; } = 4;
113+
114+
/// <summary>
115+
/// Number of pages allocated for the SingleFile table directory (schema metadata).
116+
/// Default: 4 (backward compatible).
117+
/// </summary>
118+
public int TableDirectorySizePages { get; set; } = 4;
119+
100120
/// <summary>
101121
/// Gets or sets whether to enable memory-mapped I/O for reads.
102122
/// Default: true (enables zero-copy reads).
@@ -204,6 +224,25 @@ public void Validate()
204224
$"WalBufferSizePages must be between 64 and 65536. Got: {WalBufferSizePages}");
205225
}
206226

227+
// Validate metadata region sizes (SingleFile storage layout, issue #345)
228+
if (BlockRegistrySizePages < 1 || BlockRegistrySizePages > 65536)
229+
{
230+
throw new ArgumentException(
231+
$"BlockRegistrySizePages must be between 1 and 65536. Got: {BlockRegistrySizePages}");
232+
}
233+
234+
if (FsmSizePages < 1 || FsmSizePages > 65536)
235+
{
236+
throw new ArgumentException(
237+
$"FsmSizePages must be between 1 and 65536. Got: {FsmSizePages}");
238+
}
239+
240+
if (TableDirectorySizePages < 1 || TableDirectorySizePages > 65536)
241+
{
242+
throw new ArgumentException(
243+
$"TableDirectorySizePages must be between 1 and 65536. Got: {TableDirectorySizePages}");
244+
}
245+
207246
// Validate fragmentation threshold
208247
if (FragmentationThreshold < 0 || FragmentationThreshold > 100)
209248
{

‎src/SharpCoreDB/Storage/FreeSpaceManager.cs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ internal sealed class FreeSpaceManager : IDisposable
4444
private ulong _freePages;
4545

4646
// ✅ C# 14: Pre-allocation settings for optimal file growth - Phase 3 optimized
47-
private const int MIN_EXTENSION_PAGES = 2560; // 10 MB @ 4KB pages (Phase 3: increased from 512 = 2MB)
47+
// Minimum file extension is byte-based so it stays ~10 MB regardless of PageSize;
48+
// a fixed page count would scale linearly (40 MB @ 16 KB, 80 MB @ 32 KB) - issue #345.
49+
private const long MIN_EXTENSION_BYTES = 10L * 1024 * 1024;
4850
private const int EXTENSION_GROWTH_FACTOR = 2; // Double size each time (exponential growth)
4951
private ulong _preallocatedPages = 0;
5052

@@ -200,8 +202,10 @@ public ulong AllocatePages(int count)
200202
// ✅ Calculate extension size (grow exponentially)
201203
var requiredPages = (ulong)count;
202204
var currentSize = _totalPages;
205+
// Minimum extension is byte-based (issue #345): ~10 MB regardless of PageSize.
206+
var minExtensionPages = (ulong)Math.Max(1, (int)(MIN_EXTENSION_BYTES / _pageSize));
203207
var extensionSize = Math.Max(
204-
MIN_EXTENSION_PAGES,
208+
minExtensionPages,
205209
Math.Max(requiredPages, currentSize / EXTENSION_GROWTH_FACTOR)
206210
);
207211

‎src/SharpCoreDB/Storage/SingleFileStorageProvider.cs‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1522,22 +1522,21 @@ static ulong AlignToPage(ulong value, int pageSize)
15221522
}
15231523

15241524
// Initialize block registry at next page boundary
1525-
// ✅ Phase 3: Allocate 4 pages (16KB) to support up to ~250 block entries
1526-
// Calculation: (16384 - 64 header) / 64 per entry = 255 max entries
1525+
// Configurable region size (issue #345): BlockRegistrySizePages pages.
15271526
header.BlockRegistryOffset = AlignToPage(ScdbFileHeader.HEADER_SIZE, options.PageSize);
1528-
header.BlockRegistryLength = (ulong)options.PageSize * 4;
1527+
header.BlockRegistryLength = (ulong)options.PageSize * (ulong)options.BlockRegistrySizePages;
15291528

1530-
// Initialize FSM at pages 5-8 (4 pages)
1529+
// Initialize FSM right after the registry (configurable region size)
15311530
header.FsmOffset = header.BlockRegistryOffset + header.BlockRegistryLength;
1532-
header.FsmLength = (ulong)options.PageSize * 4; // 4 pages for FSM
1531+
header.FsmLength = (ulong)options.PageSize * (ulong)options.FsmSizePages; // FSM tracks 1 bit/page
15331532

1534-
// Initialize WAL at pages 9+ (configurable)
1533+
// Initialize WAL at the next page boundary (configurable)
15351534
header.WalOffset = header.FsmOffset + header.FsmLength;
15361535
header.WalLength = (ulong)options.PageSize * (ulong)options.WalBufferSizePages;
15371536

1538-
// Initialize table directory after WAL
1537+
// Initialize table directory after WAL (configurable region size)
15391538
header.TableDirOffset = header.WalOffset + header.WalLength;
1540-
header.TableDirLength = (ulong)options.PageSize * 4; // 4 pages for table directory
1539+
header.TableDirLength = (ulong)options.PageSize * (ulong)options.TableDirectorySizePages;
15411540

15421541
// Allocate space for metadata structures
15431542
var totalMetadataSize = header.TableDirOffset + header.TableDirLength;
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// tests/SharpCoreDB.Tests/Storage/MetadataSizingTests.cs
2+
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
3+
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
4+
5+
namespace SharpCoreDB.Tests.Storage;
6+
7+
using System;
8+
using System.IO;
9+
using System.Threading.Tasks;
10+
using SharpCoreDB;
11+
using SharpCoreDB.Storage;
12+
using Xunit;
13+
14+
/// <summary>
15+
/// REGRESSION TESTS: SingleFile metadata region sizing (issue #345).
16+
/// Verifies the FSM / Block Registry / Table Directory regions are no longer
17+
/// hard-coded to 4 pages and that the minimum file extension is byte-based
18+
/// (stays ~10 MB regardless of PageSize).
19+
/// </summary>
20+
public sealed class MetadataSizingTests : IDisposable
21+
{
22+
private readonly string _testDbPath;
23+
24+
public MetadataSizingTests()
25+
{
26+
_testDbPath = Path.Combine(Path.GetTempPath(), $"metadata_sizing_{Guid.NewGuid():N}.scdb");
27+
}
28+
29+
public void Dispose()
30+
{
31+
try
32+
{
33+
if (File.Exists(_testDbPath)) File.Delete(_testDbPath);
34+
if (File.Exists(_testDbPath + ".wal")) File.Delete(_testDbPath + ".wal");
35+
if (File.Exists(_testDbPath + ".vacuum.tmp")) File.Delete(_testDbPath + ".vacuum.tmp");
36+
if (File.Exists(_testDbPath + ".backup")) File.Delete(_testDbPath + ".backup");
37+
}
38+
catch
39+
{
40+
// Ignore cleanup errors
41+
}
42+
}
43+
44+
[Fact]
45+
public void ConfiguredRegionSizes_AreWrittenToHeader()
46+
{
47+
var options = DatabaseOptions.CreateSingleFileDefault();
48+
options.PageSize = 4096;
49+
options.WalBufferSizePages = 64;
50+
options.EnableMemoryMapping = false;
51+
options.BlockRegistrySizePages = 8;
52+
options.FsmSizePages = 16;
53+
options.TableDirectorySizePages = 12;
54+
55+
using (SingleFileStorageProvider.Open(_testDbPath, options))
56+
{
57+
// Provider writes the header immediately on create; dispose closes the file.
58+
}
59+
60+
using var fs = new FileStream(_testDbPath, FileMode.Open, FileAccess.Read);
61+
using var reader = new BinaryReader(fs);
62+
63+
fs.Position = 0x28; // BlockRegistryLength
64+
var registryLength = reader.ReadUInt64();
65+
fs.Position = 0x38; // FsmLength
66+
var fsmLength = reader.ReadUInt64();
67+
fs.Position = 0x58; // TableDirLength
68+
var tableDirLength = reader.ReadUInt64();
69+
70+
Assert.Equal((ulong)(4096 * 8), registryLength);
71+
Assert.Equal((ulong)(4096 * 16), fsmLength);
72+
Assert.Equal((ulong)(4096 * 12), tableDirLength);
73+
}
74+
75+
[Fact]
76+
public async Task ManyBlocks_WithLargerRegistry_ShouldRoundtrip()
77+
{
78+
var options = DatabaseOptions.CreateSingleFileDefault();
79+
options.PageSize = 4096;
80+
options.WalBufferSizePages = 64;
81+
options.EnableMemoryMapping = false;
82+
options.BlockRegistrySizePages = 16; // 64 KB registry ≈ 680 entries
83+
84+
const int blockCount = 300; // exceeds the default 4-page (~170) capacity
85+
var payload = new byte[64];
86+
for (var i = 0; i < payload.Length; i++)
87+
{
88+
payload[i] = (byte)(i % 251);
89+
}
90+
91+
using (var provider = SingleFileStorageProvider.Open(_testDbPath, options))
92+
{
93+
for (var i = 0; i < blockCount; i++)
94+
{
95+
await provider.WriteBlockAsync($"blk_{i}", payload);
96+
}
97+
98+
// ForceFlushAsync = registry force flush + WAL checkpoint (full durability),
99+
// avoiding a race between the periodic registry flusher and the reopen below.
100+
await provider.ForceFlushAsync();
101+
}
102+
103+
using var reopened = SingleFileStorageProvider.Open(_testDbPath, options);
104+
for (var i = 0; i < blockCount; i++)
105+
{
106+
var data = await reopened.ReadBlockAsync($"blk_{i}");
107+
Assert.NotNull(data);
108+
Assert.Equal(payload, data);
109+
}
110+
}
111+
112+
[Fact]
113+
public async Task MinExtension_IsByteBased_NotPageBased()
114+
{
115+
var options = DatabaseOptions.CreateSingleFileDefault();
116+
options.PageSize = 16384; // 16 KB pages
117+
options.WalBufferSizePages = 64; // keep the WAL small for the assertion
118+
options.EnableMemoryMapping = false;
119+
120+
long fileSize;
121+
using (var provider = SingleFileStorageProvider.Open(_testDbPath, options))
122+
{
123+
await provider.WriteBlockAsync("blob", new byte[8192]);
124+
await provider.FlushAsync();
125+
fileSize = new FileInfo(_testDbPath).Length;
126+
}
127+
128+
// Old behavior: MIN_EXTENSION_PAGES=2560 * 16 KB = 40 MB -> file ~41 MB.
129+
// Byte-based behavior: ~10 MB extension -> file ~11-12 MB.
130+
Assert.True(
131+
fileSize < 20L * 1024 * 1024,
132+
$"File grew to {fileSize} bytes; expected a ~10 MB byte-based extension, not a page-count-based one.");
133+
}
134+
135+
[Fact]
136+
public void InvalidRegionSizes_ShouldThrowOnValidate()
137+
{
138+
var fsmZero = DatabaseOptions.CreateSingleFileDefault();
139+
fsmZero.FsmSizePages = 0;
140+
Assert.Throws<ArgumentException>(() => fsmZero.Validate());
141+
142+
var registryNegative = DatabaseOptions.CreateSingleFileDefault();
143+
registryNegative.BlockRegistrySizePages = -1;
144+
Assert.Throws<ArgumentException>(() => registryNegative.Validate());
145+
146+
var tableDirTooBig = DatabaseOptions.CreateSingleFileDefault();
147+
tableDirTooBig.TableDirectorySizePages = 70000;
148+
Assert.Throws<ArgumentException>(() => tableDirTooBig.Validate());
149+
}
150+
}

0 commit comments

Comments
 (0)