Data modeling and key design
In Clustron Zaris the key is the primary access path to your data. Every read, scan, and watch starts from a key or a key prefix, so the way you name keys determines how efficiently you can retrieve, iterate over, and react to your data later. A little discipline in key design pays off across the whole API: point reads stay simple, scans stay bounded, watches stay scoped, and queries have the labels they need. This article shows you how to name keys, how to use prefixes, when to add entities and labels, how to pick the right primitive, and what partitioning means for your layout.
Keys are the primary access path
A key uniquely identifies an entry, and a get requires the exact key.
var result = await client.GetAsync<Order>("order:2026:1001");
That exactness is a strength when you can construct the key from a stable identifier, and a limitation when you cannot. Everything else you do — scanning a range, watching a group, querying by attribute — is built on how you shaped the key and what metadata you attached to it. Design the key first, because it is the one thing you cannot change without rewriting the record.
Name keys with structure
Encode structure directly in the key string, using a consistent separator to group related entries. A structured key is both readable and mechanically useful: the leading segments become the prefix that scan and watch select on.
Use meaningful, delimited segments that move from the general to the specific.
order:2026:1001— entity type, then year, then order id.session:{userId}— a session keyed by the user it belongs to.user:1001— a user keyed by a stable identifier.
The general-to-specific ordering matters because prefixes match from the left. Putting the entity type first lets you select all orders; putting the year next lets you select all orders in a year; and so on. If you invert that order, no useful prefix exists.
The following table contrasts key designs that scan and watch well against ones that do not.
| Poor key | Better key | Why |
|---|---|---|
1001 | order:2026:1001 | A bare id has no prefix to group or scope on. |
order-1001-2026 | order:2026:1001 | Year before id lets you bound a scan to one year. |
SessionForUser42 | session:42 | A consistent scheme lets any service construct the key. |
data:{guid} | user:1001 | Derive keys from stable identifiers, not random values. |
Keep keys predictable: derive them from stable identifiers with a consistent pattern so any part of your system can construct a key without looking it up. See Keys and values for the underlying model.
Pick your separator once and use it everywhere. A single convention such as type:scope:id means every service reads and writes the same keys, and every prefix you form is meaningful.
Design prefixes for scan and watch
A prefix is how both scan and watch select a set of keys, so a well-chosen prefix is what makes a range operation bounded and a subscription scoped.
Scan iterates over keys under a prefix. You pass a KeyRange with its Prefix set, and scan streams only the keys that start with it.
await foreach (var item in client.Scan.ScanAsync(new KeyRange { Prefix = "order:2026:" }))
{
Console.WriteLine(item.Value);
}
Because the prefix bounds the scan, order:2026: walks one year of orders instead of the entire store. The narrower and more meaningful your prefix, the less data a scan has to read. See Scan and query for the full scan and search surface.
Watch scopes a subscription the same way. WatchPrefixAsync delivers a change event for every key that starts with the prefix and for no others.
var subscription = await client.Watch.WatchPrefixAsync(
"order:2026:",
new WatchOptions { IncludeInitialSnapshot = false },
ev => Console.WriteLine($"{ev.Key} changed"));
The same key structure that bounds a scan scopes a watch. This is why the prefix segments deserve thought up front: they are the selection mechanism for two different features at once. See Watch for the event model and subscription lifecycle.
Use entities and labels to query by attribute
Prefixes let you select by key. When you need to find records by an attribute that is not part of the key — all customers in London, or every order over a threshold — you attach an entity and labels at write time so search can filter against an index. A get and a prefix scan need no labels; attribute queries do.
An entity names a logical type of data, such as customer or order. A label is an indexed field attached to the key, such as city or age. You supply both through Put options when you write, and only labeled fields become searchable.
var putOpts = Put.WithEntity("customer")
.WithLabel("city", "London")
.WithLabel("age", "30");
await client.PutAsync("cust:1", customer, putOpts);
WithLabel indexes the label by default. You can attach a label without indexing it by passing indexed: false — the value travels with the record's metadata but does not enter the search index, so it cannot be filtered or sorted on.
var putOpts = Put.WithEntity("customer")
.WithLabel("city", "London") // indexed, queryable
.WithLabel("email", "u1@example.com", indexed: false); // stored, not indexed
You can also build the label set explicitly with LabelValue, which takes the value and an indexed flag, and pass it through WithLabels.
var labels = new Dictionary<string, LabelValue>
{
["city"] = new LabelValue("London"), // indexed by default
["email"] = new LabelValue("u1@example.com", indexed: false)
};
var putOpts = Put.WithEntity("customer").WithLabels(labels);
await client.PutAsync("cust:1", customer, putOpts);
Once records carry indexed labels, you query them with SearchQuery through client.Scan.SearchAsync — filtering with Eq, Range, and LikePrefix, and shaping results with OrderBy and Limit. Those clause builders are covered in Scan and query.
Index deliberately, not exhaustively
Every indexed label is extra work: it has to be maintained on each write and it consumes memory for the index. Indexing a field you never filter or sort on costs you on every put and buys you nothing. Under-indexing, on the other hand, forces you to fall back to a broad scan and filter in your own code, which is slower and reads more data.
Aim for the balance in the following guidelines.
- Index a field when you will filter, sort, or aggregate on it — and add the label at write time, because labels are captured on write and cannot be applied retroactively.
- Leave a field unindexed (
indexed: false) when you want it stored with the record for reading back but never query on it. - Do not index high-cardinality fields you only ever look up by exact key; the key itself is already the fastest path.
- Revisit your labels as query patterns change; a breaking change to what you query means rewriting records with new labels.
Decide your searchable fields before you write. A field you did not label when you stored the record is invisible to search, and the only way to make it queryable is to rewrite the record with the label attached.
Choose the right primitive
The key names the data; the primitive decides how the data behaves. Store a plain value for ordinary state, use a counter when many clients update the same number, and use a lease when ownership must be time-bound. The following table summarizes the choice.
| You need | Use | Why |
|---|---|---|
| To store and read back a value or object | A value (PutAsync / GetAsync) | The default path for any serializable state. |
| Atomic numeric state under concurrent updates | A counter | The store applies each change atomically, so no increment is lost. See Counters. |
| Time-bound ownership of a resource | A lease | Ownership carries an expiry and releases automatically if the owner fails. See Leases. |
Reach for a counter instead of read-modify-writing a value when more than one client updates the same number — a plain read-add-write loses concurrent increments, while a counter's AddAsync is atomic. Reach for a lease instead of a value with a TTL when the point is ownership rather than just expiry: a lease expires on its own if the owner stops or crashes, which is what makes it the basis for locks and leader election.
Understand the partitioning implications
Keys distribute across partitions. Each key is hashed and routed to exactly one partition, and routing is deterministic, so every client computes the same route without coordinating. What matters for your key design is that keys spread across the keyspace by their hash.
A shared prefix is a logical grouping for scan and watch selection. It is not a co-location guarantee. Keys such as order:2026:1001 and order:2026:1002 share a prefix and both match a order:2026: scan, yet they hash independently and can land in different partitions on different nodes. Prefix selection is what lets scan and watch find the related keys wherever they live; the store gathers the matching keys across partitions for you.
Keep two consequences in mind.
- A prefix scan may touch several partitions, because the matching keys are distributed. This is normal and handled for you, but a broader prefix means more partitions involved and more data read — another reason to make prefixes as narrow as the query allows.
- Do not design keys expecting same-prefix records to sit together on one node for locality; they do not. Design prefixes for selection, and design for correctness across partitions rather than assuming co-location.
For how keys route through segments to partitions, and how partition count tracks the cluster, see Partition.
Next steps
- Scan and query to iterate by prefix and query by label.
- Watch to react to changes on a key or prefix.
- Counters for atomic numeric state.
- Leases for time-bound ownership.
- Partition for how keys distribute across the cluster.