Skip to main content

Best practices and pitfalls

This page consolidates the guidance scattered across the developer guide and core concepts into one checklist. Each practice states what to do, why it matters, and links to the article that covers it in depth. Every recommendation reflects how Clustron Zaris actually behaves — there are no aspirational claims here.

At a glance

Use this table as a quick reference, then read the section that matches whatever you are about to build.

DoAvoid
Wrap read-modify-write in a transaction with optimistic concurrencyA blind GetAsync then PutAsync on the same key
Increment with Counters.AddAsyncRead, add in your code, and write back with SetAsync
Blind-retry only idempotent operations (reads, puts, deletes)Blind-retrying a counter increment
Keep AllowReplicaReads off when you need read-your-writesAssuming replica reads are always fresh
Choose Async or Sync replication deliberatelyDefaulting to Async without weighing the durability trade-off
Resolve one client and reuse it for the component's lifetimeResolving a client per operation or per request
Check IsSuccess/Status on every resultReading Value before confirming success
Treat NotFound as an expected outcomeTreating NotFound as an error or retrying it
Design structured, predictable keys and index only queried labelsBare-id keys, or indexing every field
Set a TTL on cache-like dataLetting data grow unbounded and relying on nothing
Size the working set to the per-node memory ceilingAssuming a node grows without limit
Keep values under the 4 MiB item limit; store big blobs as referencesPutting an oversized value and ignoring ItemTooLarge
Read each key back with the exact type you storedReading a value as a mismatched type
Dispose transactions and scan/search readers with await usingLeaking transactions or streamed readers

Avoid lost updates on read-modify-write

When an update depends on the current value, do not read the value, change it in your code, and write it back with a plain PutAsync. Wrap the read and the write in a transaction instead, and branch on the commit result.

A plain PutAsync is a blind write: it overwrites whatever is stored without checking whether anyone changed it since you read it. Two clients that read the same value and write back concurrently produce a lost update — the second write silently replaces the first. A transaction uses optimistic concurrency: the commit verifies that the values you read did not change, and fails with a conflict rather than clobbering newer data. Treat a failed commit as a signal to reread and retry, with a bounded retry count.

See Transactions for the full pattern, and Consistency model for why a plain write cannot protect you here.

tip

A commit conflict is expected, not exceptional. Reread the current data and retry the transaction; cap the retries so a persistently contended key does not loop forever.

Counters are not idempotent — never blind-retry an increment

Use Counters.AddAsync for every increment and decrement, and do not automatically retry an increment on a transient failure the way you would a put or delete.

An AddAsync is atomic on the server, so concurrent increments never lose each other — but the operation is not idempotent. If the first attempt actually succeeded and only its acknowledgement was lost, a blind retry applies the delta twice and double-counts. Reads, puts, and deletes are safe to retry because repeating them leaves the store in the same state; a counter increment is not. For counters that must be exact, guard the update with a transaction and optimistic concurrency, or attach a client-generated operation id you can check, rather than retrying blindly.

Likewise, never emulate an increment by reading with GetAsync, adding in your code, and writing back with SetAsync — that reintroduces the exact lost-update race counters exist to prevent. Reserve SetAsync for seeding or resetting a counter to a known value.

See Counters for the atomic API and Client resilience patterns for which operations are safe to retry.

Replica reads trade freshness for speed

Leave AllowReplicaReads off when your code needs to read its own writes. Turn it on only where a slightly stale read is acceptable in exchange for spreading read load off the primary.

Reads go to the primary by default, and the primary is where writes land — that is what gives you read-after-write consistency: once a write succeeds, a subsequent read reflects it. Under the default Async replication, a read served by a lagging replica can return an older value, so read-after-write is no longer guaranteed once AllowReplicaReads is enabled. Decide per read path whether freshness or load distribution matters more.

See the read-after-write note in Consistency model.

Choose a replication mode deliberately

Pick Async or Sync replication based on how much you can afford to lose on a primary failure, not by default.

Async replication acknowledges a write before it reaches replicas, so it is fast but can lose the most recent writes if the primary is lost before they replicate. Sync replication waits for replicas before acknowledging, which is safer against data loss but slower per write. This is a durability-versus-latency trade-off, and the right answer depends on the workload — a cache tolerates Async cheerfully; a system of record may not.

See Consistency model for how replication mode interacts with reads and durability.

Reuse the client; it is long-lived and thread-safe

Resolve a client once through IZarisClientProvider and store it in a field for the lifetime of the component that uses it. Do not resolve a fresh client per operation, per loop iteration, or per request.

The client is thread-safe and expensive to create relative to a single operation. A single instance can serve your whole application concurrently. Resolving a new client each time churns connections and reduces throughput for no benefit. Register the client once at startup, reuse it across services, and let it be released at shutdown; if you use several stores, resolve one client per store.

The client itself is owned by the provider and you do not dispose it — but short-lived resources are the opposite: dispose every transaction (ideally with await using) and stop every watch subscription with StopAsync, or they leak for the life of the client.

See Client lifecycle.

Handle status; do not assume success

