Skip to content

feat(client): add HIMPORT command family with managed fieldset lifecycle - #3381

Merged
nkaradzhov merged 3 commits into
redis:masterfrom
nkaradzhov:feat/himport-hinted-hash-templates
Jul 29, 2026
Merged

feat(client): add HIMPORT command family with managed fieldset lifecycle#3381
nkaradzhov merged 3 commits into
redis:masterfrom
nkaradzhov:feat/himport-hinted-hash-templates

Conversation

@nkaradzhov

@nkaradzhov nkaradzhov commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

This pull request adds first-class support for the Redis 8.10 HIMPORT command family (hinted hash templates): HIMPORT PREPARE, HIMPORT SET, HIMPORT DISCARD, and HIMPORT DISCARDALL, exposed as hImportPrepare/hImportSet/hImportDiscard/hImportDiscardAll and marked @experimental.

Fieldsets are server-side session state scoped to a single physical connection, so a naive client binding breaks under pools, reconnects, and clusters. Per the HIMPORT client HLD, the client owns the fieldset lifecycle: a shared FieldsetRegistry records registrations on the logical client (standalone, pool, cluster, sentinel), and a per-connection hook transparently pipelines PREPARE in front of SET on sessions that lack the fieldset or hold a stale field list, replays pending discards before dependent commands, and retries a SET once on no such fieldset for registered fieldsets. DISCARD/DISCARDALL replies are registry-based (registered-and-removed), giving deterministic semantics across topologies, and a rejected discard restores the session bookkeeping so the discard replays on that connection.

