How to Build a Disk-Backed C# Collection
A List<T> stops being practical when the data must survive a restart or is too large to hold in memory. The usual answer is a hand-written file format, which leaves the application responsible for record offsets, byte order and counts. Sphere10 Framework's stream-mapped collections keep the list, dictionary and set contracts and store the items in a stream instead.
This article creates a persistent list with StreamMappedFactory, appends to it, updates an item in place, and reopens the file to read a range. It also covers which other collections the factory builds, who owns the stream, and what must match when the file is reopened.
1. A first look
StreamMappedFactory.CreateList<T> takes the backing stream, a cluster size and an item serializer, and returns a list that stores its items in that stream.
using (var file = File.Open(path, FileMode.Create, FileAccess.ReadWrite)) {
using var list = StreamMappedFactory.CreateList<int>(file, clusterSize: 4096, itemSerializer: PrimitiveSerializer<int>.Instance, autoLoad: true);
list.AddRange(Enumerable.Range(0, count));
list.Update(updatedIndex, -1);
}
AddRange appends 36,000 integers, and Update replaces one item by position. Reopening uses the same factory call over the existing file.
using var reopenedFile = File.Open(path, FileMode.Open, FileAccess.ReadWrite);
using var reopened = StreamMappedFactory.CreateList<int>(reopenedFile, clusterSize: 4096, itemSerializer: PrimitiveSerializer<int>.Instance, autoLoad: true);
var range = reopened.ReadRange(updatedIndex - 1L, 4).ToArray();
The second list reads its items from the file the first one wrote, and ReadRange deserializes only the requested items.
2. How a stream-mapped list is stored
A stream-mapped list does not hold its items in memory. Each call serializes or deserializes the items it touches through an IItemSerializer<T>, and the list's Count and positions are 64-bit values.
Each layer sits on the one below it. The list writes through an ObjectStream<T>, which stores each item as its own record. Those records live in a ClusteredStreams container that divides one seekable stream into many logical streams and allocates space in fixed-size clusters. Items may therefore have different serialized sizes, and the application never calculates a byte offset.
3. Create the list and write to it
The list that CreateList returned in the first look is an IStreamMappedList<int>, which is an IExtendedList<int>. That contract adds long-indexed and batch members to the familiar list operations.
| Member | Effect |
Add, AddRange |
Append items. |
Read, ReadRange(index, count) |
Deserialize one item or a contiguous run. |
Update, UpdateRange |
Replace items in place. |
Insert, InsertRange |
Insert at a position. |
RemoveAt, RemoveRange |
Remove by position. |
Count |
The number of items, as a long. |
The arguments to CreateList control the stored layout.
-
clusterSizeis the allocation unit of the container, in bytes. -
itemSerializerconverts an item to and from bytes.PrimitiveSerializer<T>covers primitives, and anyIItemSerializer<T>can be supplied for other types. -
endiannessis the byte order of the stored data. It defaults to little-endian. -
autoLoadloads existing content during creation. Without it, callLoad()whenRequiresLoadis true. -
itemChecksummerandoptionalItemChecksumIndexNameadd a checksum index so thatIndexOfandContainsavoid reading every item.
4. Reopen the file and read a range
The first look reopened the file with the arguments it was created with, and that is a requirement. The cluster size, serializer and byte order are not negotiated at open time, so supply the values the file was created with. A different serializer would reinterpret the stored bytes.
5. Other stream-mapped collections
The same factory builds the other persistent collections, all over a stream or an existing ClusteredStreams container.
| Factory method | Result |
CreateList |
A positional list. |
CreateRecyclableList |
A list whose removed slots are reused, so item positions stay stable. |
CreateDictionary |
A dictionary. It keeps constant-length keys in a key store, and finds variable-length keys through a checksum index. |
CreateHashSet |
A set keyed by each item's digest. |
CreateBTree, CreateBTreePlus, CreateBTreeLookup |
Sorted trees for constant-size keys and values, which the B+ tree article covers. |
6. Ownership and limits
Disposing the list disposes the container the factory created for it. The backing stream stays with the caller, which is why the sample wraps both the file and the list in using. Dispose the list first.
The container adds headers and per-item records to the file. For fixed-width values that are only ever appended and read by position, a raw binary file is smaller. The stream-mapped list is the better fit when items vary in size, when they are inserted, updated or removed, or when the code should depend on a collection contract rather than a file layout.
7. Get the package and run the sample
Add the package to a .NET 10 project.
dotnet add package Sphere10.Framework
The collections ship in the Sphere10.Framework NuGet package. Download the sample project. It creates the list, appends the items, updates one, reopens the file and checks the range it reads back. Use the .NET 10 SDK and Sphere10 Framework.
dotnet run -c Release -- ./fresh-example-data
Items after reopen: 36000
Range from index 18008: 18008, -1, 18010, 18011
Download the complete NuGet sample project. The archive contains the project, its model files and run instructions. Keep the pinned package version when reproducing the example.
The Sphere10 Framework overview describes the rest of the toolkit.
Author: Herman Schoenfeld