LottaDB is a .NET library that makes it easy to store any POCO in Azure Table Storage with full Lucene search, all with the goodness of LINQ. No schema required -- just define a class and go.
- A lotta power, a little work. Store any C# class with zero attributes. All properties are automatically queryable and searchable.
- A lotta bang for a little buck. Table Storage is the cheapest durable storage in Azure. LottaDB adds Lucene so you get rich queries without the rich pricing.
- A lotta LINQ.
GetManyAsyncand() Search, .Where(), .OrderBy() etc.() - A lotta fidelity. Full JSON roundtrip. Lists, dictionaries, nested objects -- everything survives.
- A lotta views.
Ontriggers build materialized views with plain C#. - A lotta tenants. One catalog per tenant with multiple databases. Natural isolation, simple cleanup.
- A lotta nothing to operate. Table Storage is serverless. Lucene runs in-process.
- A lotta schema safety. Schema changes are detected automatically -- Lucene index is rebuilt on startup.
- A lotta concurrency.
ChangeAsyncprovides atomic read-modify-write with optimistic concurrency and automatic retry.()
LottaDB is ideal for per-user or per-tenant workloads -- think user profiles, settings, activity feeds, personal knowledge bases, mailboxes, or per-project data. Thousands of objects per tenant, thousands of tenants per deployment.
dotnet add package LottaDB
Just define a class. No attributes, no base classes, no interfaces:
public class Actor
{
public string Username { get; set; } = "";
public string DisplayName { get; set; } = "";
public string AvatarUrl { get; set; } = "";
}Create a catalog, register the type, and start storing:
var catalog = new LottaCatalog("myapp", "" );
var db = await catalog.GetDatabaseAsync("default");
// Save -- a unique key (ULID) is auto-generated
var actor = new Actor { Username = "alice", DisplayName = "Alice" };
await db.SaveAsync(actor);
var key = actor.GetKey(); // e.g. "01JKX3Q7..."
// Point read by key
var loaded = await db.GetAsync<Actor>(key);
// Search -- all properties are automatically queryable
var found = db.Search<Actor>()
.Where(a => a.DisplayName == "Alice")
.ToList();That's it. No key attribute needed -- a ULID is assigned automatically. Every property on your class is automatically:
- Stored as full-fidelity JSON in table storage
- Indexed in Lucene for fast search
- Queryable via LINQ expressions and full-text search
When you need more control, attributes let you specify keys, indexing behavior, and exclusions:
public class Note
{
[Key] // explicit key property
public string NoteId { get; set; } = "";
[Queryable(QueryableMode.NotAnalyzed)] // exact match only
public string AuthorId { get; set; } = "";
[Queryable] // full-text search
public string Content { get; set; } = "";
[NotQueryable] // exclude from indexing (e.g., large payloads)
public string RawHtml { get; set; } = "";
public DateTimeOffset Published { get; set; }
public List<string> Tags { get; set; } = new();
}| Attribute | Effect |
|---|---|
[Key] |
Designates the unique key property. Without it, a ULID is auto-generated. |
[Queryable] |
Controls how a property is indexed. Strings get full-text search by default. |
[NotQueryable] |
Excludes a property from automatic indexing (useful for large strings). |
[DefaultSearch] |
(class-level) Sets the default property for free-text queries. |
ChangeAsync provides safe read-modify-write with automatic retry on conflict:
await db.ChangeAsync<Actor>("alice", actor =>
{
actor.DisplayName = "Alice Updated";
});Multiple concurrent writers on the same key are handled correctly -- ETag-based optimistic concurrency ensures no updates are lost.
Several server processes can open the same database at once. Reads never take a lock, so any number of servers can search and query concurrently.
Writing is different: Lucene allows only one writer per index, enforced by a cross-process lock
(a write.lock file on disk, a blob lease on Azure). LottaDB takes that lock on the first
write rather than at open, and releases it after WriteLockReleaseDelay of write inactivity:
var db = await catalog.GetDatabaseAsync("mydb", config =>
{
config.Store<Note>();
config.WriteLockReleaseDelay = 30000; // release the writer after 30s idle (default)
config.WriteLockTimeout = 10000; // wait this long for another server to let go
config.MaxSearchStaleness = 1000; // how stale a search may be, in ms
});
await db.SaveAsync(note); // acquires the write lock here
Console.WriteLine(db.HoldsWriteLock); // true
await db.ReleaseWriteLockAsync(); // or hand it off explicitly, e.g. on shutdownIf another server holds the writer, a write waits up to WriteLockTimeout and then throws
WriteLockUnavailableException. The failure happens before anything is written, so no
partial state is left behind.
This is a trade between write latency and handover latency, and it is worth setting
deliberately. Writes spaced closer together than the delay cost nothing extra — the writer
is simply kept. Writes spaced further apart rebuild the IndexWriter every single time:
| Provider | Writer already held | Writer re-acquired | Extra per write |
|---|---|---|---|
SQLite (local FSDirectory) |
~0.4 ms / 54 KB | ~9.4 ms / 484 KB | ~9 ms |
Azure (AzureDirectory, Azurite) |
~2.2 ms / 185 KB | ~32 ms / 1250 KB | ~30 ms |
Measured with WriteLockChurnBenchmarks. Two things to read out of it:
- Azure pays about 3x more in absolute terms (~30 ms vs ~9 ms of added latency), because the lock is a blob lease and re-acquiring also re-syncs segment files through the local cache.
- The multiplier is actually smaller on Azure (~15x vs ~22x) only because an ordinary Azure write is already slower. Absolute added latency is the number that matters.
Those Azure figures come from Azurite running locally, so they understate real Azure: a blob lease acquire and release are sub-millisecond against the emulator and tens of milliseconds against the real service. Budget accordingly.
So set it comfortably above your normal gap between writes. Lower it only when you need another server to be able to take over sooner — the default of 30s means a failover waits up to 30s.
What this is and isn't. This gives you lock-free readers and a writer role that migrates between servers -- good for failover, for bursty writes, and for topologies where one server writes at a time. It does not let two servers write continuously at once: a server under sustained write load never goes idle, so it keeps the lock and the others time out. If every server must write continuously, use a single designated writer with a queue instead.
For that sustained-contention case there is WriteLockMaxHoldTime, which forces a writer to
yield after holding the lock for a given time:
config.WriteLockMaxHoldTime = 30000; // yield after 30s of continuously holding the writerNote that yielding costs a pause: after giving up the lock, the instance will not re-acquire it for about 1.5 seconds. Lucene's lock acquisition polls once per second, so without that back-off this process would simply re-take the lock on its next write and no other server would ever observe it free. Treat this as a fairness valve, not a throughput feature.
Set ReadOnly to make the intent explicit. Writes then throw immediately instead of competing
for the lock, and the instance creates nothing at open -- no schema manifest, no index rebuild:
var replica = await catalog.GetDatabaseAsync("mydb", config =>
{
config.Store<Note>();
config.ReadOnly = true;
});
replica.Search<Note>("lucene"); // fine
await replica.SaveAsync(note); // throws InvalidOperationExceptionA read-only replica may safely start before the writer has ever created the index; it serves empty results and picks the index up as soon as it appears.
GetAsyncandGetManyAsyncare always fresh -- they read Table Storage directly. OnlySearchis eventually consistent, bounded byMaxSearchStaleness. Your own writes are always visible to your own searches immediately.Onhandlers run only on the server that performed the write. Views written throughdb.SaveAsyncland in shared storage, so derived data stays correct -- but handlers with side effects outside the database (emails, cache invalidation, broadcasts) fire on the writing server only. Use a message bus for those.- Azure multi-writer is best-effort.
AzureDirectoryguards the index with a 60-second blob lease renewed every 30 seconds. If renewals fail for long enough (a network blip, a long GC pause, a suspended VM) the lease can expire while this process still believes it holds the writer. KeepingWriteLockReleaseDelayshort limits the exposure. The same classic caveat applies toFSDirectoryover NFS/SMB, where file locking is unreliable.
LottaDB works with Azure Table Storage out of the box. For local development and testing, install a provider package:
| Package | Install | Usage | Description |
|---|---|---|---|
| LottaDB | dotnet add package LottaDB |
catalog.UseAzure(connectionString) |
The default provider. Uses Azure Table Storage API for durability. |
| LottaDB.Memory | dotnet add package LottaDB.Memory |
catalog.UseMemory() |
In-memory provider for unit testing. No durability, but lightning fast and supports all features. |
| LottaDB.SQLite | dotnet add package LottaDB.SQLite |
catalog.UseSQLite(path) |
Local storage with SQLite. Durable and supports all features, but no serverless scaling. |
// Local development with SQLite
var catalog = new LottaCatalog("myapp", catalog => catalog.UseSQLite(@"C:\data"));
// Unit tests with in-memory storage
var catalog = new LottaCatalog("myapp", catalog => catalog.UseMemory());Full documentation is available in the wiki: