Repository navigation
Description
Describe the bug
When a SingleFile (.scdb) database grows beyond a specific size threshold, FreeSpaceManager.FlushAsync throws an InvalidOperationException because the serialized FSM bitmap exceeds its hardcoded 4-page allocation limit.
To Reproduce
- Create a SingleFile database with the default
PageSize = 4096. - Insert records continuously until the file size approaches ~350MB - 500MB.
- Trigger a flush (e.g., via
ExecuteBatchSQLtransaction commit or explicitFlush()). - See error:
System.InvalidOperationException: FSM too large: 22685 bytes exceeds limit 16384 at SharpCoreDB.Storage.FreeSpaceManager.FlushAsync(CancellationToken cancellationToken)
Expected behavior
The database should support file sizes well beyond 512MB without crashing, or the FSM allocation should scale dynamically with the file size.
Root Cause Analysis
In SingleFileStorageProvider.InitializeNewFile, the FSM is allocated a fixed 4 pages regardless of the expected database size:
header.FsmLength = (ulong)options.PageSize * 4; // 4 pages for FSMWith a 4KB page size, 4 pages = 16,384 bytes.
The FSM L1 bitmap requires 1 bit per allocated page. 16,384 bytes provides exactly 131,072 bits.
131,072 pages × 4KB/page = 512 MB maximum theoretical file size.
In practice, the 64-byte FreeSpaceMapHeader, the L2 extent map, and padding reduce this usable limit. During a 10M record stress test (which peaked at ~338MB / ~86,500 pages), the L1 bitmap (~10.8KB) combined with the L2 extent map exceeded the 16KB limit, triggering the exception.
Current Workaround
During a 10M record stress test (which peaked at ~1.4GB uncompressed), I successfully bypassed this limitation by increasing PageSize from the default 4096 to 16384:
options.PageSize = 16384;With PageSize = 16384, the fixed 4-page FSM allocation becomes 64KB. This increases the tracking capacity to 524,288 pages, supporting a maximum file size of ~8GB.
However, this is not an ideal permanent solution for downstream users because:
- It significantly increases the WAL memory footprint (default 1024 pages × 16KB = 16MB of RAM just for the WAL).
- It increases internal fragmentation for smaller data blocks.
- It merely raises the hard ceiling rather than eliminating it.
Proposed Fixes
- v1.x Mitigation: Increase the default FSM allocation (e.g., to 64 pages) or expose a
DatabaseOptions.FsmSizePagesproperty to allow users to configure the FSM footprint based on their expected maximum database size. - v2.0 Architectural Fix: Move the FSM out of the fixed-offset header layout and manage it as a standard dynamic block in the
BlockRegistry(e.g.,sys:fsm). This would allow the FSM to grow organically as the database expands, eliminating the hard ceiling entirely.
Desktop (please complete the following information):
- OS: Windows NT 10.0.26200.0
- Runtime: .NET 10.0.3
- Library Version: v1.9.8
Why I haven't submitted a PR for this yet
Unlike the block-level compression feature (PR #344), which was a purely additive extension, fixing this FSM limitation requires an architectural decision that impacts the .scdb file format:
- Option 1 (v1.x Mitigation) is a localized, backward-compatible fix.
- Option 2 (v2.0 Refactor) requires moving the FSM out of the fixed-offset header layout and managing it as a standard dynamic block in the
BlockRegistry. This is a breaking change to the file format.
Given that v2.0 appears to be in development (with the block-registry encryption roadmap mentioned in #341), Option 2 might be the perfect fit for the upcoming release. However, I did not want to invest the effort into a large refactor PR without knowing your architectural preference. I am happy to submit a PR for either approach once you have decided on the direction.
Additional context
Discovered while running a 10M record stress test for the block-level compression PR (#344). The compression feature itself is orthogonal to this issue, but the stress test reliably triggers the FSM ceiling.
Activity
[-][Bug] FreeSpaceManager FSM bitmap exceeds fixed 4-page allocation at scale[/-][+][Bug] Fixed-size metadata allocations in SingleFile header break at scaled PageSizes (FSM, BlockRegistry, FSM Extension)[/+]saltus7 commentedon Aug 30, 2026
Update: Additional Findings (Block Registry & FSM Extension)
During further stress testing (specifically a manual Disk Full POC using a mounted VHD), I discovered two additional bugs that share the exact same root cause as the FSM bitmap limitation: hardcoded page-count constants that fail to scale when
PageSizeis increased.1. Block Registry Fixed Allocation Limit
Just like the FSM, the Block Registry is allocated a fixed 4 pages in
InitializeNewFile:Each
BlockEntryis 96 bytes. With a standard 4KB page size, this limits the database to a maximum of ~170 blocks (16,320 usable bytes ÷ 96 bytes per entry) before throwingInvalidOperationException: Block registry too large.2.
MIN_EXTENSION_PAGESScales Linearly with PageSizeIn
FreeSpaceManager.ExtendFile, the minimum file extension is defined as a page count, not a byte count:The comment reveals the intent (a 10MB minimum extension), but because it is a page count, the actual byte size scales linearly with
PageSize:Impact: When testing with
PageSize = 32768on a 150MB VHD, the very firstWriteBlockAsynctriggeredExtendFile, which requested an 80MB allocation. Because the VHD had slightly less than 80MB of contiguous free space remaining, the OS immediately threw a hardIOException("There is not enough space on the disk"), entirely bypassing the expected gradual disk-fill behavior.The Shared Root Cause & v2.0 Solution
All three limitations (FSM capacity, Block Registry capacity, and FSM Extension thresholds) stem from treating critical metadata as fixed-size structures in the file header layout.
The v1.x mitigations (exposing
FsmSizePages/RegistrySizePagesinDatabaseOptions, or convertingMIN_EXTENSION_PAGESto a byte-based constant) are localized patches.The ideal v2.0 architectural fix is to move the FSM and Block Registry out of the fixed-offset header layout entirely, managing them instead as standard, dynamically-sized blocks within the
BlockRegistryitself (e.g.,sys:fsmandsys:registry). This would allow the metadata to grow organically as the database expands, permanently eliminating these hard ceilings.MPCoreDeveloper commentedon Aug 30, 2026
Thanks @saltus7 — excellent, precise root-cause analysis. Confirmed on all three counts, with a small addition:
FsmLength = PageSize * 4inInitializeNewFile→ ~512 MB ceiling at 4 KB pages (slightly lower once the 64-byte header + L2 extent map are accounted for, exactly as you measured).BlockRegistryLength = PageSize * 4, andBlockEntry.SIZEis indeed 96 bytes (~170 entries at 4 KB pages). The "64B per entry" comment inBlockRegistry.csis stale — good catch.MIN_EXTENSION_PAGES = 2560: it is a page count, so the actual allocation scales linearly withPageSize(40 MB @ 16 KB, 80 MB @ 32 KB) instead of staying a fixed ~10 MB. Confirmed.TableDirLength = PageSize * 4(table directory). Lower risk since schema metadata is small, but it is the same fixed-4-pages pattern.Direction — we'll do this in two steps:
master(backward-compatible): a localized fix — make the file-extension threshold byte-based (MIN_EXTENSION_BYTES), and exposeFsmSizePages/BlockRegistrySizePages/TableDirectorySizePagesonDatabaseOptions(defaults stay at 4 pages, so existing files are unaffected and new files self-describe their region sizes in the header). This removes the practical ceiling for 1.9.x users without a format change.fix(#345): make SingleFile metadata region sizes configurable and fil…
fix(#345): make SingleFile metadata region sizes configurable and fil…
test(#345): use ForceFlushAsync in metadata sizing test for durable r…
docs(v2): release 2.0.0-preview.2 - changelog, NuGet README, roadmap;…
docs(v1): 1.9.8 release notes - compression, metadata sizing, unicode…
fix(#345): serialize single-file stream writes - fix registry/FSM/dat…
fix(#345): serialize single-file stream writes - fix registry/FSM/dat…
fix(#345): serialize registry flushes (gate) + flush-until-clean loop…
fix(#345): serialize registry flushes (gate) + flush-until-clean loop…
feat(issue #345): dynamic metadata layout (format v2) - growable FSM …
fix(issue #345): exclude sys:fsm (not all sys: blocks) from vacuum/ke…
docs(issue #345): update binary-format / storage / encryption docs fo…
feat(issue #345): make initial BlockRegistry block size configurable …
MPCoreDeveloper commentedon Aug 30, 2026
Resolved in
v2.0.0-preview.3(released 2026-08-30 on therelease/v2.0.0.0line).Both parts of the plan from the maintainer's earlier comment are now implemented:
1. Localized / backward-compatible fixes
DatabaseOptions.FsmSizePages,BlockRegistrySizePagesandTableDirectorySizePagessize the metadata regions — they are no longer hard-coded to 4 pages.MIN_EXTENSION_PAGESscaling linearly withPageSize(fixes the 40 MB @ 16 KB / 80 MB @ 32 KB allocations).2. Architectural fix — dynamic metadata layout (format v2)
FormatVersionbumped to 2 (FEATURE_DYNAMIC_METADATA).header.RegistryRootOffset; it relocates/grows automatically when it outgrows its current block.sys:fsm) tracked in the registry; its serialized bitmap relocates/grows automatically when the database outgrows the initialFsmSizePagescapacity.<file>.backup.Validation
LegacyMigrationTests), FSM-block growth + data round-trip, dynamic registry growth (300+ blocks), tampered-registry detection at the new dynamic location, and metadata sizing (MetadataSizingTests).SharpCoreDB 2.0.0-preview.3(and all 24 package line) on NuGet.org.Closing as resolved — thanks for the precise root-cause analysis, @saltus7.