1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-22 19:49:00 +03:00
Files
go-redis/himport_commands_test.go
Nedyalko Dyakov 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

220 lines
7.9 KiB
Go

package redis_test
import (
"context"
"fmt"
"strings"
"sync"
. "github.com/bsm/ginkgo/v2"
. "github.com/bsm/gomega"
"github.com/redis/go-redis/v9"
)
var _ = Describe("HIMPORT commands", func() {
ctx := context.TODO()
var client *redis.Client
BeforeEach(func() {
SkipBeforeRedisVersion("8.10", "HIMPORT requires Redis 8.10")
client = redis.NewClient(redisOptions())
// Version alone is not enough: 8.10 pre-release builds may lack the
// HIMPORT command family. Probe for it and skip when absent.
if err := client.HImportDiscardAll(ctx).Err(); err != nil &&
strings.Contains(strings.ToLower(err.Error()), "unknown command") {
Expect(client.Close()).NotTo(HaveOccurred())
client = nil
Skip("server does not support HIMPORT")
}
Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred())
})
AfterEach(func() {
if client != nil {
Expect(client.Close()).NotTo(HaveOccurred())
}
})
It("prepares a fieldset and creates hashes from values", func() {
prepare := client.HImportPrepare(ctx, "shared", "name", "email", "age")
Expect(prepare.Err()).NotTo(HaveOccurred())
Expect(prepare.Val()).To(Equal("OK"))
set := client.HImportSet(ctx, "shared:1", "shared", "alice", "alice@example.com", "25")
Expect(set.Err()).NotTo(HaveOccurred())
Expect(set.Val()).To(Equal("OK"))
Expect(client.HGet(ctx, "shared:1", "name").Val()).To(Equal("alice"))
Expect(client.HGet(ctx, "shared:1", "email").Val()).To(Equal("alice@example.com"))
Expect(client.HGet(ctx, "shared:1", "age").Val()).To(Equal("25"))
Expect(client.HGetAll(ctx, "shared:1").Val()).To(HaveLen(3))
})
It("pairs values positionally with each fieldset's declared order", func() {
Expect(client.HImportPrepare(ctx, "order1", "a", "b", "c").Err()).NotTo(HaveOccurred())
Expect(client.HImportPrepare(ctx, "order2", "c", "b", "a").Err()).NotTo(HaveOccurred())
Expect(client.HImportSet(ctx, "order:key1", "order1", "va1", "vb1", "vc1").Err()).NotTo(HaveOccurred())
Expect(client.HImportSet(ctx, "order:key2", "order2", "vc2", "vb2", "va2").Err()).NotTo(HaveOccurred())
Expect(client.HGet(ctx, "order:key1", "a").Val()).To(Equal("va1"))
Expect(client.HGet(ctx, "order:key2", "a").Val()).To(Equal("va2"))
})
It("replays the prepare on every pooled connection that runs a set", func() {
Expect(client.HImportPrepare(ctx, "pooled", "f1", "f2").Err()).NotTo(HaveOccurred())
// Concurrent writers spread across the pool; every connection must
// have the fieldset replayed onto its session before its first SET.
var wg sync.WaitGroup
errs := make(chan error, 100)
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("pooled:%d", i)
if err := client.HImportSet(ctx, key, "pooled", "v1", "v2").Err(); err != nil {
errs <- err
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
Expect(err).NotTo(HaveOccurred())
}
keys, err := client.Keys(ctx, "pooled:*").Result()
Expect(err).NotTo(HaveOccurred())
Expect(keys).To(HaveLen(100))
})
It("fully replaces an existing hash", func() {
Expect(client.HSet(ctx, "replace:1", "old", "x", "stale", "y").Err()).NotTo(HaveOccurred())
Expect(client.HImportPrepare(ctx, "repl", "fresh").Err()).NotTo(HaveOccurred())
Expect(client.HImportSet(ctx, "replace:1", "repl", "z").Err()).NotTo(HaveOccurred())
all := client.HGetAll(ctx, "replace:1").Val()
Expect(all).To(Equal(map[string]string{"fresh": "z"}))
})
It("re-prepares after a fieldset is replaced", func() {
Expect(client.HImportPrepare(ctx, "evolving", "a").Err()).NotTo(HaveOccurred())
Expect(client.HImportSet(ctx, "evolving:1", "evolving", "1").Err()).NotTo(HaveOccurred())
// Preparing the same name again silently replaces it; subsequent
// sets must use the new field list on every pooled connection.
Expect(client.HImportPrepare(ctx, "evolving", "a", "b").Err()).NotTo(HaveOccurred())
Expect(client.HImportSet(ctx, "evolving:2", "evolving", "1", "2").Err()).NotTo(HaveOccurred())
Expect(client.HGet(ctx, "evolving:2", "b").Val()).To(Equal("2"))
})
It("discards a fieldset and stops replaying it", func() {
Expect(client.HImportPrepare(ctx, "gone", "f").Err()).NotTo(HaveOccurred())
Expect(client.HImportSet(ctx, "gone:1", "gone", "v").Err()).NotTo(HaveOccurred())
discard := client.HImportDiscard(ctx, "gone")
Expect(discard.Err()).NotTo(HaveOccurred())
err := client.HImportSet(ctx, "gone:2", "gone", "v").Err()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no such fieldset"))
// Hashes created through the fieldset survive the discard.
Expect(client.HGet(ctx, "gone:1", "f").Val()).To(Equal("v"))
})
It("discards all fieldsets", func() {
Expect(client.HImportPrepare(ctx, "all1", "f").Err()).NotTo(HaveOccurred())
Expect(client.HImportPrepare(ctx, "all2", "g").Err()).NotTo(HaveOccurred())
count, err := client.HImportDiscardAll(ctx).Result()
Expect(err).NotTo(HaveOccurred())
Expect(count).To(BeNumerically(">=", 2))
err = client.HImportSet(ctx, "all:1", "all1", "v").Err()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no such fieldset"))
})
It("propagates server errors unchanged", func() {
err := client.HImportSet(ctx, "nofs:1", "never-prepared", "v").Err()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no such fieldset"))
err = client.HImportPrepare(ctx, "dup", "f", "f").Err()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("duplicate field name"))
Expect(client.Set(ctx, "notahash", "s", 0).Err()).NotTo(HaveOccurred())
Expect(client.HImportPrepare(ctx, "wrongtype", "f").Err()).NotTo(HaveOccurred())
err = client.HImportSet(ctx, "notahash", "wrongtype", "v").Err()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("WRONGTYPE"))
})
It("counts value/field mismatches server-side", func() {
Expect(client.HImportPrepare(ctx, "counted", "a", "b").Err()).NotTo(HaveOccurred())
err := client.HImportSet(ctx, "counted:1", "counted", "only-one").Err()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("value count"))
})
It("works on a dedicated connection", func() {
conn := client.Conn()
defer conn.Close()
Expect(conn.HImportPrepare(ctx, "dedicated", "f").Err()).NotTo(HaveOccurred())
Expect(conn.HImportSet(ctx, "dedicated:1", "dedicated", "v").Err()).NotTo(HaveOccurred())
count, err := conn.HImportDiscard(ctx, "dedicated").Result()
Expect(err).NotTo(HaveOccurred())
Expect(count).To(Equal(int64(1)))
})
It("prepares and sets within one pipeline", func() {
pipe := client.Pipeline()
prepare := pipe.HImportPrepare(ctx, "piped", "f1", "f2")
set1 := pipe.HImportSet(ctx, "piped:1", "piped", "a", "b")
set2 := pipe.HImportSet(ctx, "piped:2", "piped", "c", "d")
_, err := pipe.Exec(ctx)
Expect(err).NotTo(HaveOccurred())
Expect(prepare.Val()).To(Equal("OK"))
Expect(set1.Val()).To(Equal("OK"))
Expect(set2.Val()).To(Equal("OK"))
Expect(client.HGet(ctx, "piped:2", "f2").Val()).To(Equal("d"))
})
It("replays a registered fieldset into a pipeline", func() {
Expect(client.HImportPrepare(ctx, "pipedreg", "f").Err()).NotTo(HaveOccurred())
pipe := client.Pipeline()
var sets []*redis.StatusCmd
for i := 0; i < 10; i++ {
sets = append(sets, pipe.HImportSet(ctx, fmt.Sprintf("pipedreg:%d", i), "pipedreg", "v"))
}
_, err := pipe.Exec(ctx)
Expect(err).NotTo(HaveOccurred())
for _, set := range sets {
Expect(set.Val()).To(Equal("OK"))
}
})
It("replays a registered fieldset into a transaction", func() {
Expect(client.HImportPrepare(ctx, "txfs", "f").Err()).NotTo(HaveOccurred())
tx := client.TxPipeline()
set1 := tx.HImportSet(ctx, "txfs:1", "txfs", "a")
set2 := tx.HImportSet(ctx, "txfs:2", "txfs", "b")
_, err := tx.Exec(ctx)
Expect(err).NotTo(HaveOccurred())
Expect(set1.Val()).To(Equal("OK"))
Expect(set2.Val()).To(Equal("OK"))
Expect(client.HGet(ctx, "txfs:1", "f").Val()).To(Equal("a"))
Expect(client.HGet(ctx, "txfs:2", "f").Val()).To(Equal("b"))
})
})