Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<CodeAnalysisRuleset>$(MSBuildThisFileDirectory)Shared.ruleset</CodeAnalysisRuleset>
<MSBuildWarningsAsMessages>NETSDK1069</MSBuildWarningsAsMessages>
<NoWarn>$(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005;SER006</NoWarn>
<NoWarn>$(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005;SER006;SER008</NoWarn>
<PackageReleaseNotes>https://stackexchange.github.io/StackExchange.Redis/ReleaseNotes</PackageReleaseNotes>
<PackageProjectUrl>https://stackexchange.github.io/StackExchange.Redis/</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
97 changes: 97 additions & 0 deletions docs/HImport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
Hash Import
===

The `HIMPORT` command (Redis 8.10 and later) is a fast, session-based way to create many hashes that share a common
set of field names — for example, importing a batch of records where every record has the same columns. Rather than
sending the field names again for every hash (as a series of `HSET` calls would), the field names are declared once per
connection and each hash then supplies only its values, positionally matched to those fields.

On the wire this is a *connection-local* container command: `HIMPORT PREPARE` registers a named *field-set* (the
ordered field names) on the current connection, `HIMPORT SET` creates one hash from a row of values against that
field-set, and `HIMPORT DISCARD` releases it. A field-set lives only on the connection that prepared it and disappears
when that connection is reset or closed.

StackExchange.Redis is a multiplexer: it owns the connection lifetime on your behalf, and you do not control *which*
physical connection a given command travels on<sup>&dagger;</sup>. Rather than hide this behind an all-in-one bulk call, the library
exposes something close to the raw shape — a reusable [`HashImport`](xref:StackExchange.Redis.HashImport) field-set plus
a per-row `IDatabase.HashImport` — and takes care of the one hard part for you: the `HIMPORT PREPARE` is **injected
automatically** on whichever connection a given import actually writes to (exactly the way a `SELECT` is injected for
database selection). A transparent reconnect, or a fan-out to another cluster node, simply re-prepares on demand. You
never manage `PREPARE`/`SET`/`DISCARD` ordering or connection pinning yourself.

<sup>&dagger;</sup>: *in theory* multiplexing means a single connection, but with automatic reconnects, cluster slot
migrations, active-active / retries, etc: *in reality* it is more complicated than that — which is exactly why the raw
connection-local commands are not safe to drive directly through a multiplexer.

Usage
---

Create a field-set once (declaring the shared field names, in order), then import each hash by supplying its key and
its values positionally against those fields:

``` c#
IDatabase db = muxer.GetDatabase();

// declare the field names shared by every hash we are importing; reusable and safe to share/keep
await using var fields = HashImport.Create("name", "email", "age");

// import as many hashes as you like - streamed, not materialized up front, so the total is unbounded
await db.HashImportAsync("user:1", fields, new RedisValue[] { "alice", "a@example.com", 30 });
await db.HashImportAsync("user:2", fields, new RedisValue[] { "bob", "b@example.com", 25 });
await db.HashImportAsync("user:3", fields, new RedisValue[] { "carol", "c@example.com", 42 });
```

After these complete, `user:1`, `user:2` and `user:3` each exist as a hash with the `name`, `email` and `age` fields set
to their respective values. Disposing the field-set (`await using` / `Dispose`) sends a best-effort `HIMPORT DISCARD` to
release the server-side state; it is optional (the state also dies with the connection) but good hygiene for long-lived
connections.

### Reusing a values buffer

To avoid allocating a fresh array per row, you may reuse a single `RedisValue[]` (or a slice of a larger pooled buffer),
refilling it for each hash. If you do, you **must await each import before refilling the buffer for the next
row**<sup>&Dagger;</sup> — the library reads the values when the command is actually written to the socket, which (on the
async path) can be *after* `HashImportAsync` returns, so awaiting the returned task is what guarantees the library has
finished with your data and the buffer is safe to overwrite:

``` c#
await using var fields = HashImport.Create("name", "email", "age");
var row = new RedisValue[3]; // reused for every hash

foreach (var record in records)
{
row[0] = record.Name;
row[1] = record.Email;
row[2] = record.Age;
await db.HashImportAsync(record.Key, fields, row); // MUST await before the next refill
}
```

Do **not** fire off many `HashImportAsync` calls sharing one buffer without awaiting between them (nor pass the buffer
fire-and-forget) — overwriting it while a prior write is still pending corrupts the data on the wire.

<sup>&Dagger;</sup>: we hope to lift this restriction in a future release, so that a shared buffer can be refilled without
waiting for each round-trip.

Notes
---

- The field-set is created without touching the server; the `HIMPORT PREPARE` is injected lazily on first use per
connection. A `HashImport` is immutable and may be used concurrently and against multiple databases/multiplexers
(useful for active-active). The field-set name on the wire is an opaque, process-unique id, so it never collides with
another field-set even on a shared connection.
- Field names are validated at `Create`: **duplicate** names are rejected (the server rejects them too, but only via the
injected — fire-and-forget — `PREPARE`, so we fail fast instead), and **null** names are rejected. An **empty** field
name is allowed (a hash may legitimately have an empty-string field).
- `ReadOnlyMemory<RedisValue>` is used for the values (rather than an array) so you can pass slices of a larger, pooled
or reused buffer without copying — useful when importing in chunks.
- Each call must supply exactly as many values as the field-set has fields; a mismatch throws (`ArgumentException`)
before anything is sent.
- Each import **replaces** any existing hash at its key (it does not merge into it) — this is import, not `HSET`.
- Each import is applied **on its own** and may be pipelined freely with unrelated work. A server error (for example a
key already holding a non-hash value, giving `WRONGTYPE`) is thrown for that call as usual, and does not affect other
imports; unless the call is fire-and-forget, in which case you have opted out of seeing the result.
- **Cluster-aware**: each key routes to its slot as normal, re-preparing the field-set per node as needed — you do *not*
need hash tags to keep keys together.
- Supported inside a **batch** (an ordered pipeline). *Not* supported inside a `MULTI`/`EXEC` **transaction**: the
connection-local `PREPARE` cannot be staged inside the transaction, so it throws `NotSupportedException`.
57 changes: 57 additions & 0 deletions docs/HashTags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
Hash Tags and Slots
===

Outside of Redis Cluster (a single server, or a standalone primary/replica) there are no "slots", so none of this applies -
this topic is specific to Redis Cluster environments.

In a Redis Cluster the keyspace is divided into **16384 hash slots**; every key belongs to exactly one slot, and every
slot is owned by exactly one node at any moment. The slot for a key is based on the CRC hash of the key. Clients (including
StackExchange.Redis) use this to route each command to the node that owns the key's slot; if a key has moved, the server
replies `MOVED`/`ASK` and the client re-routes.

Because a slot lives wholly on one node, **co-locating keys in the same slot guarantees they are on the same node** - which
is what multi-key operations need.

Multi-key operations
---

Operations that involve multiple keys (`MSET`, `SUNIONSTORE`, etc - or `MULTI`/`EXEC` batches) typically require *all* the keys
they touch to be in the same slot, because they cannot span nodes. An attempted multi-slot operation is rejected with a `CROSSSLOT` error.

Hash tags
---

A **hash tag** lets you force otherwise-different keys into the *same* slot. If a key contains a (non-empty) `{...}` section, only the
bytes **between the first `{` and the next `}`** are hashed, instead of the whole key:

| Key | Bytes hashed |
| --- | --- |
| `user:1:profile` | `user:1:profile` (the whole key) |
| `{user:1}:profile` | `user:1` |
| `{user:1}:settings` | `user:1` |
| `{user:1}:orders` | `user:1` |

So `{user:1}:profile`, `{user:1}:settings` and `{user:1}:orders` all hash to the same slot and can participate in one
transaction, script, or bulk import. Note that as a consequence: *all the data with the same hash tag must now fit
on that one shard*; you can't "cheat" the slot rules by using `{dummy}real_key_here`, because that simply pushes your
entire database onto a single node, defeating the entire point of Redis Cluster. Hash-tags are best used to co-locate
genuinely related data - for example splitting things by customer, tenant, etc.

Inspecting the slot
---

`IConnectionMultiplexer.HashSlot(RedisKey)` returns the slot a key resolves to, which is handy for verifying that keys
you intend to group really do share a slot:

``` c#
var slot1 = muxer.HashSlot("{user:1}:profile");
var slot2 = muxer.HashSlot("{user:1}:orders");
// slot1 == slot2
```

Interaction with key prefixes
---

A [key prefix](KeysValues) (via `WithKeyPrefix`) is prepended to the key *before* hashing, so it participates in the
slot calculation like any other bytes. To co-locate prefixed keys, make sure the hash tag falls inside the portion that
ends up shared — for example prefix `"{tenant42}:"` puts every key under that prefix in one slot.
24 changes: 24 additions & 0 deletions docs/exp/SER008.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Redis 8.10 is currently in preview and may be subject to change.

New features in Redis 8.10:

- Session-based bulk hash import via fieldsets (`HIMPORT`), surfaced as `IDatabase.HashImport`/`HashImportAsync`

The corresponding library features must also be considered subject to change:

1. Existing bindings may cease working correctly if the underlying server API changes.
2. Changes to the server API may require changes to the library API, manifesting in either/both of build-time
or run-time breaks.

While this seems *unlikely*, it must be considered a possibility. If you acknowledge this, you can suppress
this warning by adding the following to your `csproj` file:

```xml
<NoWarn>$(NoWarn);SER008</NoWarn>
```

or more granularly / locally in C#:

``` c#
#pragma warning disable SER008
```
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Documentation
- [Streams](Streams) - how to use the Stream data type
- [Arrays](Arrays) - how to use Redis Arrays as sparse arrays of values
- [Vector Sets](VectorSets) - how to use Vector Sets for similarity search with embeddings
- [Hash Import](HImport) - bulk-importing many hashes that share a common set of field names
- [Hash Tags and Slots](HashTags) - co-locating keys in the same cluster slot for multi-key operations
- [Where are `KEYS` / `SCAN` / `FLUSH*`?](KeysScan) - how to use server-based commands
- [Profiling](Profiling) - profiling interfaces, as well as how to profile in an `async` world
- [Scripting](Scripting) - running Lua scripts with convenient named parameter replacement
Expand Down
1 change: 1 addition & 0 deletions src/RESPite/Shared/Experiments.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal static class Experiments
public const string Respite = "SER004";
public const string UnitTesting = "SER005";
public const string Server_8_8 = "SER006";
public const string Server_8_10 = "SER008";

// ReSharper restore InconsistentNaming

Expand Down
Loading
Loading