mostlylucid.ephemeral.sqlite.singlewriter 2.6.4

dotnet add package mostlylucid.ephemeral.sqlite.singlewriter --version 2.6.4
                    
NuGet\Install-Package mostlylucid.ephemeral.sqlite.singlewriter -Version 2.6.4
                    
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="mostlylucid.ephemeral.sqlite.singlewriter" Version="2.6.4" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="mostlylucid.ephemeral.sqlite.singlewriter" Version="2.6.4" />
                    
Directory.Packages.props
<PackageReference Include="mostlylucid.ephemeral.sqlite.singlewriter" />
                    
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 mostlylucid.ephemeral.sqlite.singlewriter --version 2.6.4
                    
#r "nuget: mostlylucid.ephemeral.sqlite.singlewriter, 2.6.4"
                    
#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 mostlylucid.ephemeral.sqlite.singlewriter@2.6.4
                    
#: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=mostlylucid.ephemeral.sqlite.singlewriter&version=2.6.4
                    
Install as a Cake Addin
#tool nuget:?package=mostlylucid.ephemeral.sqlite.singlewriter&version=2.6.4
                    
Install as a Cake Tool

Mostlylucid.Ephemeral.Sqlite.SingleWriter

NuGet License

SQLite single-writer helper using Ephemeral patterns for serialized writes, cached reads, and signal-based observability.

dotnet add package mostlylucid.ephemeral.sqlite.singlewriter

Quick Start

using Mostlylucid.Ephemeral.Sqlite;

var writer = SqliteSingleWriter.GetOrCreate(
    "Data Source=mydb.sqlite;Mode=ReadWriteCreate;Cache=Shared");

// Serialized writes (single-writer pattern)
await writer.WriteAsync("INSERT INTO Users (Name) VALUES (@Name)", new { Name = "Alice" });

// Cached reads
var userCount = await writer.ReadAsync("users:count", async conn =>
{
    await using var cmd = conn.CreateCommand();
    cmd.CommandText = "SELECT COUNT(*) FROM Users";
    return (int)(long)await cmd.ExecuteScalarAsync();
});

// Write and invalidate cache
await writer.WriteAndInvalidateAsync(
    "INSERT INTO Users (Name) VALUES ('Bob')",
    cacheKeysToInvalidate: new[] { "users:count" });

Why Ephemeral Here?

  • Single writer = long-lived coordinator: uses EphemeralWorkCoordinator with MaxConcurrency=1 so every write flows through the same queue and is tracked with snapshots and signals.
  • Signals everywhere: write start/done/error, batch begin/commit/rollback, per-statement counts, WAL/foreign key pragmas, cache hits/misses/sets/invalidations, and external invalidation echoes keep the internal state visible.
  • Self-focusing cache: EphemeralLruCache extends TTL for hot keys and emits cache.hot/evict signals so you can watch churn and invalidate centrally.
  • Cross-process invalidation: a shared SignalSink lets other components raise cache.invalidate:*; the single writer listens and clears its cache automatically while emitting cache.invalidate.external:*.
  • Backpressure-friendly: write operations are small WriteCommand ephemerals; sampling via SampleRate keeps signal noise down but still lets you see live branches inside a batch/transaction.

All Options

SqliteSingleWriterOptions

var options = new SqliteSingleWriterOptions
{
    // Maximum items in cache
    // Default: 1000
    CacheSizeLimit = 1000,

    // Default cache duration (sliding expiration)
    // Default: 5 minutes
    DefaultCacheDuration = TimeSpan.FromMinutes(5),

    // Extended TTL for hot keys
    // Default: 30 minutes
    HotKeyExtension = TimeSpan.FromMinutes(30),

    // Accesses before a key is "hot"
    // Default: 3
    HotAccessThreshold = 3,

    // Max write operations tracked in memory
    // Default: 128
    MaxTrackedWrites = 128,

    // Signal sampling rate (1 = all, 10 = 1 in 10)
    // Default: 1
    SampleRate = 1,

    // PRAGMA busy_timeout for all connections
    // Default: 10 seconds
    BusyTimeout = TimeSpan.FromSeconds(10),

    // Default command timeout
    // Default: 30 seconds
    DefaultCommandTimeoutSeconds = 30,

    // Enable WAL mode on writer connection
    // Default: true
    EnableWriteAheadLogging = true,

    // Enforce foreign keys on all connections
    // Default: true
    EnforceForeignKeys = true
};

var writer = SqliteSingleWriter.GetOrCreate(connectionString, options);

EphemeralLruCacheOptions (from core)

var cache = new EphemeralLruCache<string, User>(new EphemeralLruCacheOptions
{
    // Default TTL for new entries
    // Default: 5 minutes
    DefaultTtl = TimeSpan.FromMinutes(5),

    // Extended TTL for hot keys
    // Default: 30 minutes
    HotKeyExtension = TimeSpan.FromMinutes(30),

    // Access count to be "hot"
    // Default: 5
    HotAccessThreshold = 5,

    // Maximum cache entries
    // Default: 1000
    MaxSize = 1000,

    // Signal sampling rate
    // Default: 1
    SampleRate = 1
});

API Reference

SqliteSingleWriter

// Get or create instance (keyed by connection string)
var writer = SqliteSingleWriter.GetOrCreate(connectionString, options);

// Serialized write
Task<int> WriteAsync(string sql, object? parameters = null, CancellationToken ct = default);

// Batch write (transactional)
Task<int> WriteBatchAsync(IEnumerable<(string Sql, object? Parameters)> commands,
    bool transactional = true, CancellationToken ct = default);

// Transaction with user code
Task ExecuteInTransactionAsync(
    Func<SqliteConnection, SqliteTransaction, CancellationToken, Task> work,
    CancellationToken ct = default);

Task<T> ExecuteInTransactionAsync<T>(
    Func<SqliteConnection, SqliteTransaction, CancellationToken, Task<T>> work,
    CancellationToken ct = default);

// Write and invalidate cache
Task<int> WriteAndInvalidateAsync(string sql, object? parameters = null,
    IEnumerable<string>? cacheKeysToInvalidate = null, CancellationToken ct = default);

// Cached read
Task<T?> ReadAsync<T>(string cacheKey, Func<SqliteConnection, Task<T>> reader,
    TimeSpan? slidingExpiration = null, CancellationToken ct = default);

// Uncached query
Task<T> QueryAsync<T>(Func<SqliteConnection, Task<T>> reader, CancellationToken ct = default);

// Signal observability
IReadOnlyList<SignalEvent> GetSignals();
IReadOnlyList<SignalEvent> GetSignals(string pattern);
IReadOnlyCollection<EphemeralOperationSnapshot> GetWriteSnapshot();

// Cache management
void InvalidateCache(string cacheKey);
Task FlushWritesAsync(CancellationToken ct = default);

// External signal-driven invalidation
void EnableSignalDrivenInvalidation(SignalSink sink,
    IEnumerable<string>? patterns = null, TimeSpan? pollInterval = null);

// Dispose
ValueTask DisposeAsync();

EphemeralLruCache (from core)

// Get or add (sync)
TValue GetOrAdd(TKey key, Func<TKey, TValue> factory);

// Get or add (async)
Task<TValue> GetOrAddAsync(TKey key, Func<TKey, Task<TValue>> factory);

// Invalidate
void Invalidate(TKey key);

// Signals
IReadOnlyList<SignalEvent> GetSignals();

// Stats
CacheStats GetStats(); // (TotalEntries, HotEntries, ExpiredEntries, MaxSize)

// Dispose
ValueTask DisposeAsync();

Signals Emitted

