Files
go-redis/himport_commands.go
Nedyalko Dyakovandofekshenawa 49e004104c feat(himport): HIMPORT command with lazy per-connection prepare (#3919)
* feat(himport): HIMPORT command family with lazy per-connection prepare replay

Fieldsets are server-side session state scoped to one physical
connection, so pooled HIMPORT SET needs the fieldset prepared on
whichever connection executes it. Instead of replaying PREPARE in the
handshake, the client keeps a versioned fieldset registry and a
per-connection prepared flag, and injects the PREPARE into the same
round trip right before a SET that needs it - at most once per
connection session. A 'no such fieldset' reply for a registered
fieldset invalidates the flag and grants one retry that re-prepares.

* fix(himport): drain push notifications between injected PREPARE and command replies

proto.Reader.ReadReply consumes a RESP3 push frame as a normal reply, so
a notification arriving between the injected PREPARE's reply and the
command's reply was read as the command's reply and desynced the
connection. Drain pending notifications again after reading the PREPARE
replies, mirroring what the pipeline read path does before every reply.

The regression test drives a RESP3 mock server that emits a push frame
between the two replies; without the drain the SET fails with "can't
parse reply=\">2\"".

* fix(himport): wire registry into failover and tx clients, invalidate stale pipeline flags

Two review fixes for the lazy prepare-replay machinery:

- NewFailoverClient built its baseClient without the HIMPORT registry,
  so HImportPrepare appeared to succeed but never registered and pooled
  HImportSet replay never engaged. Tx (client.Watch) now shares the
  parent client's registry the same way Client.Conn() does, since its
  sticky pool borrows connections from the parent pool.

- A pipelined HImportSet failing with "no such fieldset" left the
  connection's prepared flag stale, so even a caller-initiated retry of
  the pipeline failed forever on that connection. The failed exec now
  invalidates the flag and the retried pipeline replays the PREPARE.
  Pipelines are deliberately not auto-retried: re-running the batch
  would repeat side effects of commands that already succeeded.

* test(himport): skip integration specs when the server lacks HIMPORT

The 8.10.x CI image is a pre-release build without the HIMPORT patch
series, so the version gate alone let the specs run into "ERR unknown
command 'himport'". Probe with HIMPORT DISCARDALL and skip when the
command family is absent.

* docs(himport): add example app

Walks through prepare/set basics, pipelined bulk ingestion timed
against plain HSET, concurrent pooled writers relying on lazy replay,
the template encoding surfaced by OBJECT ENCODING and INFO memory, and
fieldset discard semantics. Exits gracefully when the server does not
support HIMPORT.

* feat(himport): propagate discards lazily to all pooled connections

HIMPORT DISCARD/DISCARDALL execute on one borrowed connection, so other
pooled sessions kept their fieldset copies: a subsequent HImportSet was
nondeterministic (it succeeded against a session that still held the
fieldset and failed elsewhere), and the leftover copies held server
memory until the connections closed.

Discards now propagate the same way prepares do. A discarded name stays
in the client registry as a tombstone and DISCARDALL bumps a registry
epoch; before any HIMPORT command on a connection, the client injects
HIMPORT DISCARD for tombstoned fieldsets that session still holds, or a
single HIMPORT DISCARDALL when the session predates the current epoch.
Re-registering a name clears its tombstone - the replayed PREPARE
already replaces the fieldset server-side.

Post-discard behavior is now deterministic: HImportSet fails with "no
such fieldset" on every pooled connection, exactly as on a single
connection. Non-HIMPORT traffic never pays for the cleanup; sessions
that stop running HIMPORT commands are cleaned up by connection close,
as before.

* fix(himport): guard root-cause swap, discard stale fieldset

- the single-command path replaced the SET reply with an injected
  PREPARE failure even when the SET succeeded or failed with an
  unrelated error; gate it on "no such fieldset" like the batch path
- an injected re-prepare that fails leaves the session holding the
  older fieldset version, and the pipelined SET behind it then writes
  the old field names silently; discard the held version first so the
  SET answers "no such fieldset" and the root cause is surfaced
- move the test-only registry accessors epoch/empty into the test
  file: the linter runs with tests excluded and flags them as unused

* fix(himport): align recovery and discard semantics with HLD v21

- Pipelines: HIMPORT SETs of a registered fieldset that fail with "no
  such fieldset" are re-prepared and re-issued once on the same
  connection - and only those SETs; HIMPORT SET is a full replace, so
  the re-execution is idempotent and no other command of the batch runs
  again. The error therefore never surfaces for managed fieldsets
  (NF.4). Transactions keep surfacing it - an executed MULTI/EXEC
  cannot be partially re-run - and the invalidated flags make the
  caller's transaction retry succeed.

- HImportDiscard/HImportDiscardAll report the registry lifecycle per
  the HLD method shapes: 1 when the fieldset was registered on this
  client and removed, and the count of registered fieldsets removed.
  The executing session's reply passes through only for names never
  registered through the managed API.

- Document that HIMPORT commands sent through generic interfaces (Do,
  raw Cmd values) bypass the registry and carry none of the managed
  guarantees; the root README generalizes the caveat to every command
  that alters connection session state (SELECT, CLIENT SETNAME, RESET,
  CLIENT TRACKING, ...) and points session-scoped work at Conn().

The working tree also carries the remote "guard root-cause swap,
discard stale fieldset" changes verbatim, so the merge below resolves
cleanly.

* feat(himport): cluster and ring support via shared registry and fan-out

Every ClusterClient node client and Ring shard client now shares one
HIMPORT fieldset registry, adopted at client creation - replicas
included, since roles change with the topology: a promoted replica's
connections carry no prepared flags, so their first HIMPORT SET
replays the PREPARE lazily, and MOVED/ASK redirects re-prepare on the
target node through the existing per-connection machinery with no
cluster-specific code.

On top of the shared registry, user-issued PREPARE, DISCARD, and
DISCARDALL fan out eagerly to all masters (R.4) or all ring shards:
one connection per node is prepared or cleaned up front, server-side
validation surfaces immediately, and leftover session state stays
bounded. Fan-out copies carry a pre-assigned registry version/epoch so
the registry is touched exactly once per operation; a deterministic
server rejection (e.g. duplicate field name) withdraws the
registration, while transport failures keep it and lazy replay covers
the connections the fan-out missed (all-succeeded reporting).

* chore(ci): add fieldset to the spellcheck wordlist

* fix(himport): wire cluster pipelines into the managed layer, close review findings

Review findings from three independent passes (wire protocol,
concurrency/state, HLD compliance):

- ClusterClient pipelines and tx pipelines wrote commands directly to
  node connections, bypassing HIMPORT entirely: no PREPARE replay (the
  fan-out prepares one connection per master, so pooled pipelines hit
  "no such fieldset" nondeterministically), no registry updates for
  pipelined PREPARE/DISCARD (a pipelined DISCARD was later resurrected
  by lazy replay), no re-prepare after MOVED/ASK re-queues, and no
  stale-version defense. processPipelineNodeConn and
  processTxPipelineNodeConn now inject and consume the same HIMPORT
  bookkeeping commands as the standalone paths; failed SETs of
  registered fieldsets re-queue through the pipeline's attempt budget,
  which re-prepares lazily on the next attempt.

- A transport failure inside the standalone pipeline's HIMPORT retry
  round trip signalled canRetry after the first round trip had been
  fully consumed, so the outer loop re-executed the whole batch and
  double-applied non-idempotent commands whose results were already
  delivered. The retry now reports the error without requesting a
  batch retry; the connection is still discarded.

- The handshake Conn handed to OnConnect (and the re-auth Conn) had a
  private HIMPORT registry whose versions restart at 1: an OnConnect
  prepare could mark a pooled connection at a version number colliding
  with the client registry's, silently skipping the replay of a
  different fieldset definition. Both now share the client's registry.

- A fan-out PREPARE rejected by one master withdrew the registration
  without a tombstone, leaving unaddressable fieldsets alive on the
  masters where the fan-out had already succeeded. The withdrawal now
  tombstones, so those sessions are cleaned on their next HIMPORT use.

- NewRing stored the ring-wide registry in the caller-owned
  RingOptions, so two rings built from one options struct shared and
  clobbered each other's registry. NewRing now copies the options.

- Rewrite the stale pre-cluster godoc on HImportPrepare that claimed
  ClusterClient/Ring do not fan out, and document the best-effort
  fan-out failure semantics on the cluster/ring methods.

- newConn now takes the HIMPORT registry as an explicit parameter
  instead of allocating a throwaway one that every call site
  overwrote.

* fix(himport): skip set re-queue when the batch retry already carries it

* fix(himport): scope re-issue failures, unmark while holding the conn

Three review findings:

- A transport error inside the pipeline set re-issue round trip was
  returned to generalProcessPipeline, which stamped it onto every
  command with a nil error - including commands whose results were
  already delivered by the fully-consumed first round trip. The error
  now stays on the retried SETs; the batch neither retries nor fails
  wholesale, and the connection, which may hold unread replies, is
  marked for removal on release (MarkCloseOnPut). Regression-tested by
  dropping the connection on the replayed PREPARE and asserting the
  delivered plain SET keeps its OK result.

- The single-command recovery unmarked the prepared flag from
  process(), after withConn had already released the connection,
  mutating flags on a connection another goroutine may own. The
  invalidation now happens inside _process while the connection is
  still held; himportRecover became himportShouldRetrySet, a pure
  retry decision.

- A connection's discard-all epoch could in principle be overwritten
  backwards by a mark carrying an older registry snapshot. The epoch
  is now forward-only on marks; a stale snapshot can no longer regress
  a session wiped at a newer epoch.

The working tree also mirrors the master-merge changes to _process
(assertUnstableCommand removal) so the merge below resolves cleanly.

* fix(himport): survive mass session loss, honor close-on-put in sticky pools

Deep-review findings from three independent passes (fresh-eyes diff,
behavioral simulation against a session-faithful mock, regression
safety), plus one final-review nit:

- Mass session loss (failover, cross-region switch, reset storms)
  could beat the single-command retry-once budget: the retry landed on
  a different connection whose session was wiped by the same event but
  whose prepared flag still looked current, so the PREPARE was not
  injected and "no such fieldset" surfaced for a managed fieldset
  (deterministic repro: 2-3 surfaced errors per 1200 SETs in a reset
  storm). A "no such fieldset" failure now bumps the fieldset's
  registry version (fields kept, guarded against concurrent
  re-registrations, once per batch), staling every connection's mark
  so the retry re-prepares wherever it lands.

- StickyConnPool.Put ignored MarkCloseOnPut, so a Conn/Tx whose
  HIMPORT re-issue round trip failed on a transport error got the same
  connection back with the re-issued replies still unread - the next
  command read a leftover reply as its own (live repro: HImportDiscard
  parsing "OK" as an integer; a status-typed command would accept the
  wrong reply silently). Sticky Put now records the mark as a bad-conn
  error, Get refuses the connection, and the parent pool removes it on
  unwind.

- The standalone pipeline and tx-pipeline paths stamped no per-command
  errors when the injected-reply read died on a transport error: the
  outer retry loop stamps only on its exit branch, not when the
  attempt budget runs out mid-loop, so Exec surfaced the error while
  every command still reported Err() == nil. Both paths now stamp the
  batch like a write failure, matching the cluster pipeline; the
  regression test drives it through the mock (connection dropped on
  the injected PREPARE, MaxRetries -1).

- The "no such fieldset" check after every single-command reply ran
  before any type check; its errors.As chain allocates, costing every
  non-HIMPORT command 2 heap allocations (+32 B, ~21 ns, +12.5%
  allocs/op for a simple SET). The HImportSet type assertion now runs
  first: ~1 ns and zero allocations for everything else.

Doc hardening: cross-node PREPARE+SET pipelining limitation on
ClusterClient, tombstone growth bound, the recovery attempt granted
under MaxRetries -1, and the ASKING re-issue comment corrected.

* docs(himport): mark the HIMPORT API as experimental

The server command family ships behind the hinted-hash-templates
feature of Redis 8.10 pre-releases and its surface may still move;
the typed client API (methods, HImport*Cmd types, constructors)
follows it. Same wording and placement as the vectorset commands.

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-07-29 14:52:32 +03:00

254 lines
8.7 KiB
Go

package redis
import "context"
// The HIMPORT command family (Redis 8.10+, "hinted hash templates") provides
// fast ingestion of many hashes sharing the same field names. HIMPORT PREPARE
// registers the field names once under a fieldset name, then HIMPORT SET
// creates hashes by sending only the values.
//
// The server scopes a fieldset to the physical connection that prepared it.
// Because go-redis pools connections, the client additionally keeps a
// client-side registry of fieldsets registered through HImportPrepare and
// lazily replays the PREPARE (at most once per connection session) on any
// pooled connection about to execute an HImportSet that references it. See
// himport.go.
//
// The whole HIMPORT surface — the typed methods, the HImport*Cmd types and
// their constructors — is experimental and may be subject to change.
// himportCmder marks HIMPORT commands that participate in client-side
// fieldset tracking. Process paths do a single interface assertion on the
// hot path and inspect the concrete type only for HIMPORT commands.
type himportCmder interface {
Cmder
himportCmd()
}
var (
_ himportCmder = (*HImportPrepareCmd)(nil)
_ himportCmder = (*HImportSetCmd)(nil)
_ himportCmder = (*HImportDiscardCmd)(nil)
_ himportCmder = (*HImportDiscardAllCmd)(nil)
)
// HImportPrepareCmd represents an HIMPORT PREPARE command.
type HImportPrepareCmd struct {
StatusCmd
fieldsetName string
fields []string
// registryVersion and registryEpoch are set only on commands injected by
// the client to replay a registered fieldset onto a connection; on
// success the connection is marked as prepared at this version under
// this discard-all epoch.
registryVersion uint64
registryEpoch uint64
}
func (cmd *HImportPrepareCmd) himportCmd() {}
// NewHImportPrepareCmd returns an HIMPORT PREPARE command.
func NewHImportPrepareCmd(ctx context.Context, fieldsetName string, fields ...string) *HImportPrepareCmd {
args := make([]interface{}, 3+len(fields))
args[0] = "himport"
args[1] = "prepare"
args[2] = fieldsetName
for i, field := range fields {
args[3+i] = field
}
return &HImportPrepareCmd{
StatusCmd: StatusCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeStatus,
},
},
fieldsetName: fieldsetName,
fields: append([]string(nil), fields...),
}
}
// HImportSetCmd represents an HIMPORT SET command.
type HImportSetCmd struct {
StatusCmd
fieldsetName string
}
func (cmd *HImportSetCmd) himportCmd() {}
// NewHImportSetCmd returns an HIMPORT SET command.
func NewHImportSetCmd(ctx context.Context, key, fieldsetName string, values ...interface{}) *HImportSetCmd {
args := make([]interface{}, 4+len(values))
args[0] = "himport"
args[1] = "set"
args[2] = key
args[3] = fieldsetName
copy(args[4:], values)
cmd := &HImportSetCmd{
StatusCmd: StatusCmd{
baseCmd: baseCmd{
ctx: ctx,
args: args,
cmdType: CmdTypeStatus,
},
},
fieldsetName: fieldsetName,
}
cmd.SetFirstKeyPos(2)
return cmd
}
// HImportDiscardCmd represents an HIMPORT DISCARD command.
type HImportDiscardCmd struct {
IntCmd
fieldsetName string
}
func (cmd *HImportDiscardCmd) himportCmd() {}
// NewHImportDiscardCmd returns an HIMPORT DISCARD command.
func NewHImportDiscardCmd(ctx context.Context, fieldsetName string) *HImportDiscardCmd {
return &HImportDiscardCmd{
IntCmd: IntCmd{
baseCmd: baseCmd{
ctx: ctx,
args: []interface{}{"himport", "discard", fieldsetName},
cmdType: CmdTypeInt,
},
},
fieldsetName: fieldsetName,
}
}
// HImportDiscardAllCmd represents an HIMPORT DISCARDALL command.
type HImportDiscardAllCmd struct {
IntCmd
// registryEpoch is set only on commands injected by the client to wipe a
// session that predates the registry's discard-all epoch; on success the
// connection adopts this epoch.
registryEpoch uint64
}
func (cmd *HImportDiscardAllCmd) himportCmd() {}
// NewHImportDiscardAllCmd returns an HIMPORT DISCARDALL command.
func NewHImportDiscardAllCmd(ctx context.Context) *HImportDiscardAllCmd {
return &HImportDiscardAllCmd{
IntCmd: IntCmd{
baseCmd: baseCmd{
ctx: ctx,
args: []interface{}{"himport", "discardall"},
cmdType: CmdTypeInt,
},
},
}
}
// HImportPrepare registers an ordered list of hash field names under
// fieldsetName for use by subsequent HImportSet calls:
//
// HIMPORT PREPARE fieldset_name field [field ...]
//
// The server keeps the fieldset in the session of the connection that
// executed the command. On pooled clients (Client, Conn, Pipeline, Tx) the
// fieldset is also remembered client-side and the PREPARE is replayed
// lazily — at most once per connection session — on any pooled connection
// about to execute an HImportSet referencing it, so HImportSet works
// transparently across the pool. Preparing an existing fieldset name again
// silently replaces it.
//
// ClusterClient and Ring override this method (see himport_cluster.go): the
// fieldset registers in a registry shared by every node/shard client and the
// PREPARE additionally fans out eagerly to all masters/shards.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c cmdable) HImportPrepare(ctx context.Context, fieldsetName string, fields ...string) *StatusCmd {
cmd := NewHImportPrepareCmd(ctx, fieldsetName, fields...)
_ = c(ctx, cmd)
return &cmd.StatusCmd
}
// HImportSet creates or fully replaces the hash at key using the field list
// registered under fieldsetName, pairing values positionally with the
// prepared fields:
//
// HIMPORT SET key fieldset_name value [value ...]
//
// The number of values must equal the fieldset's field count. The resulting
// key is a regular hash readable and writable by all hash commands. If the
// fieldset was registered through HImportPrepare on this client, it is
// prepared automatically on whichever pooled connection executes the command;
// otherwise the fieldset must have been prepared on the executing connection
// or the server replies "ERR no such fieldset".
//
// "no such fieldset" never surfaces for a registered fieldset: a
// single-command HImportSet whose connection lost its session state (e.g.
// RESET) is transparently re-prepared and retried once — the failure also
// stales every other connection's prepared flag, so the retry re-prepares
// wherever it lands, and this recovery attempt is granted even when retries
// are disabled (MaxRetries -1). In pipelines the failed HImportSets — and
// only those — are re-prepared and re-issued once on the same connection
// (HIMPORT SET is a full replace, so the re-execution is idempotent and no
// other command of the batch runs again). Inside transactions the error does
// surface after EXEC — an executed transaction cannot be partially re-run —
// but the prepared flags are invalidated, so retrying the transaction
// succeeds.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c cmdable) HImportSet(ctx context.Context, key, fieldsetName string, values ...interface{}) *StatusCmd {
cmd := NewHImportSetCmd(ctx, key, fieldsetName, values...)
_ = c(ctx, cmd)
return &cmd.StatusCmd
}
// HImportDiscard removes fieldsetName from the executing connection's session
// and from the client-side registry, stopping further automatic replay:
//
// HIMPORT DISCARD fieldset_name
//
// It returns 1 if the fieldset was registered on this client and is now
// removed, 0 otherwise (for names never registered through the managed API,
// the executing connection's session reply passes through unchanged). Pooled
// connections whose sessions still hold the fieldset replay the DISCARD
// before their next HIMPORT command, so a subsequent HImportSet fails with
// "no such fieldset" on every connection, exactly as on a single connection.
// Hashes already created through the fieldset are not affected.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c cmdable) HImportDiscard(ctx context.Context, fieldsetName string) *IntCmd {
cmd := NewHImportDiscardCmd(ctx, fieldsetName)
_ = c(ctx, cmd)
return &cmd.IntCmd
}
// HImportDiscardAll removes all fieldsets from the executing connection's
// session and clears the client-side registry:
//
// HIMPORT DISCARDALL
//
// It returns the number of fieldsets removed from the client-side registry
// (when none were registered, the executing connection's session count
// passes through). Other pooled connections whose sessions were prepared
// earlier replay HIMPORT DISCARDALL before their next HIMPORT command.
//
// Requires Redis 8.10 or newer.
//
// note: the API is experimental and may be subject to change.
func (c cmdable) HImportDiscardAll(ctx context.Context) *IntCmd {
cmd := NewHImportDiscardAllCmd(ctx)
_ = c(ctx, cmd)
return &cmd.IntCmd
}