mirror of
https://github.com/redis/go-redis.git
synced 2026-10-22 19:49:00 +03: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>
220 lines
7.9 KiB
Go
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"))
|
|
})
|
|
})
|