Signal Description
write.enqueue Write queued
write.start Write started
write.done:Nrows:Nms Write completed with row count and duration
write.error:message Write failed
write.batch.enqueue Batch write queued
write.tx.enqueue Transaction queued
write.flush.start Flush started
write.flush.done Flush completed
cache.hit:key Cache hit
cache.miss:key Cache miss
cache.set:key Cache entry set
cache.invalidate:key Cache entry invalidated
cache.hot:key Key became hot
cache.evict:key Key evicted
read.start:key Read started
read.done:key Read completed
query.start Query started
connection.open.write Writer connection opened
connection.open.read Reader connection opened
pragma.busy_timeout Busy timeout pragma set
pragma.foreign_keys.on Foreign keys enabled
pragma.wal.on WAL mode enabled

Patterns Demonstrated

1. Single-Writer Coordination

// MaxConcurrency=1 ensures serialized writes - no locks needed
_writeCoordinator = new EphemeralWorkCoordinator<WriteCommand>(
    async (cmd, ct) => await ExecuteWriteInternalAsync(cmd, ct),
    new EphemeralOptions { MaxConcurrency = 1 });

2. Sampling for Observability

var options = new SqliteSingleWriterOptions { SampleRate = 10 };  // 1 in 10 ops
var writer = SqliteSingleWriter.GetOrCreate(connString, options);

// Observe what's happening
var writeSignals = writer.GetSignals("write.*");
var cacheSignals = writer.GetSignals("cache.*");

3. Self-Focusing LRU Cache

Hot keys automatically get extended TTL:

var cache = new EphemeralLruCache<string, User>(new EphemeralLruCacheOptions
{
    DefaultTtl = TimeSpan.FromMinutes(5),
    HotKeyExtension = TimeSpan.FromMinutes(30),
    HotAccessThreshold = 5  // 5 hits = "hot"
});

// First 5 accesses: 5 min TTL
// After 5 accesses: 30 min TTL (hot)
var user = cache.GetOrAdd("user:123", key => LoadUser(key));

Example: Full Usage

var writer = SqliteSingleWriter.GetOrCreate(
    "Data Source=mydb.sqlite;Mode=ReadWriteCreate;Cache=Shared",
    new SqliteSingleWriterOptions
    {
        SampleRate = 5,
        BusyTimeout = TimeSpan.FromSeconds(10),
        EnableWriteAheadLogging = true
    });

// Serialized writes
await writer.WriteAsync("INSERT INTO Users (Name) VALUES (@Name)", new { Name = "Alice" });

// Transaction
var count = await writer.ExecuteInTransactionAsync(async (conn, tx, ct) =>
{
    await using var cmd = conn.CreateCommand();
    cmd.Transaction = tx;
    cmd.CommandText = "INSERT INTO Users (Name) VALUES ('Bob')";
    await cmd.ExecuteNonQueryAsync(ct);

    await using var countCmd = conn.CreateCommand();
    countCmd.Transaction = tx;
    countCmd.CommandText = "SELECT COUNT(*) FROM Users";
    return (int)(long)await countCmd.ExecuteScalarAsync(ct);
});

// Batch writes (transactional)
await writer.WriteBatchAsync(new[]
{
    ("INSERT INTO Users (Name) VALUES ('Charlie')", (object?)null),
    ("INSERT INTO Users (Name) VALUES ('Dana')", (object?)null)
});

// Cached read
var userCount = await writer.ReadAsync("users:count", async conn =>
{
    await using var cmd = conn.CreateCommand();
    cmd.CommandText = "SELECT COUNT(*) FROM Users";
    return (int)(long)await cmd.ExecuteScalarAsync();
});

// Write and invalidate
await writer.WriteAndInvalidateAsync(
    "INSERT INTO Users (Name) VALUES ('Eve')",
    cacheKeysToInvalidate: new[] { "users:count" });

// Observe signals
var writeSignals = writer.GetSignals("write.*");
var cacheSignals = writer.GetSignals("cache.*");

Example: External Signal-Driven Invalidation

Multiple processes can share cache invalidation via signals:

var sharedSink = new SignalSink();

