Data structures
Zaris 2.0.0 adds native, server-side collection types — the analogues of Redis hashes, lists, sets, and sorted sets — alongside plain key/value storage. Each collection is a first-class client API (client.Hashes, client.Lists, client.Sets, client.SortedSets), obtained the same way as counters or locks, and every method returns the usual KvResult/KvResult<T> you check before reading Value.
The same collections back the Redis-protocol front-end: HSET, LPUSH, SADD, ZADD, and their siblings map straight onto these APIs, so a native .NET service and a Redis client in another language can share the same hash or sorted set.
The four families
| Family | Client | What it stores | Native ops (representative) |
|---|---|---|---|
| Hash | client.Hashes | Field → value map under one key | SetAsync, SetIfMissingAsync, GetAsync, GetManyAsync, GetAllAsync, DeleteAsync, ExistsAsync, LengthAsync, KeysAsync, ValuesAsync, IncrementByAsync |
| List | client.Lists | Ordered sequence (head = index 0) | PushLeftAsync, PushRightAsync (with an only-if-exists variant), PopLeftAsync, PopRightAsync (with a count), RangeAsync, LengthAsync, IndexAsync, SetAsync, RemoveAsync |
| Set | client.Sets | Unique members | AddAsync, RemoveAsync, MembersAsync, ContainsAsync, CountAsync, PopAsync, RandomMembersAsync |
| Sorted set | client.SortedSets | Members ordered by a numeric score | AddAsync, RemoveAsync, ScoreAsync, CountAsync, IncrementAsync, RangeByRankAsync (forward or reverse), RangeByScoreAsync, RankAsync, CountByScoreAsync |
Hash example
A hash is a good fit for a small record you update field by field — a user profile, a session, a set of feature flags.
// Set fields
await client.Hashes.SetAsync("user:42", "name", "Ada");
await client.Hashes.SetAsync("user:42", "email", "ada@example.com");
// Read one field, or the whole hash
var name = await client.Hashes.GetAsync("user:42", "name");
var all = await client.Hashes.GetAllAsync("user:42");
// Atomic numeric field
await client.Hashes.IncrementByAsync("user:42", "logins", 1);
List, set, and sorted-set examples
// List — a simple queue or recent-items buffer
await client.Lists.PushRightAsync("queue:jobs", jobBytes);
var next = await client.Lists.PopLeftAsync("queue:jobs");
var page = await client.Lists.RangeAsync("queue:jobs", 0, 9); // first 10
// Set — membership and de-duplication
await client.Sets.AddAsync("tags:post:7", "dotnet");
bool tagged = (await client.Sets.ContainsAsync("tags:post:7", "dotnet")).Value;
// Sorted set — a leaderboard
await client.SortedSets.AddAsync("leaderboard", "player:1", 1500);
await client.SortedSets.IncrementAsync("leaderboard", "player:1", 25);
var top10 = await client.SortedSets.RangeByRankAsync("leaderboard", 0, 9, reverse: true);
How it works: one key, whole-value mutation
Every collection lives under a single key on the key's owning node (its replicas hold full copies), exactly like a key in Redis Cluster — a collection is not internally sharded across partitions. On the owner the collection is a live typed structure in memory; its stored and replicated form is one self-describing byte[] value. Because the value stays opaque bytes, collections inherit key routing, replication factor, primary failover, migration, memory accounting, and eviction from the ordinary key/value path with no special handling.
Two consequences follow, and they shape how you should use these types:
- Mutations are atomic per key. A write materializes the collection, mutates it under a per-key lock, and writes the whole value back — so concurrent writers to the same collection serialize correctly and never lose an update. This matches Redis's single-writer-per-key semantics.
- An operation is O(collection size). Because the whole collection is re-serialized on every mutation, cost scales with how large the collection is, not just the element you touched. This is ideal for typical sizes (tens to low thousands of small elements) and fine for reads (which return only the projection you asked for). It is not ideal for very large collections or extreme single-key write rates — spread hot data across more keys, or keep large collections bounded.
Once a collection's serialized size crosses ~85 KB it becomes a Large Object Heap allocation on each mutation. Zaris minimizes that cost (pooled single-pass serialization), but the guidance stands: keep individual collections modest, and shard very large working sets across multiple keys. See Memory and eviction.
Type safety
A collection key carries its kind in a small header, so a data-structure operation against a plain string value (or the wrong collection family) fails cleanly with a wrong-type error rather than corrupting data — the same WRONGTYPE behaviour Redis clients expect over the RESP front-end. When a mutation removes the last element, the key is deleted (Redis semantics); Streams are the exception (see Streams).
What is not covered
The single-key engine deliberately does not include cross-key operations that span owners: set algebra across keys (SINTERSTORE, SUNION/SDIFF across keys), SMOVE, ZUNIONSTORE, cross-key RPOPLPUSH, and list blocking pops (BLPOP). Per-element TTL is not supported — expiry remains whole-key. For those patterns, model with separate keys or use Scan and query and counters.
Validation
The native engine and its RESP mapping are validated end to end against StackExchange.Redis (38/38), Jedis (51/51), and redis-rb (40/40), plus replica-convergence under a primary kill (all keys survive on the promoted replica).
See also
- Redis protocol (RESP) — using these types from any language with an ordinary Redis client.
- Streams — the append-only log type and consumer groups.
- Pub/sub — native publish/subscribe.
- Coming from Redis — the full command-to-API mapping.