mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
* 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>
254 lines
8.7 KiB
Go
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
|
|
}
|