On a cluster, every serving node client runs the same hook, so sessions prepare and reconcile lazily per node with no HIMPORT-specific routing code. The eager all_shards fan-out for the session commands (server command tips) and its response-policy handling arrive with the request/response policies work (#2996), which upgrades cluster support from lazy-per-node to fully tip-compliant.

Behavior notes: raw sendCommand(['HIMPORT', ...]) bypasses the managed layer; duplicate() shares the parent's registrations while createPool() does not; discarded fieldsets may linger on idle pooled connections (memory only, reconciled before their next HIMPORT command).

🤖 Generated with Claude Code


Note

High Risk
Large new command path with optimistic registry/session bookkeeping, injected wire commands, and rollback/reconcile logic across standalone, pool, cluster, and sentinel—errors could cause wrong hash field mapping or inconsistent session state.

Overview
Adds experimental Redis 8.10 HIMPORT support (hImportPrepare, hImportSet, hImportDiscard, hImportDiscardAll) so hinted hash templates work across reconnects, pools, cluster nodes, and sentinel failovers.

A shared FieldsetRegistry on the logical client records prepared field lists; each connection keeps PreparedFieldsets for what that socket’s session actually holds. Typed HIMPORT calls go through #executeHimport, which pipelines lazy PREPARE and reconcile DISCARD/DISCARDALL ahead of SET, returns registry-based discard replies, gates SET on failed injected PREPARE, retries SET once on no such fieldset when still registered, and re-issues ASKING on cluster ASK chains (askRedirect). Session state is cleared on socket errors, reset(), and socket swap; duplicate() shares the registry; pools/cluster/sentinel inject one registry per topology (pools always get a new registry). MULTI/pipeline reject PREPARE/DISCARD/DISCARDALL client-side; raw sendCommand(['HIMPORT', ...]) skips the managed layer.

Reviewed by Cursor Bugbot for commit 994ac8d. Bugbot is set up for automated code reviews on this repo. Configure here.

Adds first-class support for the Redis 8.10 HIMPORT command family
(hinted hash templates): HIMPORT PREPARE/SET/DISCARD/DISCARDALL,
exposed as hImportPrepare/hImportSet/hImportDiscard/hImportDiscardAll
and marked @experimental.

Fieldsets are server-side session state scoped to one physical
connection, so the client owns the fieldset lifecycle: a shared
FieldsetRegistry records registrations on the logical client, and a
per-connection hook transparently pipelines PREPARE in front of SET on
sessions that lack the fieldset or hold a stale field list, replays
pending discards before dependent commands, and retries a SET once on
'no such fieldset' for registered fieldsets. DISCARD/DISCARDALL replies
are registry-based, giving deterministic semantics across topologies,
and a rejected user discard restores the session claims and knocks the
synced count back so the discard replays on that connection.

On a cluster each serving node client runs the same hook, so sessions
prepare and reconcile lazily per node; the eager all_shards fan-out and
its response-policy pins arrive with the request-response-policies
work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nkaradzhov
nkaradzhov marked this pull request as ready for review July 29, 2026 10:43
@nkaradzhov
nkaradzhov requested a review from PavelPashov July 29, 2026 10:43

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c01a498639

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".


const noop = () => {};

const HIMPORT_SESSION_SUBCOMMANDS = new Set(['PREPARE', 'DISCARD', 'DISCARDALL']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile discards before allowing HIMPORT SET batches

When a fieldset is discarded on a pool/cluster/sentinel client, only the connection executing hImportDiscard(All) is cleaned immediately; other connections rely on #executeHimport's lazy reconcile before their next HIMPORT command. Because HIMPORT SET is not rejected here and batched commands go straight to _executeMulti/_executePipeline, a later multi/pipeline routed to a connection that missed the discard can still run SET against the stale server-side fieldset after hImportDiscard(All) has resolved. Either reject HIMPORT SET in batches when managed lifecycle state is dirty or run the same discard reconcile before enqueuing the batch.

Useful? React with 👍 / 👎.

Comment thread packages/client/lib/client/index.ts Outdated
// reconcile it away instead of silently serving stale SETs.
const { name, version } = userPrepare;
if (prepared.get(name) === version) prepared.delete(name);
if (registry.get(name)?.version === version) registry.discard(name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore old fieldsets when replacement PREPARE fails

If a client already has fs registered and a replacement hImportPrepare('fs', ...) is rejected (for example because the new list has duplicate fields), this catch path deletes the registry entry altogether. The server rejected the replacement before changing the existing session fieldset, so the previous registration should remain the source for lazy re-prepare; dropping it makes subsequent hImportSet on a new/reconnected or pooled connection stop auto-preparing fs and fail with no such fieldset even though the last successful registration was still valid.

Useful? React with 👍 / 👎.

const existing = this.#fieldsets.get(key);
if (existing !== undefined && fieldsEqual(existing.fields, fields)) return;
this.#fieldsets.set(key, {
fields: fields.slice(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clone Buffer field names before storing them

RedisArgument field names can be Buffers, but fields.slice() only copies the array and keeps references to the caller's buffers. After a successful hImportPrepare, if the caller reuses or mutates one of those buffers before a reconnect or before another pooled connection lazily prepares the fieldset, the registry replays the mutated bytes and subsequent hImportSet writes values under the wrong field names. Store immutable copies of buffer fields when recording the registry entry.

Useful? React with 👍 / 👎.

// to the queue front — a non-asap injection would let an asap SET jump its own PREPARE).
const effectiveAsap = commandOptions?.asap ?? client._commandOptions?.asap;
const effectiveChainId = commandOptions?.chainId ?? client._commandOptions?.chainId;
const injectOpts: CommandOptions = { asap: effectiveAsap, chainId: effectiveChainId };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep slot metadata on injected HIMPORT PREPARE commands

In cluster maintenance/SMIGRATED handling, queued commands are moved by slotNumber (extractCommandsForSlots ignores slotless commands), but the lazy HIMPORT PREPARE injected ahead of an HIMPORT SET drops the main command's slot metadata here. If a slot migrates while the pair is still queued, the SET can be moved to the destination without its PREPARE, so the destination executes a bare HIMPORT SET and fails with no such fieldset; carry commandOptions.slotNumber into injectOpts so the pair stays together.

Useful? React with 👍 / 👎.

* via the policy layer, and every node client (including MOVED/rediscovered nodes and
* SMIGRATED destinations) must lazily re-prepare from the same registrations.
*/
readonly #himportRegistry = new FieldsetRegistry();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Share HIMPORT registrations with duplicated clusters

Because each RedisClusterSlots allocates a fresh registry here and RedisCluster.duplicate() cannot pass the parent's instance through, a duplicate made after cluster.hImportPrepare(...) loses all HIMPORT registrations. Direct hImportSet on that duplicate then has no registry entry to lazy-prepare from and fails with no such fieldset, unlike the standalone duplicate path and the command docs' duplicate-sharing guarantee; thread the existing registry into duplicated cluster slots instead of always creating a new one.

Useful? React with 👍 / 👎.

} else if (command === HIMPORT_DISCARD) {
const name = String(args[2]);
const sessionVersion = prepared.get(name);
if (registry.discard(name)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid discarding registry state before enqueue succeeds

When hImportDiscard is called on a closed client, with an already-aborted signal, or when the queue is full, sendCommand rejects before the DISCARD reaches Redis, but the registry has already removed the fieldset here and the catch path intentionally leaves that mutation standing. Since duplicates/pools share this registry, a rejected discard can make later hImportSet calls stop auto-preparing or replay a discard even though the server never accepted the user's discard; defer the registry mutation until the command is actually accepted for sending or roll it back on pre-enqueue failures.

Useful? React with 👍 / 👎.

Comment thread packages/client/lib/client/index.ts
try {
reply = await mainPromise;
} catch (err) {
if (command === HIMPORT_PREPARE && userPrepare !== undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we restore the previous registry entry when a replacement PREPARE fails, removing it only when no prior definition existed? Redis retains the existing fieldset after validation failure, so deleting it here breaks transparent replay for duplicates and reconnects.

Comment on lines +1403 to +1414
if (sessionVersion === undefined || sessionVersion < entry.version) {
prepared.set(name, entry.version);
prelude.push({
args: ['HIMPORT', 'PREPARE', name, ...entry.fields],
// Injected-PREPARE failures are often transient (LOADING, failover) — roll back
// only the session claim, never the registration, or a valid fieldset would
// permanently lose lazy re-prepare.
rollback: () => {
if (prepared.get(name) === entry.version) prepared.delete(name);
}
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we tie the SET's outcome to its injected PREPARE? If the session holds an older version and the injected PREPARE fails while the connection stays alive (e.g. ACL denies the PREPARE subcommand), the SET still runs against the stale field list and succeeds — values land under the wrong field names with no error. One option: keep the PREPARE promise and, after the SET resolves, reject (or re-verify the version) if the PREPARE had failed.

Comment on lines +1442 to +1456
const send = () => client.sendCommand(parser.redisArgs, commandOptions);
let mainPromise: Promise<unknown>;
if (effectiveAsap) {
// asap unshifts, so consecutive front-insertions reverse: enqueue the main command
// first, then the prelude back-to-front — the wire sees prelude order, then main.
mainPromise = send();
for (let i = prelude.length - 1; i >= 0; i--) {
const injection = prelude[i];
client.sendCommand(injection.args, injectOpts).catch(injection.rollback);
}
} else {
for (const injection of prelude) {
client.sendCommand(injection.args, injectOpts).catch(injection.rollback);
}
mainPromise = send();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this runs under an ASK redirect, the wire order becomes ASKING → injected PREPARE → SET, and the keyless PREPARE consumes the one-shot ASKING flag — the SET then gets MOVED again and loops until maxCommandRedirections. Would it make sense to re-send ASKING between the prelude and the main command when on an ASK chain (or prepare the target node eagerly in _handleAsk)?

…luster

Address three registry-lifecycle bugs in the HIMPORT transparency layer that
broke the duplicate/pool/cluster registration-sharing contract:

- Rejected replacement PREPARE dropped a still-valid registration. Snapshot the
  prior field list and restore it on reject; a rejected fresh PREPARE still
  removes the name.
- RedisCluster.duplicate() lost all registrations because RedisClusterSlots
  always allocated a fresh registry. Thread the parent registry through an
  internal himportRegistry option, mirroring the standalone duplicate guarantee.
- A DISCARD/DISCARDALL rejected before reaching the wire (client closed/offline,
  aborted signal, full queue) removed the fieldset from the shared registry even
  though the server never saw it. Re-register on pre-enqueue failure; wire/server
  failures keep the existing replay-on-next-command semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/client/lib/client/index.ts
Comment thread packages/client/lib/client/index.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 492bbda127

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/client/lib/client/index.ts Outdated
Comment on lines +1470 to +1473
mainPromise = send();
for (let i = prelude.length - 1; i >= 0; i--) {
const injection = prelude[i];
client.sendCommand(injection.args, injectOpts).catch(injection.rollback);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep required HIMPORT preludes from being dropped

When an asap() HIMPORT command needs a lazy PREPARE/DISCARD and only one queue slot remains (for example commandsQueueMaxLength: 1 on a pooled/cluster connection that still holds an old or discarded fieldset), this enqueues the main HIMPORT SET first, then the required prelude is rejected as The queue is full and only its rollback runs. The already-queued SET can still execute against the stale server-side fieldset and return OK, writing values under the wrong/previous template instead of failing or preparing first; the capacity check needs to reserve/enqueue the prelude+main atomically or reject the whole operation.

Useful? React with 👍 / 👎.

Comment on lines +1423 to +1424
prelude.push({
args: ['HIMPORT', 'PREPARE', name, ...entry.fields],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve ASKING for the redirected HIMPORT SET

During an ASK redirection for cluster.hImportSet() on a destination connection that has not prepared the fieldset yet, _handleAsk queues ASKING and then calls this hook; the injected HIMPORT PREPARE becomes the first command after ASKING, so Redis consumes the one-time ASKING state before the redirected keyed HIMPORT SET. In slot-migration windows this can make the retry bounce with another redirection instead of executing on the importing node; the lazy PREPARE needs to run before ASKING or otherwise keep ASKING immediately in front of the SET.

Useful? React with 👍 / 👎.

// One fieldset registry across master/replica node clients: fieldsets registered before
// a failover must be transparently re-preparable on the promoted master's connections.
// (Sentinel-monitor clients use #sentinelClientOptions and never run HIMPORT.)
this.#nodeClientOptions.himportRegistry = new FieldsetRegistry();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Share HIMPORT registrations across sentinel duplicates

A RedisSentinel.duplicate() created after sentinel.hImportPrepare(...) constructs a new internal sentinel and this constructor always installs a fresh FieldsetRegistry, while duplicate() only spreads the original options. The duplicate therefore loses the parent's registrations even though the new HIMPORT docs state that duplicate() shares them, so a direct dup.hImportSet(...) cannot lazy-prepare and fails with no such fieldset; thread the existing sentinel registry into duplicates as the standalone and cluster paths do.

Useful? React with 👍 / 👎.

…SK chains

Two more HIMPORT transparency-layer correctness fixes from PR review:

- Tie the SET reply to its lazily-injected PREPARE. If that PREPARE is rejected
  while the connection stays alive (e.g. ACL denies the PREPARE subcommand), the
  SET would otherwise run against the stale server-side field list and silently
  store values under the wrong field names. Hold the PREPARE promise and, once
  the SET resolves, surface the PREPARE error and drop the session claim instead
  of returning the misleading OK.

- Re-issue ASKING on ASK-redirect chains. A keyless PREPARE/DISCARD pipelined
  ahead of the SET consumes the one-shot ASKING flag, so the SET is redirected
  again until maxCommandRedirections. The cluster ASK handler now marks the
  command options (askRedirect), and the hook re-issues ASKING as the last
  prelude entry so it lands immediately before the SET.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nkaradzhov
nkaradzhov merged commit 51f8d20 into redis:master Jul 29, 2026
15 checks passed
@nkaradzhov
nkaradzhov deleted the feat/himport-hinted-hash-templates branch July 29, 2026 14:35

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 994ac8d. Configure here.

prepared.syncedDiscardCount = Math.min(prepared.syncedDiscardCount, countAtInjection - 1);
};
if (collapseToDiscardAll) {
prelude.push({ args: ['HIMPORT', 'DISCARDALL'], rollback: () => rollback(pending) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconcile collapse to DISCARDALL destroys untracked raw fieldsets

Low Severity

The collapseToDiscardAll optimization replaces individual HIMPORT DISCARD commands with a single HIMPORT DISCARDALL when all tracked entries are pending. However, DISCARDALL removes all server-side fieldsets on the session—including any set up via raw sendCommand—while the equivalent individual DISCARD commands would only remove the managed (tracked) names. The comment says "nothing this session holds is worth keeping," but fieldsets created outside the managed layer would be silently destroyed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 994ac8d. Configure here.

userDiscard = { restore, removed, countAfter: registry.discardCount };
}
prepared.clear();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DISCARDALL with empty registry loses prepared entries on failure

Low Severity

In the HIMPORT_DISCARDALL branch, prepared.clear() runs unconditionally, but userDiscard (which drives error-path rollback of prepared entries) is only set when registryReply > 0. When the registry is empty yet the session holds stale prepared entries awaiting reconciliation, a wire failure permanently loses those session claims with no rollback—the captured restore snapshot is never used.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 994ac8d. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants