diff --git a/Directory.Build.props b/Directory.Build.props
index 7d2e9c6c5..76557f67f 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -10,7 +10,7 @@
true
$(MSBuildThisFileDirectory)Shared.ruleset
NETSDK1069
- $(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005;SER006
+ $(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005;SER006;SER008
https://stackexchange.github.io/StackExchange.Redis/ReleaseNotes
https://stackexchange.github.io/StackExchange.Redis/
MIT
diff --git a/docs/HImport.md b/docs/HImport.md
new file mode 100644
index 000000000..af61d5712
--- /dev/null
+++ b/docs/HImport.md
@@ -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†. 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.
+
+†: *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**‡ — 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.
+
+‡: 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` 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`.
diff --git a/docs/HashTags.md b/docs/HashTags.md
new file mode 100644
index 000000000..0ad8ef453
--- /dev/null
+++ b/docs/HashTags.md
@@ -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.
diff --git a/docs/exp/SER008.md b/docs/exp/SER008.md
new file mode 100644
index 000000000..fec4a2be9
--- /dev/null
+++ b/docs/exp/SER008.md
@@ -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);SER008
+```
+
+or more granularly / locally in C#:
+
+``` c#
+#pragma warning disable SER008
+```
diff --git a/docs/index.md b/docs/index.md
index 69a4cc70f..a21164785 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -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
diff --git a/src/RESPite/Shared/Experiments.cs b/src/RESPite/Shared/Experiments.cs
index 304b1f624..0cf540eba 100644
--- a/src/RESPite/Shared/Experiments.cs
+++ b/src/RESPite/Shared/Experiments.cs
@@ -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
diff --git a/src/StackExchange.Redis/APITypes/HashImport.cs b/src/StackExchange.Redis/APITypes/HashImport.cs
new file mode 100644
index 000000000..2e896a3aa
--- /dev/null
+++ b/src/StackExchange.Redis/APITypes/HashImport.cs
@@ -0,0 +1,266 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using RESPite;
+
+namespace StackExchange.Redis;
+
+///
+/// A reusable, connection-local field-set for streaming hash imports via .
+///
+///
+///
+/// The underlying HIMPORT family is session-local: a field-set is PREPAREd against a single
+/// physical connection and then referenced by name from each HIMPORT SET. Because the multiplexer hides
+/// connections (pooling, transparent reconnects, cluster routing, active/active), this type does not pin a
+/// connection. Instead, the PREPARE is injected lazily and automatically on whichever connection a given
+/// actually writes to (mirroring how SELECT is injected for database
+/// selection); a reconnect or a fan-out to another cluster node simply re-prepares on demand. Each import is applied
+/// on its own and may be pipelined freely with unrelated work, so imports are effectively unbounded.
+///
+///
+/// Disposing sends a best-effort HIMPORT DISCARD to the connections the field-set was prepared on, releasing the
+/// server-side state. Disposal is not required for correctness — the state also dies with the connection — but is good
+/// hygiene for long-lived connections.
+///
+/// A single is safe to use concurrently and against multiple databases/multiplexers.
+///
+[Experimental(Experiments.Server_8_10, UrlFormat = Experiments.UrlFormat)]
+public sealed class HashImport : IDisposable, IAsyncDisposable
+{
+ // process-wide monotonic id; deliberately not bound to any multiplexer so a single field-set can span
+ // active/active deployments. The id doubles as the opaque, connection-local field-set name on the wire - its 8 raw
+ // bytes are written verbatim (binary-safe) whenever a name is needed, so no separate byte[] is stored.
+ private static long _counter;
+
+ private readonly ReadOnlyMemory _fields;
+ private readonly long _id;
+
+ private readonly object _sync = new();
+ // servers this field-set has been prepared against, weakly held so an idle multiplexer can still be collected;
+ // used only to target DISCARD on disposal. Guarded by _sync. (A named struct rather than a ValueTuple: the shipped
+ // assembly must not reference System.ValueTuple - it breaks .NET Framework; see SanityChecks.ValueTupleNotReferenced.)
+ private List? _servers;
+ private volatile bool _disposed;
+
+ private readonly struct ServerRef(WeakReference server, int db)
+ {
+ public WeakReference Server { get; } = server;
+ public int Db { get; } = db;
+ }
+
+ private HashImport(ReadOnlyMemory fields)
+ {
+ if (fields.IsEmpty) throw new ArgumentException("At least one field name must be supplied.", nameof(fields));
+
+ // Snapshot into storage we own. The field-set is long-lived and its wire encoding must stay stable for the
+ // object's lifetime, so we must not alias caller memory that could be mutated after Create - which would also
+ // silently bypass the validation below. A field-set is created once and reused for many rows, so this one
+ // copy is amortized to nothing (and a handful of RedisValue at that).
+ var snapshot = fields.ToArray();
+
+ // The server rejects a PREPARE with duplicate field names, but because PREPARE is injected fire-and-forget that
+ // would surface only indirectly as every SET failing with "no such fieldset". Reject it up front for a clear
+ // error at the point of the mistake.
+ var seen = new HashSet();
+ for (int i = 0; i < snapshot.Length; i++)
+ {
+ if (snapshot[i].IsNull) throw new ArgumentException("Field names must not be null.", nameof(fields));
+ if (!seen.Add(snapshot[i])) throw new ArgumentException($"Duplicate field name: '{snapshot[i]}'.", nameof(fields));
+ }
+
+ _fields = snapshot;
+ _id = Interlocked.Increment(ref _counter);
+ }
+
+ ///
+ /// Creates a field-set describing the ordered field names shared by every hash imported through it.
+ ///
+ /// The field names; import values are supplied positionally in this order.
+ public static HashImport Create(params RedisValue[] fields) => new(fields);
+
+ ///
+ public static HashImport Create(ReadOnlyMemory fields) => new(fields);
+
+ internal long Id => _id;
+ internal ReadOnlyMemory Fields => _fields;
+ internal int FieldCount => _fields.Length;
+
+ // writes the opaque field-set name: the id's 8 raw bytes as a bulk string. Endianness is irrelevant (the server
+ // treats the name as an arbitrary byte string, and a token never leaves the process), so an unaligned blit of the
+ // id is enough - and identical for this token's every PREPARE/SET/DISCARD, which is all that matters.
+ internal void WriteName(in MessageWriter writer)
+ {
+ Span name = stackalloc byte[8];
+ Unsafe.WriteUnaligned(ref name[0], _id);
+ writer.WriteBulkString(name);
+ }
+
+ // rejects use of a disposed field-set before anything is sent; a disposed field-set may already have been DISCARDed
+ // on the server, so a SET against it would silently mis-behave (and would never be cleaned up).
+ internal void ThrowIfDisposed()
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(HashImport));
+ }
+
+ // creates the HIMPORT PREPARE injected (fire-and-forget) ahead of a SET on a connection that has not yet
+ // prepared this field-set; its result is never surfaced - a genuinely broken PREPARE re-appears as the SET
+ // failing with a "no such field-set" server error.
+ internal Message CreatePrepareMessage(int db) => new HashImportPrepareMessage(db, CommandFlags.FireAndForget, this);
+
+ // Records (once per server) that this field-set is now prepared somewhere on the given server, so disposal can
+ // target a DISCARD there.
+ //
+ // Note the deliberate split of responsibilities, in case a future reader worries that ServerEndPoint (which
+ // outlives any one connection and spans reconnects/nodes) is too coarse to track connection-local state:
+ // * Correctness - the "must I inject a PREPARE?" decision - is keyed on the actual PhysicalConnection
+ // (PhysicalConnection._preparedFieldSets). That is the real thing tied to the real session; a reconnect gives a
+ // fresh, empty set and re-prepares automatically. This list is NEVER consulted for that.
+ // * This list is used ONLY for best-effort cleanup on Dispose, at node granularity. DISCARD is idempotent and
+ // carries this field-set's globally-unique id, so it can only ever drop its own state or no-op ("no such
+ // fieldset") - it can never disturb another field-set regardless of which connection it lands on. If the
+ // connection rotated since PREPARE, the old session state is already gone and the DISCARD is a harmless no-op
+ // on the new connection. Node granularity is also exactly right for cluster, where one field-set is prepared
+ // independently on several nodes; deduping by server keeps this list bounded to the nodes touched (rather than
+ // growing per reconnect) and naturally follows each node's current connection.
+ //
+ // Bookkeeping only - it is called from inside the bridge write lock (during PREPARE injection), so it must never
+ // itself issue I/O. If the token is already being disposed we simply skip: a field-set prepared by a SET racing
+ // against Dispose may be stranded, but it dies with the connection anyway (best-effort), and using a token
+ // concurrently with disposing it is a caller error.
+ internal void RegisterServer(ServerEndPoint server, int db)
+ {
+ lock (_sync)
+ {
+ if (_disposed) return;
+ _servers ??= new();
+ for (int i = 0; i < _servers.Count; i++)
+ {
+ if (_servers[i].Server.TryGetTarget(out var existing) && ReferenceEquals(existing, server)) return;
+ }
+ _servers.Add(new ServerRef(new WeakReference(server), db));
+ }
+ }
+
+ private List? TakeServers()
+ {
+ lock (_sync)
+ {
+ if (_disposed) return null;
+ _disposed = true;
+ var servers = _servers;
+ _servers = null;
+ return servers;
+ }
+ }
+
+ ///
+ /// Releases the connection-local server state for this field-set (best-effort HIMPORT DISCARD).
+ ///
+ public void Dispose()
+ {
+ var servers = TakeServers();
+ if (servers is null) return;
+ foreach (var entry in servers)
+ {
+ if (entry.Server.TryGetTarget(out var server)) _ = SafeDiscardAsync(server, entry.Db);
+ }
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ var servers = TakeServers();
+ if (servers is null) return;
+ foreach (var entry in servers)
+ {
+ if (entry.Server.TryGetTarget(out var server)) await SafeDiscardAsync(server, entry.Db).ForAwait();
+ }
+ }
+
+ private async Task SafeDiscardAsync(ServerEndPoint server, int db)
+ {
+ try
+ {
+ await server.WriteDirectAsync(new HashImportDiscardMessage(db, CommandFlags.FireAndForget, this), ResultProcessor.DemandOK).ForAwait();
+ }
+ catch
+ {
+ // best-effort: the field-set dies with the connection regardless, so cleanup failures are benign
+ }
+ }
+}
+
+// HIMPORT SET : the user-facing per-row import. Carries a reference to its field-set so
+// the write path can inject a PREPARE the first time this field-set is seen on a connection (see PhysicalBridge).
+internal sealed class HashImportSetMessage : Message.CommandKeyBase
+{
+ private readonly HashImport _fieldSet;
+ private readonly ReadOnlyMemory _values;
+
+ public HashImportSetMessage(int db, CommandFlags flags, HashImport fieldSet, in RedisKey key, ReadOnlyMemory values)
+ : base(db, flags, RedisCommand.HIMPORT, key)
+ {
+ _fieldSet = fieldSet;
+ _values = values;
+ }
+
+ internal HashImport FieldSet => _fieldSet;
+
+ protected override void WriteImpl(in MessageWriter writer)
+ {
+ var values = _values.Span;
+ writer.WriteHeader(RedisCommand.HIMPORT, 3 + values.Length);
+ writer.WriteBulkString(RedisLiterals.SET);
+ writer.Write(Key);
+ _fieldSet.WriteName(writer);
+ for (int i = 0; i < values.Length; i++) writer.WriteBulkString(values[i]);
+ }
+
+ public override int ArgCount => 3 + _values.Length;
+}
+
+// HIMPORT PREPARE : injected fire-and-forget ahead of the first SET for a field-set on a
+// connection; defines the connection-local name->fields mapping the SET references.
+internal sealed class HashImportPrepareMessage : Message
+{
+ private readonly HashImport _fieldSet;
+
+ public HashImportPrepareMessage(int db, CommandFlags flags, HashImport fieldSet)
+ : base(db, flags, RedisCommand.HIMPORT) => _fieldSet = fieldSet;
+
+ protected override void WriteImpl(in MessageWriter writer)
+ {
+ var fields = _fieldSet.Fields.Span;
+ writer.WriteHeader(RedisCommand.HIMPORT, 2 + fields.Length);
+ writer.WriteBulkString(RedisLiterals.PREPARE);
+ _fieldSet.WriteName(writer);
+ for (int i = 0; i < fields.Length; i++) writer.WriteBulkString(fields[i]);
+ }
+
+ public override int ArgCount => 2 + _fieldSet.FieldCount;
+}
+
+// HIMPORT DISCARD : targeted cleanup of a single field-set, issued on disposal. Deliberately not
+// DISCARDALL, which would drop sibling field-sets sharing the connection.
+internal sealed class HashImportDiscardMessage : Message
+{
+ private readonly HashImport _fieldSet;
+
+ public HashImportDiscardMessage(int db, CommandFlags flags, HashImport fieldSet)
+ : base(db, flags, RedisCommand.HIMPORT) => _fieldSet = fieldSet;
+
+ internal long FieldSetId => _fieldSet.Id;
+
+ protected override void WriteImpl(in MessageWriter writer)
+ {
+ writer.WriteHeader(RedisCommand.HIMPORT, 2);
+ writer.WriteBulkString(RedisLiterals.DISCARD);
+ _fieldSet.WriteName(writer);
+ }
+
+ public override int ArgCount => 2;
+}
diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs
index a355c02e6..039671d44 100644
--- a/src/StackExchange.Redis/Enums/RedisCommand.cs
+++ b/src/StackExchange.Redis/Enums/RedisCommand.cs
@@ -98,6 +98,7 @@ internal enum RedisCommand
HGETEX,
HGETDEL,
HGETALL,
+ HIMPORT,
HINCRBY,
HINCRBYFLOAT,
HKEYS,
@@ -363,6 +364,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command)
case RedisCommand.HEXPIREAT:
case RedisCommand.HGETDEL:
case RedisCommand.HGETEX:
+ case RedisCommand.HIMPORT:
case RedisCommand.HINCRBY:
case RedisCommand.HINCRBYFLOAT:
case RedisCommand.HMSET:
diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs
index c0761b9a7..6f73eeae8 100644
--- a/src/StackExchange.Redis/Interfaces/IDatabase.cs
+++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs
@@ -788,6 +788,27 @@ public partial interface IDatabase : IRedis, IDatabaseAsync
///
IEnumerable HashScanNoValues(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None);
+ ///
+ /// Creates a single hash from values supplied positionally against a reusable field-set,
+ /// using the server-side session field-set mechanism (HIMPORT SET). The HIMPORT PREPARE for
+ /// is injected automatically the first time it is seen on a connection.
+ ///
+ /// The key of the hash to create; any existing hash at this key is replaced.
+ /// The field-set describing the ordered field names shared by imported hashes.
+ /// The field values for this hash, matched positionally against the field-set (same count).
+ /// The flags to use for this operation.
+ ///
+ ///
+ /// Each call is applied on its own and may be freely pipelined with unrelated work, so imports are effectively
+ /// unbounded. A server error (for example, a key already holding a non-hash value) is thrown as usual unless the
+ /// call is fire-and-forget. Cluster-aware: each key routes to its slot, re-preparing per node as needed.
+ ///
+ /// Not supported inside a transaction (the connection-local PREPARE cannot be staged in MULTI/EXEC).
+ ///
+ ///
+ [Experimental(Experiments.Server_8_10, UrlFormat = Experiments.UrlFormat)]
+ void HashImport(RedisKey key, HashImport fieldSet, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None);
+
///
/// Sets the specified fields to their respective values in the hash stored at key.
/// This command overwrites any specified fields that already exist in the hash, leaving other unspecified fields untouched.
diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
index 40db55cfd..cb3b2a436 100644
--- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
+++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
@@ -180,6 +180,10 @@ public partial interface IDatabaseAsync : IRedisAsync
///
IAsyncEnumerable HashScanNoValuesAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None);
+ ///
+ [Experimental(Experiments.Server_8_10, UrlFormat = Experiments.UrlFormat)]
+ Task HashImportAsync(RedisKey key, HashImport fieldSet, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None);
+
///
Task HashSetAsync(RedisKey key, HashEntry[] hashFields, CommandFlags flags = CommandFlags.None);
diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
index 952fe0ed3..c50c8a852 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
@@ -183,6 +183,9 @@ public Task HashSetAsync(RedisKey key, RedisValue hashField, RedisValue va
public Task HashStringLengthAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) =>
Inner.HashStringLengthAsync(ToInner(key), hashField, flags);
+ public Task HashImportAsync(RedisKey key, HashImport fieldSet, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) =>
+ Inner.HashImportAsync(ToInner(key), fieldSet, values, flags);
+
public Task HashSetAsync(RedisKey key, HashEntry[] hashFields, CommandFlags flags = CommandFlags.None) =>
Inner.HashSetAsync(ToInner(key), hashFields, flags);
diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
index 2ca7ca384..2dcfbebc0 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
@@ -174,6 +174,9 @@ public bool HashSet(RedisKey key, RedisValue hashField, RedisValue value, When w
public long HashStringLength(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) =>
Inner.HashStringLength(ToInner(key), hashField, flags);
+ public void HashImport(RedisKey key, HashImport fieldSet, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) =>
+ Inner.HashImport(ToInner(key), fieldSet, values, flags);
+
public void HashSet(RedisKey key, HashEntry[] hashFields, CommandFlags flags = CommandFlags.None) =>
Inner.HashSet(ToInner(key), hashFields, flags);
diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs
index 21047efa2..f1906d1a8 100644
--- a/src/StackExchange.Redis/PhysicalBridge.cs
+++ b/src/StackExchange.Redis/PhysicalBridge.cs
@@ -1463,6 +1463,33 @@ private void SelectDatabaseInsideWriteLock(PhysicalConnection connection, Messag
}
}
+ // HIMPORT is connection-local: a field-set must be PREPAREd on the same physical connection before a SET can
+ // reference it. We inject that PREPARE lazily here (mirroring SELECT injection): the first SET for a given
+ // field-set on a given connection triggers a fire-and-forget PREPARE ahead of it; a reconnect starts with a
+ // fresh (empty) connection set and re-prepares transparently. A DISCARD drops the id so the connection's set
+ // stays bounded to live field-sets. All of this runs inside the write lock, so ordering PREPARE-before-SET on
+ // the wire is guaranteed and the connection's set needs no further synchronization.
+ private void PrepareFieldSetInsideWriteLock(PhysicalConnection connection, Message message)
+ {
+ if (message is HashImportSetMessage set)
+ {
+ var fieldSet = set.FieldSet;
+ if (connection.TryAddPreparedFieldSet(fieldSet.Id))
+ {
+ var prepare = fieldSet.CreatePrepareMessage(message.Db);
+ connection.EnqueueInsideWriteLock(prepare);
+ prepare.WriteTo(connection);
+ prepare.SetRequestSent();
+ IncrementOpCount();
+ fieldSet.RegisterServer(ServerEndPoint, message.Db);
+ }
+ }
+ else if (message is HashImportDiscardMessage discard)
+ {
+ connection.RemovePreparedFieldSet(discard.FieldSetId);
+ }
+ }
+
private WriteResult WriteMessageToServerInsideWriteLock(PhysicalConnection connection, Message message)
{
if (message == null)
@@ -1549,6 +1576,7 @@ private WriteResult WriteMessageToServerInsideWriteLock(PhysicalConnection conne
{
Debug.Assert(!message.IsHighIntegrity, "prior high integrity message found during transaction?");
}
+ if (cmd is RedisCommand.HIMPORT) PrepareFieldSetInsideWriteLock(connection, message);
connection.EnqueueInsideWriteLock(message);
isQueued = true;
message.WriteTo(connection);
diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs
index e03932c4f..bb5c93ee2 100644
--- a/src/StackExchange.Redis/PhysicalConnection.cs
+++ b/src/StackExchange.Redis/PhysicalConnection.cs
@@ -51,6 +51,11 @@ private static readonly Message
private volatile int currentDatabase = 0;
+ // ids of HIMPORT field-sets already PREPAREd on this specific connection; lazily allocated, only ever touched
+ // inside the bridge write lock. A fresh connection (e.g. after a reconnect) starts empty, so the write path
+ // transparently re-prepares on demand - the connection-local server state is scoped to exactly this socket.
+ private HashSet? _preparedFieldSets;
+
private ReadMode currentReadMode = ReadMode.NotSpecified;
private int failureReported;
@@ -703,6 +708,14 @@ internal static Message GetSelectDatabaseCommand(int targetDatabase)
: Message.Create(targetDatabase, CommandFlags.FireAndForget, RedisCommand.SELECT);
}
+ // HIMPORT field-set tracking; only ever called inside the bridge write lock, so no synchronization is needed.
+ // Returns true when the id was not already prepared on this connection (i.e. the caller must inject a PREPARE).
+ internal bool TryAddPreparedFieldSet(long id) => (_preparedFieldSets ??= new()).Add(id);
+
+ // drops a field-set id when its DISCARD is written, keeping the set bounded to live field-sets over the life of
+ // a long-lived connection.
+ internal void RemovePreparedFieldSet(long id) => _preparedFieldSets?.Remove(id);
+
internal int GetSentAwaitingResponseCount()
{
lock (_writtenAwaitingResponse)
diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
index 7dc5c5811..508e82da9 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
@@ -1 +1,9 @@
#nullable enable
+[SER008]StackExchange.Redis.HashImport
+[SER008]StackExchange.Redis.HashImport.Dispose() -> void
+[SER008]StackExchange.Redis.HashImport.DisposeAsync() -> System.Threading.Tasks.ValueTask
+[SER008]StackExchange.Redis.IDatabase.HashImport(StackExchange.Redis.RedisKey key, StackExchange.Redis.HashImport! fieldSet, System.ReadOnlyMemory values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void
+[SER008]StackExchange.Redis.IDatabaseAsync.HashImportAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.HashImport! fieldSet, System.ReadOnlyMemory values, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+[SER008]static StackExchange.Redis.HashImport.Create(params StackExchange.Redis.RedisValue[]! fields) -> StackExchange.Redis.HashImport!
+[SER008]static StackExchange.Redis.HashImport.Create(System.ReadOnlyMemory fields) -> StackExchange.Redis.HashImport!
+StackExchange.Redis.RedisFeatures.HashImport.get -> bool
diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs
index 96390ac1e..7b265266d 100644
--- a/src/StackExchange.Redis/RedisDatabase.cs
+++ b/src/StackExchange.Redis/RedisDatabase.cs
@@ -4,6 +4,7 @@
using System.Diagnostics;
using System.Net;
using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
using System.Threading.Tasks;
using RESPite.Messages;
@@ -982,6 +983,36 @@ public Task HashSetAsync(RedisKey key, HashEntry[] hashFields, CommandFlags flag
return ExecuteAsync(msg, ResultProcessor.DemandOK);
}
+ public void HashImport(RedisKey key, HashImport fieldSet, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None)
+ => ExecuteSync(GetHashImportMessage(key, fieldSet, values, flags), ResultProcessor.DemandOK);
+
+ public Task HashImportAsync(RedisKey key, HashImport fieldSet, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None)
+ => ExecuteAsync(GetHashImportMessage(key, fieldSet, values, flags), ResultProcessor.DemandOK);
+
+ private HashImportSetMessage GetHashImportMessage(in RedisKey key, HashImport fieldSet, ReadOnlyMemory values, CommandFlags flags)
+ {
+ if (fieldSet is null) throw new ArgumentNullException(nameof(fieldSet));
+ fieldSet.ThrowIfDisposed();
+ if (values.Length != fieldSet.FieldCount)
+ {
+ throw new ArgumentException(
+ $"{values.Length} value(s) were supplied but the field-set defines {fieldSet.FieldCount} field(s); the counts must match.",
+ nameof(values));
+ }
+ // HIMPORT needs its PREPARE and SET on the same physical connection; the multiplexer provides that via
+ // write-path injection (exactly like SELECT). That injection cannot be reused inside a MULTI: every queued
+ // command adds a slot to the positional EXEC result array, but the transaction only accounts for the ops it
+ // knows about - an injected PREPARE would desync every following result. (This is the same reason
+ // SELECT-injection is itself forbidden inside a transaction.) Supporting it would require enqueuing PREPARE
+ // as a tracked transaction op, which is deliberately out of scope. Batches are fine: an ordered pipeline
+ // with no EXEC aggregate, so PREPARE injects per connection as normal.
+ if (this is ITransaction)
+ {
+ throw new NotSupportedException("HashImport is not supported inside a transaction; the connection-local HIMPORT PREPARE cannot be injected into a MULTI/EXEC without desyncing the EXEC result array.");
+ }
+ return new HashImportSetMessage(Database, flags, fieldSet, key, values);
+ }
+
public Task HashSetIfNotExistsAsync(RedisKey key, RedisValue hashField, RedisValue value, CommandFlags flags)
{
var msg = Message.Create(Database, flags, RedisCommand.HSETNX, key, hashField, value);
diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs
index 1a40cd427..b5233d843 100644
--- a/src/StackExchange.Redis/RedisFeatures.cs
+++ b/src/StackExchange.Redis/RedisFeatures.cs
@@ -50,7 +50,8 @@ namespace StackExchange.Redis
v8_2_0_rc1 = new Version(8, 1, 240), // 8.2 RC1 is version 8.1.240
v8_4_0_rc1 = new Version(8, 3, 224), // 8.4 RC1 is version 8.3.224
v8_6_0 = new Version(8, 6, 0),
- v8_8_0 = new Version(8, 7, 225); // 8.8 is version 8.7.225
+ v8_8_0 = new Version(8, 7, 225), // 8.8 is version 8.7.225
+ v8_10_0 = new Version(8, 9, 241); // 8.10 preview is version 8.9.241
#pragma warning restore SA1310 // Field names should not contain underscore
#pragma warning restore SA1311 // Static readonly fields should begin with upper-case letter
@@ -298,6 +299,11 @@ public RedisFeatures(Version version)
///
public bool DeleteWithValueCheck => Version.IsAtLeast(v8_4_0_rc1);
+ ///
+ /// Is session-based bulk hash import (HIMPORT) available?
+ ///
+ public bool HashImport => Version.IsAtLeast(v8_10_0);
+
#pragma warning restore 1629 // Documentation text should end with a period.
///
diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs
index 9709eb02e..bbe3a3294 100644
--- a/src/StackExchange.Redis/RedisLiterals.cs
+++ b/src/StackExchange.Redis/RedisLiterals.cs
@@ -32,6 +32,7 @@ public static readonly RedisValue
DESC = RedisValue.FromRaw("DESC"u8),
DIFF = RedisValue.FromRaw("DIFF"u8),
DIFF1 = RedisValue.FromRaw("DIFF1"u8),
+ DISCARD = RedisValue.FromRaw("DISCARD"u8),
DOCTOR = RedisValue.FromRaw("DOCTOR"u8),
ENCODING = RedisValue.FromRaw("ENCODING"u8),
EX = RedisValue.FromRaw("EX"u8),
@@ -93,6 +94,7 @@ public static readonly RedisValue
PAUSE = RedisValue.FromRaw("PAUSE"u8),
PERSIST = RedisValue.FromRaw("PERSIST"u8),
PING = RedisValue.FromRaw("PING"u8),
+ PREPARE = RedisValue.FromRaw("PREPARE"u8),
PURGE = RedisValue.FromRaw("PURGE"u8),
PX = RedisValue.FromRaw("PX"u8),
PXAT = RedisValue.FromRaw("PXAT"u8),
diff --git a/tests/StackExchange.Redis.Tests/HashImportTests.cs b/tests/StackExchange.Redis.Tests/HashImportTests.cs
new file mode 100644
index 000000000..19ecb20e6
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/HashImportTests.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Threading.Tasks;
+using StackExchange.Redis.KeyspaceIsolation;
+using Xunit;
+
+namespace StackExchange.Redis.Tests;
+
+///
+/// Integration tests for / and the
+/// reusable field-set (the session-based HIMPORT feature, Redis 8.10+).
+///
+[RunPerProtocol]
+public class HashImportTests(ITestOutputHelper output, SharedConnectionFixture fixture) : TestBase(output, fixture)
+{
+ private static RedisValue[] Values(string name, string email, int age) => [name, email, age];
+
+ [Fact]
+ public async Task ImportsManyHashesReusingOneFieldSet()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var db = conn.GetDatabase();
+ var prefix = Me();
+
+ RedisKey k1 = prefix + ":1", k2 = prefix + ":2", k3 = prefix + ":3";
+ await db.KeyDeleteAsync([k1, k2, k3]);
+
+ await using var fieldSet = HashImport.Create("name", "email", "age");
+ await db.HashImportAsync(k1, fieldSet, Values("alice", "a@example.com", 30));
+ await db.HashImportAsync(k2, fieldSet, Values("bob", "b@example.com", 25));
+ await db.HashImportAsync(k3, fieldSet, Values("carol", "c@example.com", 42));
+
+ Assert.Equal("alice", await db.HashGetAsync(k1, "name"));
+ Assert.Equal("a@example.com", await db.HashGetAsync(k1, "email"));
+ Assert.Equal(30, (int)await db.HashGetAsync(k1, "age"));
+ Assert.Equal("bob", await db.HashGetAsync(k2, "name"));
+ Assert.Equal("carol", await db.HashGetAsync(k3, "name"));
+ Assert.Equal(3, await db.HashLengthAsync(k3));
+ }
+
+ [Fact]
+ public async Task SingleEntry_Works()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var db = conn.GetDatabase();
+ RedisKey key = Me();
+ await db.KeyDeleteAsync(key);
+
+ await using var fieldSet = HashImport.Create("name", "email", "age");
+ await db.HashImportAsync(key, fieldSet, Values("alice", "a@example.com", 30));
+
+ Assert.Equal("alice", await db.HashGetAsync(key, "name"));
+ Assert.Equal(3, await db.HashLengthAsync(key));
+ }
+
+ [Fact]
+ public async Task ExistingHashIsReplacedNotMerged()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var db = conn.GetDatabase();
+ RedisKey key = Me();
+ await db.KeyDeleteAsync(key);
+ await db.HashSetAsync(key, [new("old", 1), new("keep", 2)]);
+
+ await using var fieldSet = HashImport.Create("name", "email", "age");
+ await db.HashImportAsync(key, fieldSet, Values("alice", "a@x", 30));
+
+ // HIMPORT SET replaces the whole hash: the pre-existing 'old'/'keep' fields are gone
+ Assert.Equal(3, await db.HashLengthAsync(key));
+ Assert.False(await db.HashExistsAsync(key, "old"));
+ Assert.Equal("alice", await db.HashGetAsync(key, "name"));
+ }
+
+ [Fact]
+ public async Task MismatchedValueCount_ThrowsBeforeSending()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var db = conn.GetDatabase();
+ await using var fieldSet = HashImport.Create("name", "email", "age");
+ // 3 fields but only 1 value -> synchronous ArgumentException, before any Task is returned
+ Assert.Throws(() => { _ = db.HashImportAsync(Me(), fieldSet, new RedisValue[] { "only-one" }); });
+ }
+
+ [Fact]
+ public async Task NullFieldSet_Throws()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var db = conn.GetDatabase();
+ Assert.Throws(() => { _ = db.HashImportAsync(Me(), null!, new RedisValue[] { "x" }); });
+ }
+
+ [Fact]
+ public async Task WrongTypeKey_ThrowsButOtherKeysSucceed()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var db = conn.GetDatabase();
+ var prefix = Me();
+ RedisKey k0 = prefix + ":0", k1 = prefix + ":1";
+ await db.KeyDeleteAsync([k0, k1]);
+ await db.StringSetAsync(k0, "i-am-a-string"); // wrong type for a hash import
+
+ await using var fieldSet = HashImport.Create("name", "email", "age");
+
+ var ex = await Assert.ThrowsAsync(() => db.HashImportAsync(k0, fieldSet, Values("alice", "a@x", 30)));
+ Assert.StartsWith("WRONGTYPE", ex.Message);
+
+ // each import is applied on its own: a later valid key still succeeds despite the earlier failure
+ await db.HashImportAsync(k1, fieldSet, Values("bob", "b@x", 25));
+ Assert.Equal("bob", await db.HashGetAsync(k1, "name"));
+ Assert.Equal("i-am-a-string", await db.StringGetAsync(k0)); // untouched
+ }
+
+ [Fact]
+ public void DuplicateFieldNames_RejectedAtCreate()
+ {
+ // rejected client-side: the server would reject the PREPARE, but that is injected fire-and-forget and would
+ // only surface indirectly as a "no such fieldset" failure on every SET - so we fail fast at the mistake.
+ var ex = Assert.Throws(() => HashImport.Create("f1", "f1"));
+ Assert.Contains("Duplicate field name", ex.Message);
+ }
+
+ [Fact]
+ public async Task FieldsAreSnapshotAtCreate()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var db = conn.GetDatabase();
+ RedisKey key = Me();
+ await db.KeyDeleteAsync(key);
+
+ var fieldNames = new RedisValue[] { "name", "email", "age" };
+ await using var fieldSet = HashImport.Create(fieldNames);
+ fieldNames[0] = "MUTATED"; // mutate the caller's array after Create - must not affect the field-set
+
+ await db.HashImportAsync(key, fieldSet, Values("alice", "a@x", 30));
+ Assert.Equal("alice", await db.HashGetAsync(key, "name")); // still 'name', not 'MUTATED'
+ Assert.False(await db.HashExistsAsync(key, "MUTATED"));
+ }
+
+ [Fact]
+ public void NullFieldName_RejectedAtCreate()
+ {
+ var ex = Assert.Throws(() => HashImport.Create("a", RedisValue.Null));
+ Assert.Contains("null", ex.Message);
+ }
+
+ [Fact]
+ public void EmptyFieldName_Allowed()
+ {
+ // the server accepts an empty field name (a hash can legitimately have an empty-string field), so we do too
+ using var fieldSet = HashImport.Create("", "b");
+ Assert.NotNull(fieldSet);
+ }
+
+ [Fact]
+ public async Task KeyPrefixIsolation_PrefixesKey()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var prefix = Me() + ":";
+ var db = conn.GetDatabase().WithKeyPrefix(prefix);
+ var raw = conn.GetDatabase();
+
+ const string inner = "u1";
+ string full = prefix + inner;
+ await raw.KeyDeleteAsync(full);
+
+ await using var fieldSet = HashImport.Create("name", "email", "age");
+ await db.HashImportAsync(inner, fieldSet, Values("alice", "a@x", 30));
+
+ // written under the prefixed key
+ Assert.Equal("alice", await raw.HashGetAsync(full, "name"));
+ }
+
+ [Fact]
+ public async Task WorksInsideBatch()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var db = conn.GetDatabase();
+ var prefix = Me();
+ RedisKey k1 = prefix + ":1", k2 = prefix + ":2";
+ await db.KeyDeleteAsync([k1, k2]);
+
+ await using var fieldSet = HashImport.Create("name", "email", "age");
+ var batch = db.CreateBatch();
+ var t1 = batch.HashImportAsync(k1, fieldSet, Values("alice", "a@x", 30));
+ var t2 = batch.HashImportAsync(k2, fieldSet, Values("bob", "b@x", 25));
+ batch.Execute();
+ await Task.WhenAll(t1, t2);
+
+ Assert.Equal("alice", await db.HashGetAsync(k1, "name"));
+ Assert.Equal("bob", await db.HashGetAsync(k2, "name"));
+ }
+
+ [Fact]
+ public async Task UseAfterDispose_Throws()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var db = conn.GetDatabase();
+ var fieldSet = HashImport.Create("name", "email", "age");
+ fieldSet.Dispose();
+ // rejected before anything is sent (the field-set may already have been DISCARDed on the server)
+ Assert.Throws(() => { _ = db.HashImportAsync(Me(), fieldSet, Values("a", "b", 1)); });
+ }
+
+ [Fact]
+ public async Task DoubleDispose_IsNoOp()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var db = conn.GetDatabase();
+ RedisKey key = Me();
+ await db.KeyDeleteAsync(key);
+
+ var fieldSet = HashImport.Create("name", "email", "age");
+ await db.HashImportAsync(key, fieldSet, Values("alice", "a@x", 30));
+ fieldSet.Dispose();
+ fieldSet.Dispose(); // idempotent: no second DISCARD, no throw
+ await fieldSet.DisposeAsync(); // also idempotent across the sync/async forms
+
+ Assert.Equal("alice", await db.HashGetAsync(key, "name"));
+ }
+
+ [Fact]
+ public async Task NotSupportedInsideTransaction()
+ {
+ await using var conn = Create(require: RedisFeatures.v8_10_0);
+ var tran = conn.GetDatabase().CreateTransaction();
+ await using var fieldSet = HashImport.Create("name", "email", "age");
+ // the transaction guard runs synchronously
+ Assert.Throws(() => { _ = tran.HashImportAsync(Me(), fieldSet, Values("a", "b", 1)); });
+ }
+}
diff --git a/tests/StackExchange.Redis.Tests/HighIntegrityHashImportTests.cs b/tests/StackExchange.Redis.Tests/HighIntegrityHashImportTests.cs
new file mode 100644
index 000000000..a2db70f7b
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/HighIntegrityHashImportTests.cs
@@ -0,0 +1,13 @@
+using Xunit;
+
+namespace StackExchange.Redis.Tests;
+
+///
+/// Reruns the full suite with high-integrity mode enabled, exercising the coexistence of
+/// the fire-and-forget HIMPORT PREPARE injected into the write path (which is not high-integrity) with the
+/// per-command high-integrity checksum framing applied to the following HIMPORT SETs on the same connection.
+///
+public class HighIntegrityHashImportTests(ITestOutputHelper output, SharedConnectionFixture fixture) : HashImportTests(output, fixture)
+{
+ internal override bool HighIntegrity => true;
+}
diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/HashImport.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/HashImport.cs
new file mode 100644
index 000000000..ac6cbbf57
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/HashImport.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Text;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace StackExchange.Redis.Tests.RoundTripUnitTests;
+
+///
+/// Verifies the exact wire bytes of the individual HIMPORT messages. The field-set name is written as an
+/// 8-byte bulk string (the token's opaque id); it is derived from the live token here rather than hard-coded, since
+/// the id is a process-wide monotonic counter.
+///
+public class HashImport(ITestOutputHelper log)
+{
+ // RESP bulk-string encoding of an arbitrary byte payload (may contain non-printable bytes).
+ private static string Bulk(byte[] bytes)
+ {
+ var sb = new StringBuilder().Append('$').Append(bytes.Length).Append("\r\n");
+ foreach (var b in bytes) sb.Append((char)b);
+ return sb.Append("\r\n").ToString();
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task Prepare_RoundTrips()
+ {
+ var token = StackExchange.Redis.HashImport.Create("name", "email", "age");
+ byte[] name = BitConverter.GetBytes(token.Id);
+ var msg = new HashImportPrepareMessage(0, CommandFlags.None, token);
+
+ // HIMPORT PREPARE name email age
+ var request = "*6\r\n$7\r\nHIMPORT\r\n$7\r\nPREPARE\r\n" + Bulk(name) + "$4\r\nname\r\n$5\r\nemail\r\n$3\r\nage\r\n";
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.DemandOK, request, "+OK\r\n", log: log);
+ Assert.True(result);
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task Set_RoundTrips()
+ {
+ var token = StackExchange.Redis.HashImport.Create("f1", "f2");
+ byte[] name = BitConverter.GetBytes(token.Id);
+ ReadOnlyMemory values = new RedisValue[] { "v1", "v2" };
+ var msg = new HashImportSetMessage(0, CommandFlags.None, token, (RedisKey)"user:1", values);
+
+ // HIMPORT SET user:1 v1 v2
+ var request = "*6\r\n$7\r\nHIMPORT\r\n$3\r\nSET\r\n$6\r\nuser:1\r\n" + Bulk(name) + "$2\r\nv1\r\n$2\r\nv2\r\n";
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.DemandOK, request, "+OK\r\n", log: log);
+ Assert.True(result);
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task Set_SingleValue_RoundTrips()
+ {
+ var token = StackExchange.Redis.HashImport.Create("only");
+ byte[] name = BitConverter.GetBytes(token.Id);
+ ReadOnlyMemory values = new RedisValue[] { "v" };
+ var msg = new HashImportSetMessage(0, CommandFlags.None, token, (RedisKey)"k", values);
+
+ var request = "*5\r\n$7\r\nHIMPORT\r\n$3\r\nSET\r\n$1\r\nk\r\n" + Bulk(name) + "$1\r\nv\r\n";
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.DemandOK, request, "+OK\r\n", log: log);
+ Assert.True(result);
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task Discard_RoundTrips()
+ {
+ var token = StackExchange.Redis.HashImport.Create("f");
+ byte[] name = BitConverter.GetBytes(token.Id);
+ var msg = new HashImportDiscardMessage(0, CommandFlags.None, token);
+
+ // HIMPORT DISCARD
+ var request = "*3\r\n$7\r\nHIMPORT\r\n$7\r\nDISCARD\r\n" + Bulk(name);
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.DemandOK, request, "+OK\r\n", log: log);
+ Assert.True(result);
+ }
+}