Check IsSuccess or Status on the KvResult from every operation, and read Value only after confirming success.

Zaris returns expected outcomes — a missing key, a concurrency conflict, a locked resource, a full node — as typed statuses rather than exceptions, so you handle them with ordinary control flow. On a failed or NotFound result the value is not populated; using it treats a missing or errored read as if it returned real data. In particular, NotFound is a normal outcome that means the key is absent, not an error and not something to retry — distinguish it from transient statuses like Timeout or Unavailable, which mean the operation did not complete and may be worth retrying. Exceptions are reserved for genuinely unexpected failures such as serialization errors, so keep a try/catch for that path and check the result on every call.

See Error handling and the Status and exception reference.

Design keys and labels intentionally

Encode structure into keys with a consistent type:scope:id separator, order segments general-to-specific, and index a label only for a field you will actually filter, sort, or aggregate on.

The key is the primary access path: point reads need the exact key, and scan and watch both select on a key prefix, so a bare id like 1001 gives you nothing to group or scope on while order:2026:1001 lets you bound a scan to one year. Because prefixes match from the left, general-to-specific ordering is what makes a useful prefix exist. For attribute queries, labels must be attached at write time — a field you did not label is invisible to search and can only be made queryable by rewriting the record. Index deliberately: every indexed label costs work on each write and memory for the index, so indexing a field you never query buys nothing, while under-indexing forces a broad scan you filter in your own code. Do not index high-cardinality fields you only ever look up by exact key — the key is already the fastest path.

Remember that a shared prefix is a logical grouping for selection, not a co-location guarantee: same-prefix keys hash independently and can live on different partitions, so design prefixes for selection and for correctness across partitions rather than assuming locality.

See Data modeling and key design, and Scan and query for the query surface labels enable.

Set TTLs for cache-like data

Give cache-like entries a TTL so they expire on their own instead of accumulating, and understand how a node behaves at its memory ceiling.

Data that never expires grows until it hits the node's memory ceiling. In the current release a node always runs the always-on LRU cache: at the ceiling it drops the least-recently-used data to admit new writes. (A no-eviction mode that rejects writes with CapacityExceeded exists in the engine but is not selectable through configuration today.) A TTL lets data leave naturally on your schedule rather than depending on eviction to reclaim it under pressure. Set a TTL at write time with Put.WithTtl, or adjust an existing key with ExpireAsync.

note

ExpireAsync and PersistAsync route through the partition map and only work on a fully initialized cluster node; in-process they return Unavailable. Running in-process, set the TTL at write time with Put.WithTtl instead.

See TTL and expiration and Memory and eviction.

Size to the memory ceiling

Treat every node as a bounded buffer and plan capacity to the per-node ceiling — the one memory knob you control.

A Zaris node does not grow without limit: it accounts for the data it actively owns and holds that total under a configured ceiling — 1 GiB by default, with no "unbounded" setting. A store's total capacity is roughly the per-node ceiling times the partitions a node owns, adjusted for the replication factor. In the current release a node always runs with eviction on (LRU), so size the ceiling to your working set plus headroom for the eviction band (a run triggers around 80% usage and drains to about 70%); under pressure the node drops the coldest data rather than rejecting writes.

See Memory and eviction.

Keep values under the item-size limit

Keep individual values under the maximum item size — 4 MiB by default — and store large payloads as a reference to external storage rather than inline.

The per-item limit is checked on every write regardless of eviction mode, and no amount of eviction makes room for an oversized value: the write is rejected with ItemTooLarge. Unlike CapacityExceeded (which only arises in the no-eviction path, not exposed through configuration today), ItemTooLarge is a live outcome you can hit under the always-on LRU cache. If a value is genuinely large, split it or store a pointer to a blob store instead of pushing the whole payload into a single entry.

See the per-item size limit in Memory and eviction, and handle the status alongside your other capacity checks per Error handling.

Keep serialization stable across services

Read each key back with the exact type you stored, and keep a model's shape consistent across every service that shares the store.

The client serializes values with MessagePack using a contractless resolver, so plain types round-trip without attributes — but reading a value as a type that does not match what you stored is a deserialization error, not a silent conversion. A breaking change to a model's shape can stop existing stored values from deserializing, so version models carefully. When you already hold encoded bytes, store them as byte[] so Zaris treats the value as opaque, and read them back as byte[]. Note that a value's content type is descriptive metadata only — it does not select a serializer.

See Serialization.

Dispose streamed readers and transactions

Bind every transaction and every scan or search reader with await using, and stop every watch subscription when you no longer need it.

Transactions, search readers, and watch subscriptions hold server-side resources. A search reader is IAsyncDisposable; if you stop reading early or an exception is thrown and you did not bind it with await using, the reader and its server-side resources leak for the life of the client. The same applies to a transaction you forget to dispose and a watch subscription you never stop with StopAsync. For large result sets, also set PageSize on SearchOptions so results stream instead of materializing all at once.

See Scan and query for reader disposal and paging, and Client lifecycle for the disposal rules per object.

Next steps