Indexes
ZoneTree is a natural foundation for indexes because it stores keys in comparer order and exposes efficient seek-based iteration. An index in ZoneTree is usually a key layout plus a value shape.
Primary Index
A primary index stores the entity by its primary id:
user:{userId} -> userThis is the simplest shape when reads usually know the id.
users.Upsert($"user:{user.Id:D20}", user);Secondary Index
A secondary index encodes the indexed field into the key and stores the primary id or a compact pointer as the value.
email:{email} -> userId
status:{status}:created:{createdAt}:{userId} -> userIdThe key carries the sort order. The value can stay small.
emailIndex.Upsert($"email:{email}", userId);
statusIndex.Upsert(
$"status:{status}:created:{createdAt.Ticks:D20}:{userId:D20}",
userId);Composite Keys
Composite key layouts make range scans cheap:
tenant:{tenantId}:status:{status}:created:{createdAt}:id:{entityId}This lets you scan all records for one tenant and status in creation order.
var prefix = $"tenant:{tenantId}:status:active:";
using var iterator = indexTree.CreateIterator();
iterator.Seek(prefix);
while (iterator.Next())
{
if (!iterator.CurrentKey.StartsWith(prefix, StringComparison.Ordinal))
break;
Console.WriteLine(iterator.CurrentValue);
}For structured key types, use a comparer that orders fields in the same order your scans need.
Covering Indexes
An index value can store only the primary id:
email:{email} -> userIdOr it can store enough data to answer the query without loading the primary record:
status:{status}:created:{createdAt}:{userId} -> UserListItemCovering indexes improve read paths but increase write cost and value size. Use them when the query is hot enough to justify duplicating data.
Updating Indexes
If the primary record and secondary index entries must change together, use a transactional tree.
using var zoneTree = new ZoneTreeFactory<string, string>()
.SetDataDirectory("data/users")
.OpenOrCreateTransactional();
var tx = zoneTree.BeginTransaction();
zoneTree.Upsert(tx, "user:42", userJson);
zoneTree.Upsert(tx, "email:alice@example.com", "42");
var result = zoneTree.PrepareAndCommit(tx);If an index is rebuildable from primary data, keep it in a separate ZoneTree and repair it with an iterator scan. Rebuildable indexes can use a different durability policy than primary data when the product can tolerate rebuilding them.
Rebuilds
Iterators make rebuilds straightforward:
using var iterator = primary.CreateIterator(IteratorType.Snapshot);
while (iterator.Next())
{
var user = iterator.CurrentValue;
rebuiltEmailIndex.Upsert($"email:{user.Email}", user.Id);
}Use a snapshot iterator when the rebuild needs a stable view. For online rebuilds, combine a snapshot pass with an application operation stream so writes that happen during the rebuild are applied after the scan.
Partitioned Indexes
Indexes can be partitioned independently from primary data:
tenant:{tenantId}:email:{email}
index:{indexName}:partition:{partitionId}:{field}:{id}Independent index trees give separate maintenance, backup, restore, and placement boundaries. This is useful for large tenants, heavy secondary indexes, or indexes that can be rebuilt separately from the primary store.
WAL Mode
The default async compressed WAL is the right starting point for persistent indexes.
UseNo WALonly when the index is intentionally rebuildable from another source. This is common for derived projections, but it should be a product decision.