Core Concepts
Modes
MemDb supports four operational modes. The first three are explicit AccessMode values set during configuration; the fourth is implicit, selected by omitting the directory path.
| Mode | Description |
|---|---|
ReadWrite | Full read and write access (default). The entire dataset is held in RAM, and all modifications are persisted to disk. |
ReadOnly | Read access only. On open, the entire dataset is loaded into RAM. Insert, update, and delete throw InvalidOperationException. |
AppendOnly | Insert only — reads, updates, and deletes throw. No in-memory cache; writes go straight through the queue to disk. The fastest write path. Unfiltered Count() is still permitted (read from the always-initialized record map). |
| Volatile (none) | No persistence; data exists only in RAM for the lifetime of the process. Selected by omitting the directory path on ConfigureFor. Useful for ephemeral caches and test fixtures. |
// ReadOnly
MemDb.ConfigureFor<Person>("people", _dbDirectory)
.SetMode(AccessMode.ReadOnly)
.Register();
// Volatile (no path)
MemDb.ConfigureFor<Person>("cache")
.Register();Persistence
When a directory path is provided, MemDb manages three file types:
| Name Pattern | Purpose |
|---|---|
htl.{name}.db | Data records, written sequentially as records are flushed |
htl.{name}.map | Pointer map — id, state, timestamps, encryption flag, position, and length of each .db file record |
~$htl.{name}.lock | OS-enforced cross-process lock file |
Each pointer in the .map file is exactly 39 bytes on disk. The map file has a 12-byte header (4 bytes for record count, 8 bytes for the last-assigned identity), so the on-disk overhead for the pointer map is 12 + (39 × record count) bytes.
The lock file is opened with FileShare.None and FileOptions.DeleteOnClose. It is automatically released by the operating system when the holding process exits — including on abnormal termination.
Flush
Pending writes are flushed to disk in three ways: on a configurable timer, manually via db.Flush(), or automatically on dispose.
The default flush interval is 5 seconds. The interval can be adjusted up to a maximum of 60 seconds, or set to 0 to disable automatic flushing entirely:
MemDb.ConfigureFor<Person>("people", _dbDirectory)
.SetFlushInterval(8) // flush every 8 seconds
.Register();db.Flush() forces an immediate write — useful before any critical persistence checkpoint:
using (var db = MemDb.Open<Person>("people"))
{
db.Insert(/* ... */);
db.Flush();
}0 disables automatic flushing. Writes are persisted only on explicit db.Flush() or on dispose. An abnormal process exit that bypasses dispose will result in data loss.The defaults and limits referenced in this section are exposed as public const values on MemDbConfiguration if you need to reference them in code:
| Constant | Value | Used for |
|---|---|---|
MemDbConfiguration.DefaultFlushIntervalSeconds | 5 | Default flush interval |
MemDbConfiguration.MaxFlushIntervalSeconds | 60 | Upper bound accepted by SetFlushInterval |
MemDbConfiguration.MinPasswordLength | 10 | Minimum length for EncryptWithPassword |
MemDbConfiguration.ArchiveTimestampFormat | yyyyMMdd.HHmm.ss.fff | Naming pattern for archive .bak files |
MemDbConfiguration.SnapshotTimestampFormat | yyyyMMdd.HHmm.ss.fff | Naming pattern for snapshot files |
Cache
Both the in-memory cache and the data files on disk are log-structured: new versions are appended and old versions are marked stale, never modified in place. Stale and deleted records are reclaimed from the cache via Online Cache Maintenance and from disk via Defragmentation.
For the full write path and read path, see Architecture.
Data Integrity
MemDb guarantees data integrity by deep-cloning every record that crosses the cache boundary — in both directions:
- On insert — the record is cloned before it enters the cache.
- On read — the cached record is cloned before it is returned.
- On update — the cached record is deep-cloned, the apply action runs against the clone, and the clone is appended as the new fresh version. Sequential updates between flushes therefore produce distinct, archivable record versions rather than collapsing into one.
The mechanism used for deep cloning depends on what is registered:
| Registered | Clone mechanism |
|---|---|
| Nothing (defaults) | Round-trip through System.Text.Json.JsonSerializer |
IMemDbSerializer<T> only | Round-trip through the custom serializer |
IMemDbCloner<T> (with or without a custom serializer) | Direct field copy via the cloner — fastest |
See Extensibility for details on providing custom serializers and cloners.
Concurrency
All read and write operations within a single process are thread-safe — no additional locking or coordination required. Multiple datasets can be open simultaneously within the same process; each instance is independent and identified by its unique dataset name.
Cross-process access to the same database files is prevented by the ~$htl.{name}.lock file, opened with FileShare.None. A second process attempting to open the same database fails at open time. Because the lock file is also opened with FileOptions.DeleteOnClose, the OS releases it on exit (including abnormal termination) — no stale-lock recovery procedure required.
Computed Properties
MemDb stores the full object graph. Computed (non-persisted) properties work naturally.
public class Person
{
public long Id { get; set; }
public DateTime BirthDate { get; set; }
// Not persisted — derived from BirthDate on every read.
// [JsonIgnore] is optional for getter-only properties; it documents intent.
[JsonIgnore]
public bool IsAdult => BirthDate < DateTime.Now.AddYears(-18);
}MemDb’s default System.Text.Json configuration sets IgnoreReadOnlyProperties = true, so getter-only computed properties like IsAdult are automatically excluded from the serialized output without any attribute — [JsonIgnore] on a getter-only property documents intent but does not change behavior.
[JsonIgnore] is required for get-set properties that represent computed or derived state (e.g., a denormalized field maintained in code) — the default serializer treats them as persisted unless told otherwise.
Custom serializers have no such concern — they only read and write the fields they explicitly handle. See Custom Serialization.
Failure modes
A flush exception (disk full, permission revoked, etc.) halts the persister: the flush timer is disposed and all subsequent operations on the instance throw MemDbPersisterDisposedException. Its FlushException property exposes the originating MemDbFlushException; the underlying disk/IO exception is on InnerException. Recovery: address the root cause, dispose the failed instance, re-open the database.
If a flush fails partway through writing a record, the data file is truncated back to the end of the last successfully written record before the exception propagates — .db and .map remain mutually consistent across crashes.