Isaac.FileStorage
2.2.1
dotnet add package Isaac.FileStorage --version 2.2.1
NuGet\Install-Package Isaac.FileStorage -Version 2.2.1
<PackageReference Include="Isaac.FileStorage" Version="2.2.1" />
<PackageVersion Include="Isaac.FileStorage" Version="2.2.1" />
<PackageReference Include="Isaac.FileStorage" />
paket add Isaac.FileStorage --version 2.2.1
#r "nuget: Isaac.FileStorage, 2.2.1"
#:package Isaac.FileStorage@2.2.1
#addin nuget:?package=Isaac.FileStorage&version=2.2.1
#tool nuget:?package=Isaac.FileStorage&version=2.2.1
JSONStorage
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/Getfor that key), it waits on itself and eventually throwsLockTimeoutExceptionrather than deadlocking forever. A fail-fast reentrancy guard was tried and dropped: it relied onAsyncLocal, which flows intoTask.Runby default, making it indistinguishable from — and prone to misfiring on — the common, legitimate pattern of firing off independent concurrent work viaTask.Runwhile 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.locksidecar 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 isDeleted;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/PruneOrphanedFilesall 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 failedInserton a brand-new key,Get/Deleteon 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
.j2kfiles 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
.j2kfiles. - 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
.jkfiles — 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 forInsert/InsertAsync, andPruneOrphanedFiles()— fully additive, no breaking changes from 2.0.
| Product | Versions 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. |
-
net10.0
- Newtonsoft.Json (>= 13.0.3)
- Newtonsoft.Json.Bson (>= 1.0.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated | |
|---|---|---|---|
| 2.2.1 | 120 | 7/23/2026 | |
| 2.2.0 | 117 | 7/23/2026 | |
| 2.0.1 | 109 | 7/22/2026 | |
| 1.6.0 | 713 | 3/8/2022 | |
| 1.5.0 | 585 | 3/8/2022 | |
| 1.3.0 | 543 | 2/16/2021 | |
| 1.1.0 | 595 | 3/8/2022 | |
| 1.0.0 | 561 | 1/24/2021 | |
| 0.3.4 | 580 | 1/15/2021 | |
| 0.3.3 | 551 | 1/15/2021 | |
| 0.3.2 | 647 | 1/10/2021 | |
| 0.3.1 | 837 | 1/9/2021 | |
| 0.2.5 | 1,076 | 1/6/2021 | |
| 0.2.4 | 1,072 | 1/6/2021 | |
| 0.2.3 | 719 | 1/6/2021 | |
| 0.2.2 | 729 | 1/6/2021 | |
| 0.2.1 | 960 | 1/6/2021 | |
| 0.2.0 | 1,458 | 1/5/2021 |
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.