Isaac.FileStorage 2.2.1

dotnet add package Isaac.FileStorage --version 2.2.1
                    
NuGet\Install-Package Isaac.FileStorage -Version 2.2.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Isaac.FileStorage" Version="2.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Isaac.FileStorage" Version="2.2.1" />
                    
Directory.Packages.props
<PackageReference Include="Isaac.FileStorage" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Isaac.FileStorage --version 2.2.1
                    
#r "nuget: Isaac.FileStorage, 2.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Isaac.FileStorage@2.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Isaac.FileStorage&version=2.2.1
                    
Install as a Cake Addin
#tool nuget:?package=Isaac.FileStorage&version=2.2.1
                    
Install as a Cake Tool

JSONStorage

.NET NuGet NuGet Downloads

A tiny, dependency-light key/value file storage library for .NET. Give it a key and any object, and it serializes the object to BSON (binary JSON) and writes it to a .j2k file named after the key: no schema, no database, no manual (de)serialization code.

Targets net10.0.

Install

dotnet add package Isaac.FileStorage
using Isaac.FileStorage;

Quick start

var store = new FileStorageEngine("data"); // creates "data" if it doesn't exist

store.Insert("user-42", new { Name = "Ada", Role = "Admin" });

var user = store.Get<dynamic>("user-42");

foreach (var key in store.GetAllKeys())
    Console.WriteLine(key);

store.Delete("user-42");

Each entry is stored as its own <key>.j2k file inside the storage directory. Keys map directly to file names, so anything that's a valid file name works as a key.

API

Member Description
FileStorageEngine(string dirPath, TimeSpan? lockTimeout = null) Opens (or creates) the storage directory at dirPath. DirectoryPath exposes the resolved full path. lockTimeout (default 30s) caps how long a call waits to acquire a key's lock; must be non-negative and no more than 100 years.
void Insert<T>(string key, T obj) / Task InsertAsync<T>(string key, T obj, CancellationToken ct = default) Serialises obj to BSON and writes/overwrites <key>.j2k. Inserting under an existing key replaces its contents. Writes atomically (via a temp file + rename), so a failure or cancellation partway through can never leave <key>.j2k truncated or partially overwritten — you keep either the old value or the fully-written new one.
T Get<T>(string key) / Task<T> GetAsync<T>(string key, CancellationToken ct = default) Reads <key>.j2k and deserialises it into T. If the key doesn't exist, no trace of the attempt is left behind — see below.
IEnumerable<string> GetAllKeys() / Task<IEnumerable<string>> GetAllKeysAsync(CancellationToken ct = default) Returns every key currently stored (i.e. every .j2k file's base name) in the storage directory.
void Delete(string key) / Task DeleteAsync(string key, CancellationToken ct = default) Deletes <key>.j2k. On success, also removes that key's sidecar .lock and any leftover .tmp file.
(int LockFilesRemoved, int TempFilesRemoved) PruneOrphanedFiles() Removes stray sidecar files left behind by interrupted operations: .lock files with no corresponding .j2k, and .tmp files left over from an Insert that crashed between writing and its atomic rename. Not called automatically — call it yourself if you want to reclaim that space. Safe to call anytime, including mid-flight: a file currently in active use for its key is simply skipped and left for a future call.

Concurrency

Insert/Get/Delete (sync and async) are safe to call concurrently — from multiple threads and multiple processes — against the same key. Access to different keys never blocks each other. This is enforced by a per-key lock: an in-process SemaphoreSlim for cheap same-process waiting, plus an exclusively-held sidecar <key>.j2k.lock file for real cross-process exclusion. The async methods only get genuine non-blocking behavior on the lock wait and (for Insert) the write itself — Newtonsoft's BSON reader has no async API, so GetAsync's deserialization step still runs synchronously once the lock is held.

Known trade-offs, by design:

  • Not reentrant. If the same call chain re-enters a lock it's already holding for the same key (e.g. a custom serialization callback that calls back into Insert/Get for that key), it waits on itself and eventually throws LockTimeoutException rather than deadlocking forever. A fail-fast reentrancy guard was tried and dropped: it relied on AsyncLocal, which flows into Task.Run by default, making it indistinguishable from — and prone to misfiring on — the common, legitimate pattern of firing off independent concurrent work via Task.Run while already holding a lock.
  • GetAllKeys()/GetAllKeysAsync() aren't lock-protected. Locking the whole directory for a listing would defeat the point of per-key parallelism, so the result is a point-in-time snapshot that can be stale by the time you act on it — the same way any directory listing is under concurrent modification. It's guaranteed to never throw due to concurrent inserts/deletes elsewhere, just not guaranteed to be exactly current.
  • Locks are held one per key that's ever been accessed, for as long as that key exists, as a small <key>.j2k.lock sidecar file — this roughly doubles file count in the storage directory at scale. This is intentional: recreating/deleting the lock file on every single access would add I/O overhead for no benefit. Lock files are cleaned up automatically when their key is Deleted; PruneOrphanedFiles() handles the rest (stale ones left behind by out-of-band data deletion, or by versions before 2.1). A key that turns out not to have real data is a special case: Insert/Get/Delete/PruneOrphanedFiles all have to acquire the lock to safely check or attempt a write, which creates the lock file as a side effect — but they also clean that up immediately once they confirm there's no real data behind it (a failed Insert on a brand-new key, Get/Delete on a key that's never been created, or a stray temp file with no data of its own), so none of these leave anything behind.
  • Locking only protects access mediated through this library. It can't protect against something else — another tool, a manual edit — touching the .j2k files directly.

Exceptions

All custom exceptions live in Isaac.FileStorage.CustomExceptions.

Exception Thrown by When
EmptyKeyException Insert, Get, Delete (sync and async) key is null or empty.
InvalidKeyException Insert, Get, Delete (sync and async) key would resolve to a path outside the storage directory (e.g. contains .. traversal or is an absolute path).
StorageKeyNotFoundException Delete (sync and async) key doesn't exist.
LockTimeoutException Insert, Get, Delete (sync and async) Waiting to acquire the key's lock exceeded lockTimeout.
InvalidOperationException Insert, Get, Delete (sync and async) An unexpected I/O or (de)serialisation failure occurred (e.g. corrupt file, type mismatch, file locked by another process). The original exception is preserved as InnerException.

FileStorageEngine's constructor also throws ArgumentNullException/ArgumentException if dirPath is null or empty.

Notes

  • Storage format is BSON, written to .j2k files.
  • As of 2.0, keys are validated so they can never resolve to a path outside the storage directory.
  • 2.0 dropped automatic migration of legacy pre-0.3 plain-JSON .jk files — if you're upgrading from one of those very old versions, convert your data before updating past 1.6.
  • 2.1 added concurrency safety, the async API (InsertAsync/GetAsync/DeleteAsync/GetAllKeysAsync), atomic writes for Insert/InsertAsync, and PruneOrphanedFiles() — fully additive, no breaking changes from 2.0.
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

2.2.1 fixes a gap in 2.2.0's own PruneOrphanedFiles double-count fix (below): that fix relied on FileShare.Delete-based file exclusivity alone to stop two concurrent PruneOrphanedFiles calls from each claiming the same orphaned lock file, which held up under testing on Windows but not reliably under heavy same-process contention on Linux - caught by CI, not by local testing beforehand. The claim now also goes through the same in-process SemaphoreSlim every other lock acquisition in this library already relies on, rather than depending on FileShare semantics to carry it alone. No public API changes. 2.2.0 is a set of correctness fixes and an annotation improvement on top of 2.1.0's concurrency support, with one behavior-relevant fix: PruneOrphanedFiles could, on Linux/macOS, delete a key's sidecar lock file while another Insert/Get/Delete/DeleteAsync (or another PruneOrphanedFiles call) was still actively using it - POSIX's unlink() doesn't respect an open file the way Windows does - potentially letting two operations run against the same key without real exclusion between them; lock files are now only ever deleted while the deleting call still holds them, closing that gap on every platform. PruneOrphanedFiles's returned LockFilesRemoved/TempFilesRemoved counts could also be inflated under concurrent PruneOrphanedFiles calls, since each caller could recreate and then immediately delete the same already-removed orphan and count it again; a single logical orphan is now counted at most once no matter how many callers race for it. The library now builds with Nullable Reference Types enabled: Get<T>/GetAsync<T> return T? (a key storing a null value round-trips back as null, now explicit in the signature), and Insert<T>/InsertAsync<T>'s obj parameter and the four custom exceptions' message constructor parameter are similarly annotated as nullable to match their actual null-tolerant behavior. None of this is binary-breaking - nullable annotations are compile-time-only and don't change the IL - but consumers who have Nullable enabled themselves may see new compiler hints after upgrading. 2.1.0 adds concurrency support, fully additive over 2.0.x. Insert, Get, and Delete are now safe to call concurrently from multiple threads and even multiple processes against the same key (different keys never block each other), backed by a per-key lock: an in-process, reference-counted SemaphoreSlim plus an exclusively-held sidecar ".lock" file for cross-process safety, correctly cleaned up even when a caller's CancellationToken fires mid-wait. Adds InsertAsync, GetAsync, DeleteAsync, and GetAllKeysAsync. The constructor gains an optional lockTimeout parameter (default 30s, must be non-negative and at most 100 years), bounding the total wait across both lock layers combined; exceeding it throws the new LockTimeoutException. Delete/DeleteAsync now also remove their key's sidecar lock file and any leftover temp file; the new PruneOrphanedFiles() removes any left behind by data deleted outside this library or by a crashed Insert, doing so safely by briefly acquiring each key's own lock before touching its temp file. Insert/InsertAsync now write atomically via a temp file plus rename, so a crash, disk-full error, or cancelled write can no longer truncate or partially overwrite a key's existing data - InsertAsync also discards the write if cancellation arrives after it finishes but before the rename, rather than completing it anyway. Insert, InsertAsync, Get, GetAsync, Delete, DeleteAsync, and PruneOrphanedFiles no longer leave a stray lock file behind for a key that turns out to have no real data - acquiring the lock to safely check or attempt a write is sometimes unavoidable, but it's now cleaned back up immediately once that's confirmed, so a routine "does this key exist" check, or a failed Insert on a brand-new key, leaves nothing behind. 2.0.0 was a breaking release: custom exceptions moved to Isaac.FileStorage.CustomExceptions, KeyNotFoundException was renamed to StorageKeyNotFoundException, a path-traversal vulnerability in key handling was fixed, legacy pre-0.3 .jk migration was dropped, and Insert/Get/Delete now consistently wrap unexpected failures in InvalidOperationException with the original exception preserved as InnerException.