var writer = SqliteSingleWriter.GetOrCreate(connectionString);
writer.EnableSignalDrivenInvalidation(sharedSink);

// Any process can invalidate cache keys
sharedSink.Raise(new SignalEvent("cache.invalidate:users:count",
    EphemeralIdGenerator.NextId(), null, DateTimeOffset.UtcNow));

// Writer automatically clears its local cache for "users:count"

Cache Strategy Comparison

Pick the cache behavior that fits the scenario:

Cache Expiration Model Specialization Where/Why
EphemeralLruCache (default) Sliding on every hit; hot keys get extended TTL and LRU-style eviction Emits cache.hot/evict signals; best when you want the cache to self-focus on frequently accessed keys.
SlidingCacheAtom Sliding on every hit plus absolute max lifetime Deduplicates concurrent computes; emits rich signals Separate package (atoms.slidingcache) if you need async factories with sliding expiration baked in.

Tip: Default ReadAsync uses EphemeralLruCache so you get hot-key bias automatically; reach for SlidingCacheAtom when you need async factories + dedupe.

Example: Self-optimizing hot-key cache

using Mostlylucid.Ephemeral;

var cache = new EphemeralLruCache<string, User>(new EphemeralLruCacheOptions
{
    DefaultTtl = TimeSpan.FromMinutes(5),
    HotKeyExtension = TimeSpan.FromMinutes(30),
    HotAccessThreshold = 3,
    MaxSize = 5000
});

// Read-through with self-optimizing TTLs
var user = await cache.GetOrAddAsync("user:123", async key =>
{
    var result = await LoadUserAsync(key);
    return result!;
});

// Observe how the cache focuses on hot keys
var stats = cache.GetStats();              // hot/expired counts, size
var signals = cache.GetSignals("cache.*"); // cache.hot/evict, etc.

Package Description
mostlylucid.ephemeral Core library
mostlylucid.ephemeral.complete All in one DLL

License

Unlicense (public domain)

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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 (2)

Showing the top 2 NuGet packages that depend on mostlylucid.ephemeral.sqlite.singlewriter:

Package Downloads
mostlylucid.ephemeral.complete

Meta-package that references all Mostlylucid.Ephemeral packages - bounded async execution with signals, atoms, and patterns. Install this single package to get everything.

Mostlylucid.StyloExtract.Templates

SQLite-backed template index for StyloExtract. Single-writer coordination via mostlylucid.ephemeral; learned-extractor centroids that drift and refit with EWMA accumulation; refit-as-version-event for site-template-version monitoring. JSON export and import for portable template bundles.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.6.4 534 6/21/2026
2.6.3 111 5/22/2026
2.6.2 111 5/22/2026
2.5.1 99 5/22/2026
2.5.0 101 5/3/2026
2.4.0 113 4/17/2026
2.3.2 126 1/9/2026
2.3.1 125 1/9/2026
2.3.1-alpha0 124 1/9/2026
2.3.0 1,234 1/8/2026
2.3.0-alpha1 121 1/8/2026
2.1.0 122 1/8/2026
2.1.0-preview 123 1/8/2026
2.0.1 126 1/8/2026
2.0.0 159 1/8/2026
2.0.0-alpha1 119 1/8/2026
1.7.1 475 12/11/2025
1.6.8 472 12/9/2025
1.6.7 464 12/9/2025
1.6.6 452 12/9/2025
Loading failed

v2.6.4
- Skip indexer properties in AddParameters; fixes TargetParameterCountException when passing objects that expose an indexer (e.g. Dictionary<TKey,TValue>).
- Add AOT-safe IReadOnlyDictionary<string, object?> parameter overloads for WriteAsync and WriteAndInvalidateAsync.

v1.0.0
- Initial release
- SqliteSingleWriter: serialized writes via ephemeral coordinator
- EphemeralLruCache: self-focusing cache with hot key detection
- Signal-based sampling for observability
- Connection-string keyed instances