Put the records you read together
A comparer defines both key equality and the order of the tree. Choose that order around the ranges your application needs to seek and scan.
Design keys around range queries
Suppose an application reads events from one stream for one tenant. An order of TenantId → StreamId → Sequence keeps that stream's records together:
(7, 41, 1) first event
(7, 41, 2) second event
(7, 41, 3) third event
(7, 42, 1) another stream The scan can seek to (7, 41, fromSequence) and stop when tenant or stream changes. Ordering the same fields as Sequence → TenantId → StreamId serves a different query: it groups sequence positions and scatters one stream among other streams.
| Access pattern | Useful leading order |
|---|---|
| One tenant's stream, in sequence | Tenant, stream, sequence. |
| One entity's time range | Entity, timestamp, a tie-breaker if needed. |
| Highest scores first within a group | Group, score in the required direction, unique tie-breaker. |
| Multiple unrelated query orders | Separate secondary indexes with their own key layouts. |
Field names do not establish any of these orders. The comparer implementation does.
Implement a composite key completely
This console example supplies all three custom-key components: comparer, equality-compatible hasher, and serializer. It prints the first stream's events in sequence order:
using ZoneTree;
using ZoneTree.Comparers;
using ZoneTree.Hashers;
using ZoneTree.Serializers;
using var tree = new ZoneTreeFactory<EventKey, string>()
.SetDataDirectory("data/ordered-events")
.SetComparer(new EventKeyComparer())
.SetKeyHasher(new EventKeyHasher())
.SetKeySerializer(new StructSerializer<EventKey>())
.OpenOrCreate();
using var maintainer = tree.CreateMaintainer();
tree.Upsert(new EventKey(7, 41, 2), "Second");
tree.Upsert(new EventKey(7, 42, 1), "Another stream");
tree.Upsert(new EventKey(7, 41, 1), "First");
using var iterator = tree.CreateIterator();
iterator.Seek(new EventKey(7, 41, 1));
while (iterator.Next())
{
var key = iterator.CurrentKey;
if (key.TenantId != 7 || key.StreamId != 41)
break;
Console.WriteLine($"{key.Sequence}: {iterator.CurrentValue}");
}
readonly record struct EventKey(int TenantId, long StreamId, long Sequence);
sealed class EventKeyComparer : IRefComparer<EventKey>
{
public int Compare(in EventKey x, in EventKey y)
{
var tenant = x.TenantId.CompareTo(y.TenantId);
if (tenant != 0) return tenant;
var stream = x.StreamId.CompareTo(y.StreamId);
return stream != 0 ? stream : x.Sequence.CompareTo(y.Sequence);
}
}
sealed class EventKeyHasher : IKeyHasher<EventKey>
{
public int GetHashCode(in EventKey key) =>
HashCode.Combine(key.TenantId, key.StreamId, key.Sequence);
} CompareTo avoids the overflow risk of subtracting numeric fields to produce a comparison result. The hasher includes the same equality-defining fields. StructSerializer<EventKey> works because every field is unmanaged; its raw struct layout is part of this example's storage contract.
For a long-lived or cross-platform format, choose and version an explicit key encoding rather than assuming a raw struct layout is a portable wire format. See serializers and comparers.
Keep equality, ordering, and hashing consistent
The comparer must establish a consistent order. A result of 0 means the keys address the same logical record, even if their fields or serialized bytes differ.
For example, a comparer that ignores case makes "User:42" and "USER:42" the same key. A later write can replace the earlier value. The Bloom-filter hasher must also treat those keys as equal.
Do not let a key's order depend on mutable fields, the current time, or configuration that can change while the tree is open. Avoid mutating key buffers after insertion. A key that changes position without an engine write breaks the assumptions used by lookup, iteration, sparse indexes, and merging.
Default ordering for common key types
| Representation | Default order | Consequence |
|---|---|---|
int, long, and other supported numeric keys | Built-in ascending comparer. | Numeric ranges can seek directly to numeric bounds. |
string | Ordinal ascending. | "item:10" sorts before "item:2"; text order is not numeric order. |
Memory<byte> | Lexicographic byte order. | A byte prefix sorts before a longer key beginning with that prefix. |
| Custom key type | You supply the comparer. | Implement every field and direction needed by the query. |
For text keys with embedded numbers, use an appropriate encoding or comparer; a delimiter alone does not create numeric sorting. For byte keys, make sure the byte encoding and the comparer jointly produce the intended order. Use Memory<byte> rather than unsupported byte[] keys, and retain ownership of the bytes.
Forward and reverse traversal
A forward iterator follows the configured comparer. A reverse iterator traverses that same order backward. Reverse traversal does not change equality or re-encode the stored data.
Use it for latest-first access when the chosen key layout puts a tenant or entity's newest record at the high end of its range. See iteration and range scans for inclusive seek behavior, stopping bounds, and snapshot visibility.
Treat ordering changes as migrations
The loader checks the comparer type recorded in metadata when opening a tree. A different comparer class can trigger TreeComparerMismatchException before segment loading.
When equality or ordering changes, create a new tree with the new components and rebuild the records into it. Resolve any keys that become equal under the new comparer. Do not bypass a metadata mismatch to reinterpret old segments under a different order.