Core Concepts

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.

ModeDescription
ReadWriteFull read and write access (default). The entire dataset is held in RAM, and all modifications are persisted to disk.
ReadOnlyRead access only. On open, the entire dataset is loaded into RAM. Insert, update, and delete throw InvalidOperationException.
AppendOnlyInsert 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 PatternPurpose
htl.{name}.dbData records, written sequentially as records are flushed
htl.{name}.mapPointer map — id, state, timestamps, encryption flag, position, and length of each .db file record
~$htl.{name}.lockOS-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();
}
Setting the flush interval to 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:

ConstantValueUsed for
MemDbConfiguration.DefaultFlushIntervalSeconds5Default flush interval
MemDbConfiguration.MaxFlushIntervalSeconds60Upper bound accepted by SetFlushInterval
MemDbConfiguration.MinPasswordLength10Minimum length for EncryptWithPassword
MemDbConfiguration.ArchiveTimestampFormatyyyyMMdd.HHmm.ss.fffNaming pattern for archive .bak files
MemDbConfiguration.SnapshotTimestampFormatyyyyMMdd.HHmm.ss.fffNaming 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:

RegisteredClone mechanism
Nothing (defaults)Round-trip through System.Text.Json.JsonSerializer
IMemDbSerializer<T> onlyRound-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.