From 64661a688b204c5fde0fb3121811aed3a18b23e3 Mon Sep 17 00:00:00 2001 From: Terry Cai Date: Mon, 28 Sep 2026 15:33:53 +0800 Subject: [PATCH] Mark outgoing DoH sockets for policy routing Set a Linux fwmark (SO_MARK) on the sockets used for DoH queries, including HTTP/3, so policy routing rules can steer them - e.g. to send DoH through a VPN client instead of the default route. Setting SO_MARK requires CAP_NET_ADMIN and only applies on Linux; it is a no-op elsewhere, and is not applied when a SOCKS proxy is configured, since that replaces the dialer and leaves no socket of ours to mark. The mark is not a setting. It is read from Redis on the dial path, at most every 5 seconds, through a fixed key holding the name of the key that holds the mark: whoever owns the route publishes it under its own stable key and points the indirection at it. A mark that changes while the proxy runs - a VPN client restarting with a different routing table id - is therefore picked up without a restart, and nothing about the route ever reaches the configuration file, so selecting a client, switching between clients or turning one off does not rewrite it either. This is how the unbound build on the same device already reads its own mark, down to the key indirection, the refresh interval and the choice it makes when the mark is unavailable: the socket is left unmarked and the query follows the default route rather than failing. A VPN client selection steers both services the same way, and the decision about what should happen when that route is gone stays in one place, with the killSwitch handling above them, instead of being made differently by each service. Having no mark at all is the normal state when no client is selected, so it is told apart from a mark that was asked for and could not be resolved: the first is logged at debug level, the second warns. Co-Authored-By: Claude Opus 5 (1M context) --- dnscrypt-proxy/config_loader.go | 14 + dnscrypt-proxy/example-dnscrypt-proxy.toml | 16 + dnscrypt-proxy/mark_linux.go | 41 + dnscrypt-proxy/mark_linux_test.go | 80 + dnscrypt-proxy/mark_others.go | 12 + dnscrypt-proxy/mark_resolver.go | 208 + dnscrypt-proxy/mark_resolver_test.go | 290 + dnscrypt-proxy/xtransport.go | 10 +- dnscrypt-proxy/xtransport_test.go | 17 + go.mod | 3 + go.sum | 6 + .../github.com/cespare/xxhash/v2/LICENSE.txt | 22 + vendor/github.com/cespare/xxhash/v2/README.md | 74 + .../github.com/cespare/xxhash/v2/testall.sh | 10 + vendor/github.com/cespare/xxhash/v2/xxhash.go | 243 + .../cespare/xxhash/v2/xxhash_amd64.s | 209 + .../cespare/xxhash/v2/xxhash_arm64.s | 183 + .../cespare/xxhash/v2/xxhash_asm.go | 15 + .../cespare/xxhash/v2/xxhash_other.go | 76 + .../cespare/xxhash/v2/xxhash_safe.go | 16 + .../cespare/xxhash/v2/xxhash_unsafe.go | 58 + .../github.com/redis/go-redis/v9/.gitignore | 28 + .../redis/go-redis/v9/.golangci.yml | 36 + .../redis/go-redis/v9/.prettierrc.yml | 4 + vendor/github.com/redis/go-redis/v9/AGENTS.md | 228 + vendor/github.com/redis/go-redis/v9/CLAUDE.md | 19 + .../redis/go-redis/v9/CONTRIBUTING.md | 118 + vendor/github.com/redis/go-redis/v9/LICENSE | 25 + vendor/github.com/redis/go-redis/v9/Makefile | 151 + vendor/github.com/redis/go-redis/v9/README.md | 806 ++ .../redis/go-redis/v9/RELEASE-NOTES.md | 1348 +++ .../github.com/redis/go-redis/v9/RELEASING.md | 146 + .../redis/go-redis/v9/acl_commands.go | 116 + .../github.com/redis/go-redis/v9/adapters.go | 118 + .../redis/go-redis/v9/array_commands.go | 387 + .../github.com/redis/go-redis/v9/auth/auth.go | 79 + .../v9/auth/reauth_credentials_listener.go | 47 + .../redis/go-redis/v9/autopipeline.go | 2609 +++++ .../go-redis/v9/autopipeline_bench_README.md | 128 + .../redis/go-redis/v9/bitmap_commands.go | 197 + .../redis/go-redis/v9/cluster_commands.go | 205 + .../github.com/redis/go-redis/v9/command.go | 9714 +++++++++++++++++ .../go-redis/v9/command_policy_resolver.go | 236 + .../github.com/redis/go-redis/v9/commands.go | 918 ++ .../redis/go-redis/v9/csc_commands.go | 257 + .../redis/go-redis/v9/csc_integration.go | 922 ++ .../github.com/redis/go-redis/v9/csc_stats.go | 36 + .../redis/go-redis/v9/dial_retry_backoff.go | 39 + vendor/github.com/redis/go-redis/v9/doc.go | 4 + .../redis/go-redis/v9/docker-compose.yml | 176 + vendor/github.com/redis/go-redis/v9/error.go | 382 + .../redis/go-redis/v9/generic_commands.go | 398 + .../redis/go-redis/v9/geo_commands.go | 161 + .../redis/go-redis/v9/hash_commands.go | 631 ++ .../github.com/redis/go-redis/v9/himport.go | 473 + .../redis/go-redis/v9/himport_cluster.go | 206 + .../redis/go-redis/v9/himport_commands.go | 253 + .../redis/go-redis/v9/hotkeys_commands.go | 122 + .../redis/go-redis/v9/hyperloglog_commands.go | 42 + .../redis/go-redis/v9/internal/arg.go | 58 + .../conn_reauth_credentials_listener.go | 100 + .../internal/auth/streaming/cred_listeners.go | 77 + .../v9/internal/auth/streaming/manager.go | 137 + .../v9/internal/auth/streaming/pool_hook.go | 241 + .../go-redis/v9/internal/hashtag/hashtag.go | 89 + .../v9/internal/hashtag/rendezvous.go | 54 + .../redis/go-redis/v9/internal/hscan/hscan.go | 207 + .../go-redis/v9/internal/hscan/structmap.go | 127 + .../v9/internal/interfaces/interfaces.go | 59 + .../redis/go-redis/v9/internal/internal.go | 28 + .../redis/go-redis/v9/internal/log.go | 79 + .../maintnotifications/logs/log_messages.go | 663 ++ .../redis/go-redis/v9/internal/once.go | 63 + .../go-redis/v9/internal/otel/metrics.go | 321 + .../redis/go-redis/v9/internal/pool/conn.go | 1235 +++ .../go-redis/v9/internal/pool/conn_check.go | 123 + .../v9/internal/pool/conn_check_dummy.go | 34 + .../go-redis/v9/internal/pool/conn_state.go | 336 + .../redis/go-redis/v9/internal/pool/hooks.go | 165 + .../redis/go-redis/v9/internal/pool/pool.go | 2031 ++++ .../go-redis/v9/internal/pool/pool_single.go | 104 + .../go-redis/v9/internal/pool/pool_sticky.go | 236 + .../redis/go-redis/v9/internal/pool/pubsub.go | 109 + .../go-redis/v9/internal/pool/want_conn.go | 115 + .../go-redis/v9/internal/proto/reader.go | 1064 ++ .../v9/internal/proto/redis_errors.go | 539 + .../redis/go-redis/v9/internal/proto/scan.go | 185 + .../go-redis/v9/internal/proto/writer.go | 242 + .../redis/go-redis/v9/internal/redis.go | 3 + .../v9/internal/routing/aggregator.go | 1000 ++ .../go-redis/v9/internal/routing/policy.go | 144 + .../v9/internal/routing/shard_picker.go | 57 + .../redis/go-redis/v9/internal/semaphore.go | 187 + .../redis/go-redis/v9/internal/util.go | 137 + .../go-redis/v9/internal/util/atomic_max.go | 97 + .../go-redis/v9/internal/util/atomic_min.go | 96 + .../go-redis/v9/internal/util/convert.go | 41 + .../redis/go-redis/v9/internal/util/safe.go | 11 + .../go-redis/v9/internal/util/strconv.go | 19 + .../redis/go-redis/v9/internal/util/type.go | 5 + .../redis/go-redis/v9/internal/util/unsafe.go | 17 + .../github.com/redis/go-redis/v9/iterator.go | 74 + vendor/github.com/redis/go-redis/v9/json.go | 698 ++ .../redis/go-redis/v9/list_commands.go | 362 + .../redis/go-redis/v9/local_cache.go | 774 ++ .../v9/maintnotifications/FEATURES.md | 235 + .../go-redis/v9/maintnotifications/README.md | 73 + .../v9/maintnotifications/circuit_breaker.go | 353 + .../go-redis/v9/maintnotifications/config.go | 502 + .../go-redis/v9/maintnotifications/errors.go | 76 + .../v9/maintnotifications/example_hooks.go | 101 + .../v9/maintnotifications/handoff_worker.go | 525 + .../go-redis/v9/maintnotifications/hooks.go | 60 + .../go-redis/v9/maintnotifications/manager.go | 553 + .../v9/maintnotifications/pool_hook.go | 193 + .../push_notification_handler.go | 531 + .../go-redis/v9/maintnotifications/state.go | 24 + .../github.com/redis/go-redis/v9/options.go | 999 ++ .../redis/go-redis/v9/osscluster.go | 3072 ++++++ .../redis/go-redis/v9/osscluster_commands.go | 109 + .../redis/go-redis/v9/osscluster_router.go | 1008 ++ vendor/github.com/redis/go-redis/v9/otel.go | 235 + .../github.com/redis/go-redis/v9/pipeline.go | 136 + .../redis/go-redis/v9/probabilistic.go | 1499 +++ vendor/github.com/redis/go-redis/v9/pubsub.go | 856 ++ .../redis/go-redis/v9/pubsub_commands.go | 96 + .../redis/go-redis/v9/push/errors.go | 176 + .../redis/go-redis/v9/push/handler.go | 14 + .../redis/go-redis/v9/push/handler_context.go | 44 + .../redis/go-redis/v9/push/processor.go | 269 + .../github.com/redis/go-redis/v9/push/push.go | 7 + .../redis/go-redis/v9/push/registry.go | 61 + .../redis/go-redis/v9/push_notifications.go | 21 + vendor/github.com/redis/go-redis/v9/redis.go | 2659 +++++ vendor/github.com/redis/go-redis/v9/result.go | 196 + vendor/github.com/redis/go-redis/v9/ring.go | 1028 ++ vendor/github.com/redis/go-redis/v9/script.go | 221 + .../redis/go-redis/v9/scripting_commands.go | 228 + .../redis/go-redis/v9/search_builders.go | 883 ++ .../redis/go-redis/v9/search_collect.go | 235 + .../redis/go-redis/v9/search_commands.go | 4038 +++++++ .../github.com/redis/go-redis/v9/sentinel.go | 1338 +++ .../redis/go-redis/v9/set_commands.go | 420 + .../redis/go-redis/v9/sortedset_commands.go | 813 ++ .../redis/go-redis/v9/stream_commands.go | 748 ++ .../redis/go-redis/v9/string_commands.go | 927 ++ .../redis/go-redis/v9/timeseries_commands.go | 1547 +++ vendor/github.com/redis/go-redis/v9/tx.go | 208 + .../github.com/redis/go-redis/v9/universal.go | 436 + .../redis/go-redis/v9/vectorset_commands.go | 480 + .../github.com/redis/go-redis/v9/version.go | 6 + vendor/go.uber.org/atomic/.codecov.yml | 19 + vendor/go.uber.org/atomic/.gitignore | 15 + vendor/go.uber.org/atomic/CHANGELOG.md | 127 + vendor/go.uber.org/atomic/LICENSE.txt | 19 + vendor/go.uber.org/atomic/Makefile | 79 + vendor/go.uber.org/atomic/README.md | 63 + vendor/go.uber.org/atomic/bool.go | 88 + vendor/go.uber.org/atomic/bool_ext.go | 53 + vendor/go.uber.org/atomic/doc.go | 23 + vendor/go.uber.org/atomic/duration.go | 89 + vendor/go.uber.org/atomic/duration_ext.go | 40 + vendor/go.uber.org/atomic/error.go | 72 + vendor/go.uber.org/atomic/error_ext.go | 39 + vendor/go.uber.org/atomic/float32.go | 77 + vendor/go.uber.org/atomic/float32_ext.go | 76 + vendor/go.uber.org/atomic/float64.go | 77 + vendor/go.uber.org/atomic/float64_ext.go | 76 + vendor/go.uber.org/atomic/gen.go | 27 + vendor/go.uber.org/atomic/int32.go | 109 + vendor/go.uber.org/atomic/int64.go | 109 + vendor/go.uber.org/atomic/nocmp.go | 35 + vendor/go.uber.org/atomic/pointer_go118.go | 31 + .../atomic/pointer_go118_pre119.go | 60 + vendor/go.uber.org/atomic/pointer_go119.go | 61 + vendor/go.uber.org/atomic/string.go | 72 + vendor/go.uber.org/atomic/string_ext.go | 54 + vendor/go.uber.org/atomic/time.go | 55 + vendor/go.uber.org/atomic/time_ext.go | 36 + vendor/go.uber.org/atomic/uint32.go | 109 + vendor/go.uber.org/atomic/uint64.go | 109 + vendor/go.uber.org/atomic/uintptr.go | 109 + vendor/go.uber.org/atomic/unsafe_pointer.go | 65 + vendor/go.uber.org/atomic/value.go | 31 + vendor/modules.txt | 23 + 185 files changed, 66276 insertions(+), 1 deletion(-) create mode 100644 dnscrypt-proxy/mark_linux.go create mode 100644 dnscrypt-proxy/mark_linux_test.go create mode 100644 dnscrypt-proxy/mark_others.go create mode 100644 dnscrypt-proxy/mark_resolver.go create mode 100644 dnscrypt-proxy/mark_resolver_test.go create mode 100644 dnscrypt-proxy/xtransport_test.go create mode 100644 vendor/github.com/cespare/xxhash/v2/LICENSE.txt create mode 100644 vendor/github.com/cespare/xxhash/v2/README.md create mode 100644 vendor/github.com/cespare/xxhash/v2/testall.sh create mode 100644 vendor/github.com/cespare/xxhash/v2/xxhash.go create mode 100644 vendor/github.com/cespare/xxhash/v2/xxhash_amd64.s create mode 100644 vendor/github.com/cespare/xxhash/v2/xxhash_arm64.s create mode 100644 vendor/github.com/cespare/xxhash/v2/xxhash_asm.go create mode 100644 vendor/github.com/cespare/xxhash/v2/xxhash_other.go create mode 100644 vendor/github.com/cespare/xxhash/v2/xxhash_safe.go create mode 100644 vendor/github.com/cespare/xxhash/v2/xxhash_unsafe.go create mode 100644 vendor/github.com/redis/go-redis/v9/.gitignore create mode 100644 vendor/github.com/redis/go-redis/v9/.golangci.yml create mode 100644 vendor/github.com/redis/go-redis/v9/.prettierrc.yml create mode 100644 vendor/github.com/redis/go-redis/v9/AGENTS.md create mode 100644 vendor/github.com/redis/go-redis/v9/CLAUDE.md create mode 100644 vendor/github.com/redis/go-redis/v9/CONTRIBUTING.md create mode 100644 vendor/github.com/redis/go-redis/v9/LICENSE create mode 100644 vendor/github.com/redis/go-redis/v9/Makefile create mode 100644 vendor/github.com/redis/go-redis/v9/README.md create mode 100644 vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md create mode 100644 vendor/github.com/redis/go-redis/v9/RELEASING.md create mode 100644 vendor/github.com/redis/go-redis/v9/acl_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/adapters.go create mode 100644 vendor/github.com/redis/go-redis/v9/array_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/auth/auth.go create mode 100644 vendor/github.com/redis/go-redis/v9/auth/reauth_credentials_listener.go create mode 100644 vendor/github.com/redis/go-redis/v9/autopipeline.go create mode 100644 vendor/github.com/redis/go-redis/v9/autopipeline_bench_README.md create mode 100644 vendor/github.com/redis/go-redis/v9/bitmap_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/cluster_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/command.go create mode 100644 vendor/github.com/redis/go-redis/v9/command_policy_resolver.go create mode 100644 vendor/github.com/redis/go-redis/v9/commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/csc_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/csc_integration.go create mode 100644 vendor/github.com/redis/go-redis/v9/csc_stats.go create mode 100644 vendor/github.com/redis/go-redis/v9/dial_retry_backoff.go create mode 100644 vendor/github.com/redis/go-redis/v9/doc.go create mode 100644 vendor/github.com/redis/go-redis/v9/docker-compose.yml create mode 100644 vendor/github.com/redis/go-redis/v9/error.go create mode 100644 vendor/github.com/redis/go-redis/v9/generic_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/geo_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/hash_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/himport.go create mode 100644 vendor/github.com/redis/go-redis/v9/himport_cluster.go create mode 100644 vendor/github.com/redis/go-redis/v9/himport_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/hotkeys_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/hyperloglog_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/arg.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/auth/streaming/conn_reauth_credentials_listener.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/auth/streaming/cred_listeners.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/auth/streaming/manager.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/auth/streaming/pool_hook.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/hashtag/hashtag.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/hashtag/rendezvous.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/hscan/hscan.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/hscan/structmap.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/interfaces/interfaces.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/internal.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/log.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/maintnotifications/logs/log_messages.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/once.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/otel/metrics.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/pool/conn.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/pool/conn_check.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/pool/conn_check_dummy.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/pool/conn_state.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/pool/hooks.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/pool/pool.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/pool/pool_single.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/pool/pool_sticky.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/pool/pubsub.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/pool/want_conn.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/proto/reader.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/proto/redis_errors.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/proto/scan.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/proto/writer.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/redis.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/routing/aggregator.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/routing/policy.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/routing/shard_picker.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/semaphore.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/util.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/util/atomic_max.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/util/atomic_min.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/util/convert.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/util/safe.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/util/strconv.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/util/type.go create mode 100644 vendor/github.com/redis/go-redis/v9/internal/util/unsafe.go create mode 100644 vendor/github.com/redis/go-redis/v9/iterator.go create mode 100644 vendor/github.com/redis/go-redis/v9/json.go create mode 100644 vendor/github.com/redis/go-redis/v9/list_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/local_cache.go create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/FEATURES.md create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/README.md create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/circuit_breaker.go create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/config.go create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/errors.go create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/example_hooks.go create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/handoff_worker.go create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/hooks.go create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/manager.go create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/pool_hook.go create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/push_notification_handler.go create mode 100644 vendor/github.com/redis/go-redis/v9/maintnotifications/state.go create mode 100644 vendor/github.com/redis/go-redis/v9/options.go create mode 100644 vendor/github.com/redis/go-redis/v9/osscluster.go create mode 100644 vendor/github.com/redis/go-redis/v9/osscluster_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/osscluster_router.go create mode 100644 vendor/github.com/redis/go-redis/v9/otel.go create mode 100644 vendor/github.com/redis/go-redis/v9/pipeline.go create mode 100644 vendor/github.com/redis/go-redis/v9/probabilistic.go create mode 100644 vendor/github.com/redis/go-redis/v9/pubsub.go create mode 100644 vendor/github.com/redis/go-redis/v9/pubsub_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/push/errors.go create mode 100644 vendor/github.com/redis/go-redis/v9/push/handler.go create mode 100644 vendor/github.com/redis/go-redis/v9/push/handler_context.go create mode 100644 vendor/github.com/redis/go-redis/v9/push/processor.go create mode 100644 vendor/github.com/redis/go-redis/v9/push/push.go create mode 100644 vendor/github.com/redis/go-redis/v9/push/registry.go create mode 100644 vendor/github.com/redis/go-redis/v9/push_notifications.go create mode 100644 vendor/github.com/redis/go-redis/v9/redis.go create mode 100644 vendor/github.com/redis/go-redis/v9/result.go create mode 100644 vendor/github.com/redis/go-redis/v9/ring.go create mode 100644 vendor/github.com/redis/go-redis/v9/script.go create mode 100644 vendor/github.com/redis/go-redis/v9/scripting_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/search_builders.go create mode 100644 vendor/github.com/redis/go-redis/v9/search_collect.go create mode 100644 vendor/github.com/redis/go-redis/v9/search_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/sentinel.go create mode 100644 vendor/github.com/redis/go-redis/v9/set_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/sortedset_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/stream_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/string_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/timeseries_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/tx.go create mode 100644 vendor/github.com/redis/go-redis/v9/universal.go create mode 100644 vendor/github.com/redis/go-redis/v9/vectorset_commands.go create mode 100644 vendor/github.com/redis/go-redis/v9/version.go create mode 100644 vendor/go.uber.org/atomic/.codecov.yml create mode 100644 vendor/go.uber.org/atomic/.gitignore create mode 100644 vendor/go.uber.org/atomic/CHANGELOG.md create mode 100644 vendor/go.uber.org/atomic/LICENSE.txt create mode 100644 vendor/go.uber.org/atomic/Makefile create mode 100644 vendor/go.uber.org/atomic/README.md create mode 100644 vendor/go.uber.org/atomic/bool.go create mode 100644 vendor/go.uber.org/atomic/bool_ext.go create mode 100644 vendor/go.uber.org/atomic/doc.go create mode 100644 vendor/go.uber.org/atomic/duration.go create mode 100644 vendor/go.uber.org/atomic/duration_ext.go create mode 100644 vendor/go.uber.org/atomic/error.go create mode 100644 vendor/go.uber.org/atomic/error_ext.go create mode 100644 vendor/go.uber.org/atomic/float32.go create mode 100644 vendor/go.uber.org/atomic/float32_ext.go create mode 100644 vendor/go.uber.org/atomic/float64.go create mode 100644 vendor/go.uber.org/atomic/float64_ext.go create mode 100644 vendor/go.uber.org/atomic/gen.go create mode 100644 vendor/go.uber.org/atomic/int32.go create mode 100644 vendor/go.uber.org/atomic/int64.go create mode 100644 vendor/go.uber.org/atomic/nocmp.go create mode 100644 vendor/go.uber.org/atomic/pointer_go118.go create mode 100644 vendor/go.uber.org/atomic/pointer_go118_pre119.go create mode 100644 vendor/go.uber.org/atomic/pointer_go119.go create mode 100644 vendor/go.uber.org/atomic/string.go create mode 100644 vendor/go.uber.org/atomic/string_ext.go create mode 100644 vendor/go.uber.org/atomic/time.go create mode 100644 vendor/go.uber.org/atomic/time_ext.go create mode 100644 vendor/go.uber.org/atomic/uint32.go create mode 100644 vendor/go.uber.org/atomic/uint64.go create mode 100644 vendor/go.uber.org/atomic/uintptr.go create mode 100644 vendor/go.uber.org/atomic/unsafe_pointer.go create mode 100644 vendor/go.uber.org/atomic/value.go diff --git a/dnscrypt-proxy/config_loader.go b/dnscrypt-proxy/config_loader.go index 3493a0ece0..7cd7a4507f 100644 --- a/dnscrypt-proxy/config_loader.go +++ b/dnscrypt-proxy/config_loader.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "os" + "runtime" "strconv" "strings" "time" @@ -95,6 +96,11 @@ func configureXTransport(proxy *Proxy, config *Config) error { proxy.xTransport.useIPv4 = config.SourceIPv4 proxy.xTransport.useIPv6 = config.SourceIPv6 proxy.xTransport.keepAlive = time.Duration(config.KeepAlive) * time.Second + // SO_MARK-based outgoing policy routing is Linux-only; elsewhere the + // resolver is never consulted, so it is not worth building one. + if runtime.GOOS == "linux" { + proxy.xTransport.markResolver = newFWMarkResolver() + } // Configure HTTP proxy URL if specified if len(config.HTTPProxyURL) > 0 { @@ -129,6 +135,14 @@ func configureXTransport(proxy *Proxy, config *Config) error { } proxy.xTransport.proxyDialer = &proxyDialer proxy.xTransport.mainProto = "tcp" + if proxy.xTransport.markResolver != nil { + // A SOCKS proxy replaces the dialer entirely, so no socket of ours + // is left to mark. Clear it so that every dial path is covered by + // this one check rather than each having to repeat it. + dlog.Warn("fwmark is ignored while a SOCKS proxy is configured") + _ = proxy.xTransport.markResolver.Close() + proxy.xTransport.markResolver = nil + } } proxy.xTransport.rebuildTransport() diff --git a/dnscrypt-proxy/example-dnscrypt-proxy.toml b/dnscrypt-proxy/example-dnscrypt-proxy.toml index 8d53a79551..44dd0a8030 100644 --- a/dnscrypt-proxy/example-dnscrypt-proxy.toml +++ b/dnscrypt-proxy/example-dnscrypt-proxy.toml @@ -141,6 +141,22 @@ http3_probe = false # http_proxy = 'http://127.0.0.1:8888' +## Outgoing fwmark (Linux only, no setting) +## The sockets used for DoH queries (including HTTP/3) carry a mark (SO_MARK) +## read from Redis on 127.0.0.1:6379: the key `dnscrypt:markkey` holds the +## name of the key holding the numeric mark. Policy routing rules can then +## steer those queries - e.g. send DoH through a VPN client instead of the +## default route - and a mark that changes while the proxy runs is picked up +## within 5 seconds, without a restart and without touching this file. +## The mark has to fit in 31 bits. Setting SO_MARK requires the +## CAP_NET_ADMIN capability. When no mark can be +## read the sockets are left unmarked and the queries follow the default +## route. Ignored when `proxy` is set. +## Only DoH queries are marked: DNSCrypt-protocol servers, anonymized DNS +## relays, forwarding rules and bootstrap/netprobe lookups keep using the +## default route. + + ## How long a DNS query will wait for a response, in milliseconds. ## If you have a network with *a lot* of latency, you may need to ## increase this. Startup may be slower if you do so. diff --git a/dnscrypt-proxy/mark_linux.go b/dnscrypt-proxy/mark_linux.go new file mode 100644 index 0000000000..da323da560 --- /dev/null +++ b/dnscrypt-proxy/mark_linux.go @@ -0,0 +1,41 @@ +package main + +import ( + "syscall" + + "github.com/jedisct1/dlog" + "golang.org/x/sys/unix" +) + +// dialerControlForMark returns a net.Dialer/net.ListenConfig Control +// callback that sets SO_MARK on the underlying socket, so that outgoing +// packets can be steered by policy routing (e.g. to send DoH queries out via +// a specific VPN client). Setting SO_MARK requires CAP_NET_ADMIN. +// +// The mark is resolved per dial rather than captured here, so that a mark +// which changes while the proxy runs takes effect on the next connection. +// +// A mark that cannot be resolved, or cannot be set, never fails the dial: the +// socket is left unmarked and the query follows the default route. This is +// what the unbound build on the same device does, so a VPN client selection +// steers both services the same way and fails over the same way. +func dialerControlForMark(resolver *fwmarkResolver) func(network, address string, c syscall.RawConn) error { + return func(_, _ string, c syscall.RawConn) error { + mark := resolver.fwmark() + if mark == 0 { + return nil + } + var sockErr error + if err := c.Control(func(fd uintptr) { + // The mark is bounded to 31 bits when it is read, so it is + // representable as an int on every platform this builds for. + sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, int(mark)) + }); err != nil { + return err + } + if sockErr != nil { + dlog.Warnf("Unable to set fwmark %d on socket, CAP_NET_ADMIN is required: %v", mark, sockErr) + } + return nil + } +} diff --git a/dnscrypt-proxy/mark_linux_test.go b/dnscrypt-proxy/mark_linux_test.go new file mode 100644 index 0000000000..3aff8b673f --- /dev/null +++ b/dnscrypt-proxy/mark_linux_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "net" + "os" + "testing" + + "golang.org/x/sys/unix" +) + +func TestDialerControlForMarkSetsSockopt(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("setting SO_MARK requires CAP_NET_ADMIN") + } + const mark = 2048 + + conn, err := net.ListenPacket("udp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("unable to open a socket: %v", err) + } + defer conn.Close() + rawConn, err := conn.(*net.UDPConn).SyscallConn() + if err != nil { + t.Fatalf("unable to get the raw connection: %v", err) + } + + if err := dialerControlForMark(resolverFor(mark))("udp4", "127.0.0.1:0", rawConn); err != nil { + t.Fatalf("the Control callback failed: %v", err) + } + + var ( + readMark int + readErr error + ) + if err := rawConn.Control(func(fd uintptr) { + readMark, readErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK) + }); err != nil { + t.Fatalf("unable to read back the socket option: %v", err) + } + if readErr != nil { + t.Fatalf("unable to read back SO_MARK: %v", readErr) + } + if readMark != mark { + t.Fatalf("expected SO_MARK to be %d, got %d", mark, readMark) + } +} + +func TestDialerControlForMarkLeavesTheSocketUnmarkedWhenUnresolvable(t *testing.T) { + conn, err := net.ListenPacket("udp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("unable to open a socket: %v", err) + } + defer conn.Close() + rawConn, err := conn.(*net.UDPConn).SyscallConn() + if err != nil { + t.Fatalf("unable to get the raw connection: %v", err) + } + + // Matching unbound, an unresolvable mark leaves the socket unmarked and + // lets the query follow the default route instead of failing the dial. + if err := dialerControlForMark(unresolvableFWMark())("udp4", "127.0.0.1:0", rawConn); err != nil { + t.Fatalf("expected the dial to proceed unmarked, got: %v", err) + } + + var ( + readMark int + readErr error + ) + if err := rawConn.Control(func(fd uintptr) { + readMark, readErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK) + }); err != nil { + t.Fatalf("unable to read back the socket option: %v", err) + } + if readErr != nil { + t.Fatalf("unable to read back SO_MARK: %v", readErr) + } + if readMark != 0 { + t.Fatalf("expected the socket to be left unmarked, got %d", readMark) + } +} diff --git a/dnscrypt-proxy/mark_others.go b/dnscrypt-proxy/mark_others.go new file mode 100644 index 0000000000..e2d41a7b8a --- /dev/null +++ b/dnscrypt-proxy/mark_others.go @@ -0,0 +1,12 @@ +//go:build !linux + +package main + +import "syscall" + +// dialerControlForMark: SO_MARK-based outgoing policy routing is Linux-only. +// A nil Control callback means the dialer/listener behaves exactly as it did +// before fwmark was configured. +func dialerControlForMark(*fwmarkResolver) func(network, address string, c syscall.RawConn) error { + return nil +} diff --git a/dnscrypt-proxy/mark_resolver.go b/dnscrypt-proxy/mark_resolver.go new file mode 100644 index 0000000000..71952e86b8 --- /dev/null +++ b/dnscrypt-proxy/mark_resolver.go @@ -0,0 +1,208 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/jedisct1/dlog" + "github.com/redis/go-redis/v9" +) + +const ( + // How long a resolved mark is reused before it is looked up again. Kept in + // step with the interval the unbound build on the same device uses, so both + // resolvers notice a VPN client coming back up at the same rate. + fwmarkRefreshInterval = 5 * time.Second + // A lookup runs on the dial path, so it must not stall a query for long. + fwmarkLookupTimeout = 500 * time.Millisecond + fwmarkRedisAddr = "127.0.0.1:6379" + // The key holding the name of the key that holds the mark. Fixed rather + // than configurable, the way the unbound build on the same device fixes + // its own: the config file then says nothing about the VPN client at all, + // so selecting one, switching between them or turning one off never + // rewrites the config and never restarts the proxy. + fwmarkRedisKey = "dnscrypt:markkey" +) + +// errNoFWMarkKey reports that it could not be established that a mark was asked +// for - either nothing requested one, or the request itself was unreadable. In +// neither case is anything known to be wrong, and having no mark is the normal +// state wherever no VPN client is selected, so it is not worth a warning. +// A mark that was definitely requested and could not be resolved is. +var errNoFWMarkKey = errors.New("no outgoing fwmark is configured") + +// A keyReader reads a single string value by key, reporting an unset key as an +// empty value rather than an error. Backed by Redis at run time. +type keyReader interface { + get(ctx context.Context, key string) (string, error) + Close() error +} + +type redisKeyReader struct { + client *redis.Client +} + +// redisLogger routes the client's own diagnostics into dlog. Left alone it +// writes them straight to stderr, bypassing the proxy's logging: on a host +// with no Redis at all that is a steady trickle of connection failures on a +// path which is meant to stay quiet. +type redisLogger struct{} + +func (redisLogger) Printf(_ context.Context, format string, v ...interface{}) { + dlog.Debugf(format, v...) +} + +var redisLoggerOnce sync.Once + +func newRedisKeyReader(addr string) *redisKeyReader { + redisLoggerOnce.Do(func() { redis.SetLogger(redisLogger{}) }) + return &redisKeyReader{ + client: redis.NewClient(&redis.Options{ + Addr: addr, + DialTimeout: fwmarkLookupTimeout, + ReadTimeout: fwmarkLookupTimeout, + WriteTimeout: fwmarkLookupTimeout, + // Retrying only spends the lookup budget over and over when there + // is nothing listening, and the next dial will try again anyway. + MaxRetries: -1, + }), + } +} + +func (r *redisKeyReader) get(ctx context.Context, key string) (string, error) { + value, err := r.client.Get(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return "", nil + } + return value, err +} + +func (r *redisKeyReader) Close() error { return r.client.Close() } + +// A fwmarkResolver yields the mark to set on outgoing DoH sockets, read at dial +// time rather than at startup so that a VPN client which is restarted (and +// comes back with a different routing table id) is picked up without +// restarting the proxy. +// +// The lookup is deliberately indirect, matching what the device's unbound build +// does: the configured key holds the *name* of the key that holds the mark. The +// owner of the route publishes the mark under its own stable key and only has +// to point the indirection at it once. +// +// A mark that cannot be resolved is not an error: the socket is left unmarked +// and the query follows the default route, which is how the unbound build on +// the same device behaves for the same setting. Both services therefore make +// the same choice between the VPN client and the WAN, and what happens when +// the route is unavailable is left to the killSwitch handling above them. +type fwmarkResolver struct { + reader keyReader + key string + // now is time.Now except in tests. + now func() time.Time + + mu sync.RWMutex + cached uint32 + fetched time.Time +} + +func newFWMarkResolverWithReader(reader keyReader, key string) *fwmarkResolver { + return &fwmarkResolver{reader: reader, key: key, now: time.Now} +} + +func newFWMarkResolver() *fwmarkResolver { + return newFWMarkResolverWithReader(newRedisKeyReader(fwmarkRedisAddr), fwmarkRedisKey) +} + +func (r *fwmarkResolver) fresh() bool { + return !r.fetched.IsZero() && r.now().Sub(r.fetched) < fwmarkRefreshInterval +} + +// fwmark returns the mark to set on outgoing sockets, or 0 when none could be +// resolved, in which case the socket is left unmarked. +func (r *fwmarkResolver) fwmark() uint32 { + r.mu.RLock() + if r.fresh() { + mark := r.cached + r.mu.RUnlock() + return mark + } + r.mu.RUnlock() + + r.mu.Lock() + defer r.mu.Unlock() + // Another dial may have refreshed while this one waited for the lock. + if r.fresh() { + return r.cached + } + // A failure is cached like a success: an unreachable Redis would otherwise + // be hit once per dial, and the mark is unknown either way. + mark, err := r.lookup() + if err != nil { + mark = 0 + } + // Only report a change, so that a mark which stays unresolvable does not + // log once per refresh for as long as the proxy runs. + if first := r.fetched.IsZero(); first || mark != r.cached { + switch { + case errors.Is(err, errNoFWMarkKey): + // The steady state whenever no VPN client is selected. + dlog.Debugf("DoH queries are not marked: %v", err) + case mark == 0: + dlog.Warnf("No fwmark for DoH queries, they will follow the default route: %v", err) + case first: + dlog.Noticef("DoH queries are marked %d", mark) + default: + dlog.Noticef("The fwmark for DoH queries changed from %d to %d", r.cached, mark) + } + } + r.cached = mark + r.fetched = r.now() + return mark +} + +func (r *fwmarkResolver) lookup() (uint32, error) { + ctx, cancel := context.WithTimeout(context.Background(), fwmarkLookupTimeout) + defer cancel() + + markKey, err := r.reader.get(ctx, r.key) + if err != nil { + // Whether a mark was even asked for is now unknown, so this is not + // reported as a problem: a host with no Redis simply marks nothing. + return 0, fmt.Errorf("unable to read [%s]: %w: %w", r.key, err, errNoFWMarkKey) + } + if len(markKey) == 0 { + return 0, fmt.Errorf("[%s] is not set: %w", r.key, errNoFWMarkKey) + } + + value, err := r.reader.get(ctx, markKey) + if err != nil { + return 0, fmt.Errorf("unable to read the fwmark from [%s]: %w", markKey, err) + } + if len(value) == 0 { + return 0, fmt.Errorf("fwmark key [%s] points at [%s], which is not set", r.key, markKey) + } + + // Parsed as 31 bits, not the 32 the kernel's u32 would allow: the mark ends + // up in unix.SetsockoptInt, whose argument is a signed int, so a mark with + // the high bit set would only arrive intact by relying on that package + // truncating it back to an int32. Route marks are routing table ids and do + // not come near the limit - the ones this reads are id<<10 for id < 64 - + // so the range that is given up is one nothing publishes. + mark, err := strconv.ParseUint(value, 10, 31) + if err != nil { + return 0, fmt.Errorf("[%s] does not hold a usable fwmark: %w", markKey, err) + } + if mark == 0 { + // Mark 0 is what an unmarked socket already carries, so it cannot + // select a routing rule; treating it as valid would silently send the + // query over the default route. + return 0, fmt.Errorf("[%s] holds 0, which is not a usable fwmark", markKey) + } + return uint32(mark), nil +} + +func (r *fwmarkResolver) Close() error { return r.reader.Close() } diff --git a/dnscrypt-proxy/mark_resolver_test.go b/dnscrypt-proxy/mark_resolver_test.go new file mode 100644 index 0000000000..fe21a1c21b --- /dev/null +++ b/dnscrypt-proxy/mark_resolver_test.go @@ -0,0 +1,290 @@ +package main + +import ( + "context" + "errors" + "strconv" + "sync" + "testing" + "time" +) + +// fakeKeyReader stands in for Redis, counting reads so that the refresh +// throttle can be observed. +type fakeKeyReader struct { + mu sync.Mutex + values map[string]string + err error + reads int + closed bool +} + +func newFakeKeyReader(values map[string]string) *fakeKeyReader { + if values == nil { + values = map[string]string{} + } + return &fakeKeyReader{values: values} +} + +func (f *fakeKeyReader) get(_ context.Context, key string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.reads++ + if f.err != nil { + return "", f.err + } + return f.values[key], nil +} + +func (f *fakeKeyReader) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + f.closed = true + return nil +} + +func (f *fakeKeyReader) set(key, value string) { + f.mu.Lock() + defer f.mu.Unlock() + f.values[key] = value +} + +func (f *fakeKeyReader) fail(err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.err = err +} + +func (f *fakeKeyReader) readCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.reads +} + +// newTestFWMark builds a resolver whose clock the test drives. +func newTestFWMark(reader keyReader, key string, clock *time.Time) *fwmarkResolver { + resolver := newFWMarkResolverWithReader(reader, key) + resolver.now = func() time.Time { return *clock } + return resolver +} + +// resolverFor yields a resolver that resolves to mark. +func resolverFor(mark uint32) *fwmarkResolver { + return newFWMarkResolverWithReader(newFakeKeyReader(map[string]string{ + fwmarkRedisKey: "fwmark:vpn:profile1", + "fwmark:vpn:profile1": strconv.FormatUint(uint64(mark), 10), + }), fwmarkRedisKey) +} + +// unresolvableFWMark yields a resolver whose mark cannot be resolved, the way +// the real one behaves when the VPN client that owns it is not up. +func unresolvableFWMark() *fwmarkResolver { + return newFWMarkResolverWithReader(newFakeKeyReader(nil), fwmarkRedisKey) +} + +func TestFWMarkResolverFollowsTheIndirection(t *testing.T) { + reader := newFakeKeyReader(map[string]string{ + fwmarkRedisKey: "fwmark:vpn:profile1", + "fwmark:vpn:profile1": "4098", + }) + clock := time.Now() + resolver := newTestFWMark(reader, fwmarkRedisKey, &clock) + + if mark := resolver.fwmark(); mark != 4098 { + t.Fatalf("expected mark 4098, got %d", mark) + } +} + +func TestFWMarkResolverCachesWithinTheRefreshInterval(t *testing.T) { + reader := newFakeKeyReader(map[string]string{ + fwmarkRedisKey: "fwmark:vpn:profile1", + "fwmark:vpn:profile1": "4098", + }) + clock := time.Now() + resolver := newTestFWMark(reader, fwmarkRedisKey, &clock) + + resolver.fwmark() + readsAfterFirst := reader.readCount() + + // A new routing table id, not yet visible: the cached mark is still fresh. + reader.set("fwmark:vpn:profile1", "4099") + clock = clock.Add(fwmarkRefreshInterval - time.Millisecond) + mark := resolver.fwmark() + if mark != 4098 { + t.Fatalf("expected the cached mark 4098, got %d", mark) + } + if reader.readCount() != readsAfterFirst { + t.Fatalf("expected no further reads while the mark is fresh, got %d", reader.readCount()-readsAfterFirst) + } + + // Past the interval the new id is picked up without a restart. + clock = clock.Add(2 * time.Millisecond) + if mark = resolver.fwmark(); mark != 4099 { + t.Fatalf("expected the refreshed mark 4099, got %d", mark) + } +} + +func TestFWMarkResolverFallsBackToTheDefaultRoute(t *testing.T) { + for _, tt := range []struct { + name string + values map[string]string + err error + }{ + {name: "indirection unset", values: map[string]string{}}, + { + name: "target unset", + values: map[string]string{fwmarkRedisKey: "fwmark:vpn:profile1"}, + }, + { + name: "target not a number", + values: map[string]string{ + fwmarkRedisKey: "fwmark:vpn:profile1", + "fwmark:vpn:profile1": "not-a-mark", + }, + }, + { + name: "target is zero", + values: map[string]string{ + fwmarkRedisKey: "fwmark:vpn:profile1", + "fwmark:vpn:profile1": "0", + }, + }, + {name: "backend unreachable", values: map[string]string{}, err: errors.New("connection refused")}, + } { + t.Run(tt.name, func(t *testing.T) { + reader := newFakeKeyReader(tt.values) + if tt.err != nil { + reader.fail(tt.err) + } + clock := time.Now() + resolver := newTestFWMark(reader, fwmarkRedisKey, &clock) + + // Matching unbound: an unusable mark leaves the socket + // unmarked rather than failing the query. + if mark := resolver.fwmark(); mark != 0 { + t.Fatalf("expected no mark, got %d", mark) + } + }) + } +} + +func TestFWMarkResolverCachesFailures(t *testing.T) { + reader := newFakeKeyReader(map[string]string{}) + reader.fail(errors.New("connection refused")) + clock := time.Now() + resolver := newTestFWMark(reader, fwmarkRedisKey, &clock) + + if mark := resolver.fwmark(); mark != 0 { + t.Fatalf("expected no mark, got %d", mark) + } + reads := reader.readCount() + if mark := resolver.fwmark(); mark != 0 { + t.Fatalf("expected no mark, got %d", mark) + } + if reader.readCount() != reads { + t.Fatalf("expected the failure to be cached, got %d extra reads", reader.readCount()-reads) + } +} + +func TestFWMarkResolverIsConcurrencySafe(t *testing.T) { + reader := newFakeKeyReader(map[string]string{ + fwmarkRedisKey: "fwmark:vpn:profile1", + "fwmark:vpn:profile1": "4098", + }) + resolver := newFWMarkResolverWithReader(reader, fwmarkRedisKey) + + var wg sync.WaitGroup + for i := 0; i < 64; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if mark := resolver.fwmark(); mark != 4098 { + t.Errorf("concurrent lookup returned %d", mark) + } + }() + } + wg.Wait() + + // Every dial racing at startup must not turn into one backend read each. + if reads := reader.readCount(); reads > 2 { + t.Fatalf("expected the refresh to be shared, got %d reads", reads) + } +} + +func TestFWMarkResolverCloseClosesTheReader(t *testing.T) { + reader := newFakeKeyReader(map[string]string{}) + if err := newFWMarkResolverWithReader(reader, fwmarkRedisKey).Close(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reader.closed { + t.Fatal("expected the reader to be closed") + } +} + +func TestFWMarkResolverDistinguishesUnconfiguredFromUnavailable(t *testing.T) { + // No mark requested at all: the steady state when no VPN client is + // selected, and not worth warning about. + resolver := newFWMarkResolverWithReader(newFakeKeyReader(nil), fwmarkRedisKey) + if _, err := resolver.lookup(); !errors.Is(err, errNoFWMarkKey) { + t.Fatalf("expected errNoFWMarkKey when the indirection is unset, got: %v", err) + } + + // The backend is unreachable, so whether a mark was requested cannot be + // established either. That is the shape of a host with no Redis at all - + // the CI suite runs one - and it has to stay as quiet as the case above. + unreachable := newFakeKeyReader(nil) + unreachable.fail(errors.New("connection refused")) + resolver = newFWMarkResolverWithReader(unreachable, fwmarkRedisKey) + if _, err := resolver.lookup(); !errors.Is(err, errNoFWMarkKey) { + t.Fatalf("expected errNoFWMarkKey when the backend is unreachable, got: %v", err) + } + + // A mark was requested but the client that owns it has published none. + // That is a real problem and has to stay distinguishable. + resolver = newFWMarkResolverWithReader(newFakeKeyReader(map[string]string{ + fwmarkRedisKey: "fwmark:vpn:profile1", + }), fwmarkRedisKey) + _, err := resolver.lookup() + if err == nil { + t.Fatal("expected an error when the mark itself is unset") + } + if errors.Is(err, errNoFWMarkKey) { + t.Fatalf("an unavailable mark must not look unconfigured, got: %v", err) + } +} + +func TestFWMarkResolverAcceptsTheUsableMarkRange(t *testing.T) { + // Route marks are routing table ids, far below the limit; the top of the + // range is here to pin where the 31-bit bound actually falls. + for _, mark := range []uint32{1, 1024, 4098, 64512, 0x40000000, 1<<31 - 1} { + reader := newFakeKeyReader(map[string]string{ + fwmarkRedisKey: "fwmark:vpn:profile1", + "fwmark:vpn:profile1": strconv.FormatUint(uint64(mark), 10), + }) + clock := time.Now() + if got := newTestFWMark(reader, fwmarkRedisKey, &clock).fwmark(); got != mark { + t.Fatalf("expected mark %d, got %d", mark, got) + } + // The mark is handed to SetsockoptInt as an int, so it has to be + // representable there without changing value. + if back := uint32(int(mark)); back != mark { + t.Fatalf("mark %d does not survive the conversion to int, got %d", mark, back) + } + } +} + +func TestFWMarkResolverRejectsUnusableMarks(t *testing.T) { + // Above 31 bits a mark could not be passed to SetsockoptInt without + // relying on it wrapping through a negative int, so it is refused rather + // than sent as something else. + for _, value := range []string{"2147483648", "4294967296", "-1", "1e5", " 4098"} { + reader := newFakeKeyReader(map[string]string{ + fwmarkRedisKey: "fwmark:vpn:profile1", + "fwmark:vpn:profile1": value, + }) + clock := time.Now() + if got := newTestFWMark(reader, fwmarkRedisKey, &clock).fwmark(); got != 0 { + t.Fatalf("expected [%s] to be rejected, got mark %d", value, got) + } + } +} diff --git a/dnscrypt-proxy/xtransport.go b/dnscrypt-proxy/xtransport.go index 6907769910..8e31d834bd 100644 --- a/dnscrypt-proxy/xtransport.go +++ b/dnscrypt-proxy/xtransport.go @@ -101,6 +101,7 @@ type XTransport struct { httpProxyFunction func(*http.Request) (*url.URL, error) tlsClientCreds DOHClientCreds keyLogWriter io.Writer + markResolver *fwmarkResolver } func NewXTransport() *XTransport { @@ -324,6 +325,9 @@ func (xTransport *XTransport) rebuildTransport() { dial := func(address string) (net.Conn, error) { if xTransport.proxyDialer == nil { dialer := &net.Dialer{Timeout: timeout, KeepAlive: xTransport.keepAlive, DualStack: true} + if xTransport.markResolver != nil { + dialer.Control = dialerControlForMark(xTransport.markResolver) + } return dialer.DialContext(ctx, network, address) } return (*xTransport.proxyDialer).Dial(network, address) @@ -479,7 +483,11 @@ func (xTransport *XTransport) rebuildTransport() { } continue } - udpConn, err := net.ListenUDP(target.network, nil) + lc := net.ListenConfig{} + if xTransport.markResolver != nil { + lc.Control = dialerControlForMark(xTransport.markResolver) + } + udpConn, err := lc.ListenPacket(ctx, target.network, ":0") if err != nil { lastErr = err if idx < len(targets)-1 { diff --git a/dnscrypt-proxy/xtransport_test.go b/dnscrypt-proxy/xtransport_test.go new file mode 100644 index 0000000000..1d0b327fc2 --- /dev/null +++ b/dnscrypt-proxy/xtransport_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "runtime" + "testing" +) + +func TestDialerControlForMark(t *testing.T) { + control := dialerControlForMark(newFWMarkResolverWithReader(newFakeKeyReader(nil), "dnscrypt:markkey")) + if runtime.GOOS == "linux" { + if control == nil { + t.Fatal("expected a Control callback for fwmark on linux") + } + } else if control != nil { + t.Fatal("expected no Control callback for fwmark outside linux") + } +} diff --git a/go.mod b/go.mod index b99a5379da..e23213da2c 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/miekg/dns v1.1.72 github.com/powerman/check v1.13.0 github.com/quic-go/quic-go v0.60.0 + github.com/redis/go-redis/v9 v9.22.0 golang.org/x/crypto v0.54.0 golang.org/x/net v0.57.0 golang.org/x/sys v0.47.0 @@ -34,6 +35,7 @@ require ( ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/go-syslog v1.0.0 // indirect @@ -41,6 +43,7 @@ require ( github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.6.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/mock v0.6.0 // indirect golang.org/x/mod v0.38.0 // indirect golang.org/x/sync v0.22.0 // indirect diff --git a/go.sum b/go.sum index bcbc0e4e70..351e877f23 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= @@ -64,8 +66,12 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= diff --git a/vendor/github.com/cespare/xxhash/v2/LICENSE.txt b/vendor/github.com/cespare/xxhash/v2/LICENSE.txt new file mode 100644 index 0000000000..24b53065f4 --- /dev/null +++ b/vendor/github.com/cespare/xxhash/v2/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2016 Caleb Spare + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/cespare/xxhash/v2/README.md b/vendor/github.com/cespare/xxhash/v2/README.md new file mode 100644 index 0000000000..33c88305c4 --- /dev/null +++ b/vendor/github.com/cespare/xxhash/v2/README.md @@ -0,0 +1,74 @@ +# xxhash + +[![Go Reference](https://pkg.go.dev/badge/github.com/cespare/xxhash/v2.svg)](https://pkg.go.dev/github.com/cespare/xxhash/v2) +[![Test](https://github.com/cespare/xxhash/actions/workflows/test.yml/badge.svg)](https://github.com/cespare/xxhash/actions/workflows/test.yml) + +xxhash is a Go implementation of the 64-bit [xxHash] algorithm, XXH64. This is a +high-quality hashing algorithm that is much faster than anything in the Go +standard library. + +This package provides a straightforward API: + +``` +func Sum64(b []byte) uint64 +func Sum64String(s string) uint64 +type Digest struct{ ... } + func New() *Digest +``` + +The `Digest` type implements hash.Hash64. Its key methods are: + +``` +func (*Digest) Write([]byte) (int, error) +func (*Digest) WriteString(string) (int, error) +func (*Digest) Sum64() uint64 +``` + +The package is written with optimized pure Go and also contains even faster +assembly implementations for amd64 and arm64. If desired, the `purego` build tag +opts into using the Go code even on those architectures. + +[xxHash]: http://cyan4973.github.io/xxHash/ + +## Compatibility + +This package is in a module and the latest code is in version 2 of the module. +You need a version of Go with at least "minimal module compatibility" to use +github.com/cespare/xxhash/v2: + +* 1.9.7+ for Go 1.9 +* 1.10.3+ for Go 1.10 +* Go 1.11 or later + +I recommend using the latest release of Go. + +## Benchmarks + +Here are some quick benchmarks comparing the pure-Go and assembly +implementations of Sum64. + +| input size | purego | asm | +| ---------- | --------- | --------- | +| 4 B | 1.3 GB/s | 1.2 GB/s | +| 16 B | 2.9 GB/s | 3.5 GB/s | +| 100 B | 6.9 GB/s | 8.1 GB/s | +| 4 KB | 11.7 GB/s | 16.7 GB/s | +| 10 MB | 12.0 GB/s | 17.3 GB/s | + +These numbers were generated on Ubuntu 20.04 with an Intel Xeon Platinum 8252C +CPU using the following commands under Go 1.19.2: + +``` +benchstat <(go test -tags purego -benchtime 500ms -count 15 -bench 'Sum64$') +benchstat <(go test -benchtime 500ms -count 15 -bench 'Sum64$') +``` + +## Projects using this package + +- [InfluxDB](https://github.com/influxdata/influxdb) +- [Prometheus](https://github.com/prometheus/prometheus) +- [VictoriaMetrics](https://github.com/VictoriaMetrics/VictoriaMetrics) +- [FreeCache](https://github.com/coocood/freecache) +- [FastCache](https://github.com/VictoriaMetrics/fastcache) +- [Ristretto](https://github.com/dgraph-io/ristretto) +- [Badger](https://github.com/dgraph-io/badger) diff --git a/vendor/github.com/cespare/xxhash/v2/testall.sh b/vendor/github.com/cespare/xxhash/v2/testall.sh new file mode 100644 index 0000000000..94b9c44398 --- /dev/null +++ b/vendor/github.com/cespare/xxhash/v2/testall.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -eu -o pipefail + +# Small convenience script for running the tests with various combinations of +# arch/tags. This assumes we're running on amd64 and have qemu available. + +go test ./... +go test -tags purego ./... +GOARCH=arm64 go test +GOARCH=arm64 go test -tags purego diff --git a/vendor/github.com/cespare/xxhash/v2/xxhash.go b/vendor/github.com/cespare/xxhash/v2/xxhash.go new file mode 100644 index 0000000000..78bddf1cee --- /dev/null +++ b/vendor/github.com/cespare/xxhash/v2/xxhash.go @@ -0,0 +1,243 @@ +// Package xxhash implements the 64-bit variant of xxHash (XXH64) as described +// at http://cyan4973.github.io/xxHash/. +package xxhash + +import ( + "encoding/binary" + "errors" + "math/bits" +) + +const ( + prime1 uint64 = 11400714785074694791 + prime2 uint64 = 14029467366897019727 + prime3 uint64 = 1609587929392839161 + prime4 uint64 = 9650029242287828579 + prime5 uint64 = 2870177450012600261 +) + +// Store the primes in an array as well. +// +// The consts are used when possible in Go code to avoid MOVs but we need a +// contiguous array for the assembly code. +var primes = [...]uint64{prime1, prime2, prime3, prime4, prime5} + +// Digest implements hash.Hash64. +// +// Note that a zero-valued Digest is not ready to receive writes. +// Call Reset or create a Digest using New before calling other methods. +type Digest struct { + v1 uint64 + v2 uint64 + v3 uint64 + v4 uint64 + total uint64 + mem [32]byte + n int // how much of mem is used +} + +// New creates a new Digest with a zero seed. +func New() *Digest { + return NewWithSeed(0) +} + +// NewWithSeed creates a new Digest with the given seed. +func NewWithSeed(seed uint64) *Digest { + var d Digest + d.ResetWithSeed(seed) + return &d +} + +// Reset clears the Digest's state so that it can be reused. +// It uses a seed value of zero. +func (d *Digest) Reset() { + d.ResetWithSeed(0) +} + +// ResetWithSeed clears the Digest's state so that it can be reused. +// It uses the given seed to initialize the state. +func (d *Digest) ResetWithSeed(seed uint64) { + d.v1 = seed + prime1 + prime2 + d.v2 = seed + prime2 + d.v3 = seed + d.v4 = seed - prime1 + d.total = 0 + d.n = 0 +} + +// Size always returns 8 bytes. +func (d *Digest) Size() int { return 8 } + +// BlockSize always returns 32 bytes. +func (d *Digest) BlockSize() int { return 32 } + +// Write adds more data to d. It always returns len(b), nil. +func (d *Digest) Write(b []byte) (n int, err error) { + n = len(b) + d.total += uint64(n) + + memleft := d.mem[d.n&(len(d.mem)-1):] + + if d.n+n < 32 { + // This new data doesn't even fill the current block. + copy(memleft, b) + d.n += n + return + } + + if d.n > 0 { + // Finish off the partial block. + c := copy(memleft, b) + d.v1 = round(d.v1, u64(d.mem[0:8])) + d.v2 = round(d.v2, u64(d.mem[8:16])) + d.v3 = round(d.v3, u64(d.mem[16:24])) + d.v4 = round(d.v4, u64(d.mem[24:32])) + b = b[c:] + d.n = 0 + } + + if len(b) >= 32 { + // One or more full blocks left. + nw := writeBlocks(d, b) + b = b[nw:] + } + + // Store any remaining partial block. + copy(d.mem[:], b) + d.n = len(b) + + return +} + +// Sum appends the current hash to b and returns the resulting slice. +func (d *Digest) Sum(b []byte) []byte { + s := d.Sum64() + return append( + b, + byte(s>>56), + byte(s>>48), + byte(s>>40), + byte(s>>32), + byte(s>>24), + byte(s>>16), + byte(s>>8), + byte(s), + ) +} + +// Sum64 returns the current hash. +func (d *Digest) Sum64() uint64 { + var h uint64 + + if d.total >= 32 { + v1, v2, v3, v4 := d.v1, d.v2, d.v3, d.v4 + h = rol1(v1) + rol7(v2) + rol12(v3) + rol18(v4) + h = mergeRound(h, v1) + h = mergeRound(h, v2) + h = mergeRound(h, v3) + h = mergeRound(h, v4) + } else { + h = d.v3 + prime5 + } + + h += d.total + + b := d.mem[:d.n&(len(d.mem)-1)] + for ; len(b) >= 8; b = b[8:] { + k1 := round(0, u64(b[:8])) + h ^= k1 + h = rol27(h)*prime1 + prime4 + } + if len(b) >= 4 { + h ^= uint64(u32(b[:4])) * prime1 + h = rol23(h)*prime2 + prime3 + b = b[4:] + } + for ; len(b) > 0; b = b[1:] { + h ^= uint64(b[0]) * prime5 + h = rol11(h) * prime1 + } + + h ^= h >> 33 + h *= prime2 + h ^= h >> 29 + h *= prime3 + h ^= h >> 32 + + return h +} + +const ( + magic = "xxh\x06" + marshaledSize = len(magic) + 8*5 + 32 +) + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (d *Digest) MarshalBinary() ([]byte, error) { + b := make([]byte, 0, marshaledSize) + b = append(b, magic...) + b = appendUint64(b, d.v1) + b = appendUint64(b, d.v2) + b = appendUint64(b, d.v3) + b = appendUint64(b, d.v4) + b = appendUint64(b, d.total) + b = append(b, d.mem[:d.n]...) + b = b[:len(b)+len(d.mem)-d.n] + return b, nil +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (d *Digest) UnmarshalBinary(b []byte) error { + if len(b) < len(magic) || string(b[:len(magic)]) != magic { + return errors.New("xxhash: invalid hash state identifier") + } + if len(b) != marshaledSize { + return errors.New("xxhash: invalid hash state size") + } + b = b[len(magic):] + b, d.v1 = consumeUint64(b) + b, d.v2 = consumeUint64(b) + b, d.v3 = consumeUint64(b) + b, d.v4 = consumeUint64(b) + b, d.total = consumeUint64(b) + copy(d.mem[:], b) + d.n = int(d.total % uint64(len(d.mem))) + return nil +} + +func appendUint64(b []byte, x uint64) []byte { + var a [8]byte + binary.LittleEndian.PutUint64(a[:], x) + return append(b, a[:]...) +} + +func consumeUint64(b []byte) ([]byte, uint64) { + x := u64(b) + return b[8:], x +} + +func u64(b []byte) uint64 { return binary.LittleEndian.Uint64(b) } +func u32(b []byte) uint32 { return binary.LittleEndian.Uint32(b) } + +func round(acc, input uint64) uint64 { + acc += input * prime2 + acc = rol31(acc) + acc *= prime1 + return acc +} + +func mergeRound(acc, val uint64) uint64 { + val = round(0, val) + acc ^= val + acc = acc*prime1 + prime4 + return acc +} + +func rol1(x uint64) uint64 { return bits.RotateLeft64(x, 1) } +func rol7(x uint64) uint64 { return bits.RotateLeft64(x, 7) } +func rol11(x uint64) uint64 { return bits.RotateLeft64(x, 11) } +func rol12(x uint64) uint64 { return bits.RotateLeft64(x, 12) } +func rol18(x uint64) uint64 { return bits.RotateLeft64(x, 18) } +func rol23(x uint64) uint64 { return bits.RotateLeft64(x, 23) } +func rol27(x uint64) uint64 { return bits.RotateLeft64(x, 27) } +func rol31(x uint64) uint64 { return bits.RotateLeft64(x, 31) } diff --git a/vendor/github.com/cespare/xxhash/v2/xxhash_amd64.s b/vendor/github.com/cespare/xxhash/v2/xxhash_amd64.s new file mode 100644 index 0000000000..3e8b132579 --- /dev/null +++ b/vendor/github.com/cespare/xxhash/v2/xxhash_amd64.s @@ -0,0 +1,209 @@ +//go:build !appengine && gc && !purego +// +build !appengine +// +build gc +// +build !purego + +#include "textflag.h" + +// Registers: +#define h AX +#define d AX +#define p SI // pointer to advance through b +#define n DX +#define end BX // loop end +#define v1 R8 +#define v2 R9 +#define v3 R10 +#define v4 R11 +#define x R12 +#define prime1 R13 +#define prime2 R14 +#define prime4 DI + +#define round(acc, x) \ + IMULQ prime2, x \ + ADDQ x, acc \ + ROLQ $31, acc \ + IMULQ prime1, acc + +// round0 performs the operation x = round(0, x). +#define round0(x) \ + IMULQ prime2, x \ + ROLQ $31, x \ + IMULQ prime1, x + +// mergeRound applies a merge round on the two registers acc and x. +// It assumes that prime1, prime2, and prime4 have been loaded. +#define mergeRound(acc, x) \ + round0(x) \ + XORQ x, acc \ + IMULQ prime1, acc \ + ADDQ prime4, acc + +// blockLoop processes as many 32-byte blocks as possible, +// updating v1, v2, v3, and v4. It assumes that there is at least one block +// to process. +#define blockLoop() \ +loop: \ + MOVQ +0(p), x \ + round(v1, x) \ + MOVQ +8(p), x \ + round(v2, x) \ + MOVQ +16(p), x \ + round(v3, x) \ + MOVQ +24(p), x \ + round(v4, x) \ + ADDQ $32, p \ + CMPQ p, end \ + JLE loop + +// func Sum64(b []byte) uint64 +TEXT ·Sum64(SB), NOSPLIT|NOFRAME, $0-32 + // Load fixed primes. + MOVQ ·primes+0(SB), prime1 + MOVQ ·primes+8(SB), prime2 + MOVQ ·primes+24(SB), prime4 + + // Load slice. + MOVQ b_base+0(FP), p + MOVQ b_len+8(FP), n + LEAQ (p)(n*1), end + + // The first loop limit will be len(b)-32. + SUBQ $32, end + + // Check whether we have at least one block. + CMPQ n, $32 + JLT noBlocks + + // Set up initial state (v1, v2, v3, v4). + MOVQ prime1, v1 + ADDQ prime2, v1 + MOVQ prime2, v2 + XORQ v3, v3 + XORQ v4, v4 + SUBQ prime1, v4 + + blockLoop() + + MOVQ v1, h + ROLQ $1, h + MOVQ v2, x + ROLQ $7, x + ADDQ x, h + MOVQ v3, x + ROLQ $12, x + ADDQ x, h + MOVQ v4, x + ROLQ $18, x + ADDQ x, h + + mergeRound(h, v1) + mergeRound(h, v2) + mergeRound(h, v3) + mergeRound(h, v4) + + JMP afterBlocks + +noBlocks: + MOVQ ·primes+32(SB), h + +afterBlocks: + ADDQ n, h + + ADDQ $24, end + CMPQ p, end + JG try4 + +loop8: + MOVQ (p), x + ADDQ $8, p + round0(x) + XORQ x, h + ROLQ $27, h + IMULQ prime1, h + ADDQ prime4, h + + CMPQ p, end + JLE loop8 + +try4: + ADDQ $4, end + CMPQ p, end + JG try1 + + MOVL (p), x + ADDQ $4, p + IMULQ prime1, x + XORQ x, h + + ROLQ $23, h + IMULQ prime2, h + ADDQ ·primes+16(SB), h + +try1: + ADDQ $4, end + CMPQ p, end + JGE finalize + +loop1: + MOVBQZX (p), x + ADDQ $1, p + IMULQ ·primes+32(SB), x + XORQ x, h + ROLQ $11, h + IMULQ prime1, h + + CMPQ p, end + JL loop1 + +finalize: + MOVQ h, x + SHRQ $33, x + XORQ x, h + IMULQ prime2, h + MOVQ h, x + SHRQ $29, x + XORQ x, h + IMULQ ·primes+16(SB), h + MOVQ h, x + SHRQ $32, x + XORQ x, h + + MOVQ h, ret+24(FP) + RET + +// func writeBlocks(d *Digest, b []byte) int +TEXT ·writeBlocks(SB), NOSPLIT|NOFRAME, $0-40 + // Load fixed primes needed for round. + MOVQ ·primes+0(SB), prime1 + MOVQ ·primes+8(SB), prime2 + + // Load slice. + MOVQ b_base+8(FP), p + MOVQ b_len+16(FP), n + LEAQ (p)(n*1), end + SUBQ $32, end + + // Load vN from d. + MOVQ s+0(FP), d + MOVQ 0(d), v1 + MOVQ 8(d), v2 + MOVQ 16(d), v3 + MOVQ 24(d), v4 + + // We don't need to check the loop condition here; this function is + // always called with at least one block of data to process. + blockLoop() + + // Copy vN back to d. + MOVQ v1, 0(d) + MOVQ v2, 8(d) + MOVQ v3, 16(d) + MOVQ v4, 24(d) + + // The number of bytes written is p minus the old base pointer. + SUBQ b_base+8(FP), p + MOVQ p, ret+32(FP) + + RET diff --git a/vendor/github.com/cespare/xxhash/v2/xxhash_arm64.s b/vendor/github.com/cespare/xxhash/v2/xxhash_arm64.s new file mode 100644 index 0000000000..7e3145a221 --- /dev/null +++ b/vendor/github.com/cespare/xxhash/v2/xxhash_arm64.s @@ -0,0 +1,183 @@ +//go:build !appengine && gc && !purego +// +build !appengine +// +build gc +// +build !purego + +#include "textflag.h" + +// Registers: +#define digest R1 +#define h R2 // return value +#define p R3 // input pointer +#define n R4 // input length +#define nblocks R5 // n / 32 +#define prime1 R7 +#define prime2 R8 +#define prime3 R9 +#define prime4 R10 +#define prime5 R11 +#define v1 R12 +#define v2 R13 +#define v3 R14 +#define v4 R15 +#define x1 R20 +#define x2 R21 +#define x3 R22 +#define x4 R23 + +#define round(acc, x) \ + MADD prime2, acc, x, acc \ + ROR $64-31, acc \ + MUL prime1, acc + +// round0 performs the operation x = round(0, x). +#define round0(x) \ + MUL prime2, x \ + ROR $64-31, x \ + MUL prime1, x + +#define mergeRound(acc, x) \ + round0(x) \ + EOR x, acc \ + MADD acc, prime4, prime1, acc + +// blockLoop processes as many 32-byte blocks as possible, +// updating v1, v2, v3, and v4. It assumes that n >= 32. +#define blockLoop() \ + LSR $5, n, nblocks \ + PCALIGN $16 \ + loop: \ + LDP.P 16(p), (x1, x2) \ + LDP.P 16(p), (x3, x4) \ + round(v1, x1) \ + round(v2, x2) \ + round(v3, x3) \ + round(v4, x4) \ + SUB $1, nblocks \ + CBNZ nblocks, loop + +// func Sum64(b []byte) uint64 +TEXT ·Sum64(SB), NOSPLIT|NOFRAME, $0-32 + LDP b_base+0(FP), (p, n) + + LDP ·primes+0(SB), (prime1, prime2) + LDP ·primes+16(SB), (prime3, prime4) + MOVD ·primes+32(SB), prime5 + + CMP $32, n + CSEL LT, prime5, ZR, h // if n < 32 { h = prime5 } else { h = 0 } + BLT afterLoop + + ADD prime1, prime2, v1 + MOVD prime2, v2 + MOVD $0, v3 + NEG prime1, v4 + + blockLoop() + + ROR $64-1, v1, x1 + ROR $64-7, v2, x2 + ADD x1, x2 + ROR $64-12, v3, x3 + ROR $64-18, v4, x4 + ADD x3, x4 + ADD x2, x4, h + + mergeRound(h, v1) + mergeRound(h, v2) + mergeRound(h, v3) + mergeRound(h, v4) + +afterLoop: + ADD n, h + + TBZ $4, n, try8 + LDP.P 16(p), (x1, x2) + + round0(x1) + + // NOTE: here and below, sequencing the EOR after the ROR (using a + // rotated register) is worth a small but measurable speedup for small + // inputs. + ROR $64-27, h + EOR x1 @> 64-27, h, h + MADD h, prime4, prime1, h + + round0(x2) + ROR $64-27, h + EOR x2 @> 64-27, h, h + MADD h, prime4, prime1, h + +try8: + TBZ $3, n, try4 + MOVD.P 8(p), x1 + + round0(x1) + ROR $64-27, h + EOR x1 @> 64-27, h, h + MADD h, prime4, prime1, h + +try4: + TBZ $2, n, try2 + MOVWU.P 4(p), x2 + + MUL prime1, x2 + ROR $64-23, h + EOR x2 @> 64-23, h, h + MADD h, prime3, prime2, h + +try2: + TBZ $1, n, try1 + MOVHU.P 2(p), x3 + AND $255, x3, x1 + LSR $8, x3, x2 + + MUL prime5, x1 + ROR $64-11, h + EOR x1 @> 64-11, h, h + MUL prime1, h + + MUL prime5, x2 + ROR $64-11, h + EOR x2 @> 64-11, h, h + MUL prime1, h + +try1: + TBZ $0, n, finalize + MOVBU (p), x4 + + MUL prime5, x4 + ROR $64-11, h + EOR x4 @> 64-11, h, h + MUL prime1, h + +finalize: + EOR h >> 33, h + MUL prime2, h + EOR h >> 29, h + MUL prime3, h + EOR h >> 32, h + + MOVD h, ret+24(FP) + RET + +// func writeBlocks(d *Digest, b []byte) int +TEXT ·writeBlocks(SB), NOSPLIT|NOFRAME, $0-40 + LDP ·primes+0(SB), (prime1, prime2) + + // Load state. Assume v[1-4] are stored contiguously. + MOVD d+0(FP), digest + LDP 0(digest), (v1, v2) + LDP 16(digest), (v3, v4) + + LDP b_base+8(FP), (p, n) + + blockLoop() + + // Store updated state. + STP (v1, v2), 0(digest) + STP (v3, v4), 16(digest) + + BIC $31, n + MOVD n, ret+32(FP) + RET diff --git a/vendor/github.com/cespare/xxhash/v2/xxhash_asm.go b/vendor/github.com/cespare/xxhash/v2/xxhash_asm.go new file mode 100644 index 0000000000..78f95f2561 --- /dev/null +++ b/vendor/github.com/cespare/xxhash/v2/xxhash_asm.go @@ -0,0 +1,15 @@ +//go:build (amd64 || arm64) && !appengine && gc && !purego +// +build amd64 arm64 +// +build !appengine +// +build gc +// +build !purego + +package xxhash + +// Sum64 computes the 64-bit xxHash digest of b with a zero seed. +// +//go:noescape +func Sum64(b []byte) uint64 + +//go:noescape +func writeBlocks(d *Digest, b []byte) int diff --git a/vendor/github.com/cespare/xxhash/v2/xxhash_other.go b/vendor/github.com/cespare/xxhash/v2/xxhash_other.go new file mode 100644 index 0000000000..118e49e819 --- /dev/null +++ b/vendor/github.com/cespare/xxhash/v2/xxhash_other.go @@ -0,0 +1,76 @@ +//go:build (!amd64 && !arm64) || appengine || !gc || purego +// +build !amd64,!arm64 appengine !gc purego + +package xxhash + +// Sum64 computes the 64-bit xxHash digest of b with a zero seed. +func Sum64(b []byte) uint64 { + // A simpler version would be + // d := New() + // d.Write(b) + // return d.Sum64() + // but this is faster, particularly for small inputs. + + n := len(b) + var h uint64 + + if n >= 32 { + v1 := primes[0] + prime2 + v2 := prime2 + v3 := uint64(0) + v4 := -primes[0] + for len(b) >= 32 { + v1 = round(v1, u64(b[0:8:len(b)])) + v2 = round(v2, u64(b[8:16:len(b)])) + v3 = round(v3, u64(b[16:24:len(b)])) + v4 = round(v4, u64(b[24:32:len(b)])) + b = b[32:len(b):len(b)] + } + h = rol1(v1) + rol7(v2) + rol12(v3) + rol18(v4) + h = mergeRound(h, v1) + h = mergeRound(h, v2) + h = mergeRound(h, v3) + h = mergeRound(h, v4) + } else { + h = prime5 + } + + h += uint64(n) + + for ; len(b) >= 8; b = b[8:] { + k1 := round(0, u64(b[:8])) + h ^= k1 + h = rol27(h)*prime1 + prime4 + } + if len(b) >= 4 { + h ^= uint64(u32(b[:4])) * prime1 + h = rol23(h)*prime2 + prime3 + b = b[4:] + } + for ; len(b) > 0; b = b[1:] { + h ^= uint64(b[0]) * prime5 + h = rol11(h) * prime1 + } + + h ^= h >> 33 + h *= prime2 + h ^= h >> 29 + h *= prime3 + h ^= h >> 32 + + return h +} + +func writeBlocks(d *Digest, b []byte) int { + v1, v2, v3, v4 := d.v1, d.v2, d.v3, d.v4 + n := len(b) + for len(b) >= 32 { + v1 = round(v1, u64(b[0:8:len(b)])) + v2 = round(v2, u64(b[8:16:len(b)])) + v3 = round(v3, u64(b[16:24:len(b)])) + v4 = round(v4, u64(b[24:32:len(b)])) + b = b[32:len(b):len(b)] + } + d.v1, d.v2, d.v3, d.v4 = v1, v2, v3, v4 + return n - len(b) +} diff --git a/vendor/github.com/cespare/xxhash/v2/xxhash_safe.go b/vendor/github.com/cespare/xxhash/v2/xxhash_safe.go new file mode 100644 index 0000000000..05f5e7dfe7 --- /dev/null +++ b/vendor/github.com/cespare/xxhash/v2/xxhash_safe.go @@ -0,0 +1,16 @@ +//go:build appengine +// +build appengine + +// This file contains the safe implementations of otherwise unsafe-using code. + +package xxhash + +// Sum64String computes the 64-bit xxHash digest of s with a zero seed. +func Sum64String(s string) uint64 { + return Sum64([]byte(s)) +} + +// WriteString adds more data to d. It always returns len(s), nil. +func (d *Digest) WriteString(s string) (n int, err error) { + return d.Write([]byte(s)) +} diff --git a/vendor/github.com/cespare/xxhash/v2/xxhash_unsafe.go b/vendor/github.com/cespare/xxhash/v2/xxhash_unsafe.go new file mode 100644 index 0000000000..cf9d42aed5 --- /dev/null +++ b/vendor/github.com/cespare/xxhash/v2/xxhash_unsafe.go @@ -0,0 +1,58 @@ +//go:build !appengine +// +build !appengine + +// This file encapsulates usage of unsafe. +// xxhash_safe.go contains the safe implementations. + +package xxhash + +import ( + "unsafe" +) + +// In the future it's possible that compiler optimizations will make these +// XxxString functions unnecessary by realizing that calls such as +// Sum64([]byte(s)) don't need to copy s. See https://go.dev/issue/2205. +// If that happens, even if we keep these functions they can be replaced with +// the trivial safe code. + +// NOTE: The usual way of doing an unsafe string-to-[]byte conversion is: +// +// var b []byte +// bh := (*reflect.SliceHeader)(unsafe.Pointer(&b)) +// bh.Data = (*reflect.StringHeader)(unsafe.Pointer(&s)).Data +// bh.Len = len(s) +// bh.Cap = len(s) +// +// Unfortunately, as of Go 1.15.3 the inliner's cost model assigns a high enough +// weight to this sequence of expressions that any function that uses it will +// not be inlined. Instead, the functions below use a different unsafe +// conversion designed to minimize the inliner weight and allow both to be +// inlined. There is also a test (TestInlining) which verifies that these are +// inlined. +// +// See https://github.com/golang/go/issues/42739 for discussion. + +// Sum64String computes the 64-bit xxHash digest of s with a zero seed. +// It may be faster than Sum64([]byte(s)) by avoiding a copy. +func Sum64String(s string) uint64 { + b := *(*[]byte)(unsafe.Pointer(&sliceHeader{s, len(s)})) + return Sum64(b) +} + +// WriteString adds more data to d. It always returns len(s), nil. +// It may be faster than Write([]byte(s)) by avoiding a copy. +func (d *Digest) WriteString(s string) (n int, err error) { + d.Write(*(*[]byte)(unsafe.Pointer(&sliceHeader{s, len(s)}))) + // d.Write always returns len(s), nil. + // Ignoring the return output and returning these fixed values buys a + // savings of 6 in the inliner's cost model. + return len(s), nil +} + +// sliceHeader is similar to reflect.SliceHeader, but it assumes that the layout +// of the first two words is the same as the layout of a string. +type sliceHeader struct { + s string + cap int +} diff --git a/vendor/github.com/redis/go-redis/v9/.gitignore b/vendor/github.com/redis/go-redis/v9/.gitignore new file mode 100644 index 0000000000..4134a1f458 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/.gitignore @@ -0,0 +1,28 @@ +*.rdb +testdata/* +.idea/ +.DS_Store +*.tar.gz +*.dic +redis8tests.sh +coverage.txt +**/coverage.txt +.vscode +tmp/* +*.test +extra/redisotel-native/metrics-collector-app/ +# maintenanceNotifications upgrade documentation (temporary) +maintenanceNotifications/docs/ + +# Docker-generated files (TLS certificates, cluster data, etc.) +dockers/*/tls/ +dockers/osscluster-tls/ + +# Per-user Claude Code settings (machine/personal overrides, not shared policy) +.claude/settings.local.json + +# example build artifacts +example/autopipeline/autopipeline + +# Claude Code worktrees (ephemeral, local) +.claude/worktrees/ diff --git a/vendor/github.com/redis/go-redis/v9/.golangci.yml b/vendor/github.com/redis/go-redis/v9/.golangci.yml new file mode 100644 index 0000000000..dd13c2c293 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/.golangci.yml @@ -0,0 +1,36 @@ +version: "2" +run: + timeout: 5m + tests: false +linters: + settings: + staticcheck: + checks: + - all + # Incorrect or missing package comment. + # https://staticcheck.dev/docs/checks/#ST1000 + - -ST1000 + # Omit embedded fields from selector expression. + # https://staticcheck.dev/docs/checks/#QF1008 + - -QF1008 + - -ST1003 + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/vendor/github.com/redis/go-redis/v9/.prettierrc.yml b/vendor/github.com/redis/go-redis/v9/.prettierrc.yml new file mode 100644 index 0000000000..8b7f044ad1 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/.prettierrc.yml @@ -0,0 +1,4 @@ +semi: false +singleQuote: true +proseWrap: always +printWidth: 100 diff --git a/vendor/github.com/redis/go-redis/v9/AGENTS.md b/vendor/github.com/redis/go-redis/v9/AGENTS.md new file mode 100644 index 0000000000..ca0a311681 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/AGENTS.md @@ -0,0 +1,228 @@ +# AGENTS.md + +Guidance for AI coding agents (and humans) working in this repository. This is +the shared, tool-agnostic source of truth: Claude Code loads it through +`CLAUDE.md` (which imports this file), and other agents (Codex, Cursor, Aider, +Zed, …) read `AGENTS.md` directly. Edit repository guidance here, not in +`CLAUDE.md`. + +## Repository + +go-redis is the official Redis client for Go. Module path: +`github.com/redis/go-redis/v9` (Go 1.24+). The repo is a multi-module workspace +— every directory containing a `go.mod` is built and tested independently: + +- root (`github.com/redis/go-redis/v9`) — the client library. +- `extra/redisotel`, `extra/redisotel-native`, `extra/redisprometheus`, + `extra/rediscensus`, `extra/rediscmd` — instrumentation adapters with their + own module paths (so they can pin large telemetry deps without forcing them on + root consumers). +- `internal/customvet` — custom `go vet` analyzers (also its own module). +- `maintnotifications/e2e`, `doctests`, `fuzz`, examples under `example/` — + separate modules. + +The Makefile iterates over every `go.mod` (`GO_MOD_DIRS`) when running +`test.ci`, `go_mod_tidy`, etc. When you add a dependency in one module, you +almost never need to update the others. + +## Common commands + +Tests run against a Redis stack started via Docker Compose. Profiles in +`docker-compose.yml` control which services come up (`standalone`, `cluster`, +`sentinel`, `all`, `e2e`). + +```sh +make docker.start # bring up the full test stack (profile: all) +make docker.stop +make test # docker.start -> test.ci -> docker.stop +make test.ci # run tests assuming containers are already up +make test.ci.skip-vectorsets # when REDIS_VERSION < 8 +make bench # go test -bench=. (root module only) +make fmt # gofumpt + goimports -local github.com/redis/go-redis +make build +make go_mod_tidy # go mod tidy across every module +``` + +E2E (maintenance notifications) needs the extra `cae-resp-proxy` service: + +```sh +make test.e2e # starts e2e profile, runs ./maintnotifications/e2e/, tears down +make test.e2e.docker # subset that runs inside docker +make test.e2e.logic # logic-only tests, no proxy required +``` + +Run a single test. The root suite is Ginkgo-based (`bsm/ginkgo` + `bsm/gomega` +forks), so `go test -run` matches the Go-level wrapper and you focus a spec with +the Ginkgo flag: + +```sh +go test -run TestGinkgoSuite . -ginkgo.focus="ZAdd" +go test -run TestGinkgoSuite . -ginkgo.focus="cluster" +``` + +Plain `go test` tests (most files outside the Ginkgo suite, e.g. `internal/...`, +`maintnotifications/...`) work the usual way: + +```sh +go test -run TestConnStateMachine ./internal/pool/... +go test -race -run TestCircuitBreaker ./maintnotifications/... +``` + +Env knobs (passed through the Makefile): + +- `REDIS_VERSION` — e.g. `8.8`. Drives both the test image tag and + `main_test.go` version-gating (`SkipBeforeRedisVersion` / + `SkipAfterRedisVersion`). +- `CLIENT_LIBS_TEST_IMAGE` — full image ref, e.g. + `redislabs/client-libs-test:8.8-m03`. +- `RE_CLUSTER=true` — run against a Redis Enterprise cluster instead of the + docker-compose stack (the suite then skips ring/sentinel/TLS-cluster setup). +- `RCE_DOCKER=true` — Redis CE in docker (default for `make test`). +- `REDIS_PORT` — override the default standalone port (`6380`). + +CI also runs the custom vet tool: +`go vet -vettool ./internal/customvet/customvet ./...`. The `setval` analyzer +requires every `Cmder` with a `Result()` to also have a `SetVal()`. + +## Architecture + +### Client types (root package) + +All clients are in the root package and share most plumbing: + +- `Client` (`redis.go`) — single-node client. +- `ClusterClient` (`osscluster.go`) — Redis Cluster aware. `osscluster_router.go` + routes commands to the right shard; `internal/routing/` handles cluster-wide + aggregation policies (e.g. fan-out for `KEYS`, `DBSIZE`). +- `Ring` (`ring.go`) — client-side sharding across independent Redis nodes + (consistent hashing, no cluster protocol). +- Failover client (`sentinel.go`) — Sentinel-managed failover. +- `UniversalClient` (`universal.go`) — wrapper that picks one of the above based + on options. + +Command surface lives in topical files: `string_commands.go`, `hash_commands.go`, +`stream_commands.go`, `search_commands.go`, `vectorset_commands.go`, etc. Each +file defines methods on the shared `Cmdable` interface so every client type gets +the same API. + +### Hooks (`redis.go` `hooksMixin`) + +Three hook chains run around every operation: `DialHook`, `ProcessHook`, +`ProcessPipelineHook`. Hooks are registered via `client.AddHook(...)` and chain +in FIFO order; each hook must call `next` to continue. When a hook wraps an +error, it must call `cmd.SetErr(wrappedErr)` so the typed-error helpers +(`redis.IsLoadingError`, `IsMovedError`, etc. in `error.go`) keep working through +`errors.As`. The README has a longer pipeline-hook example. + +### Connection pool (`internal/pool`) + +Owns dialing, idle/active connection bookkeeping, conn state (`conn_state.go`), +pubsub-conn lifecycle (`pubsub.go`), and the dial-retry/backoff logic that powers +`DialerRetries` / `DialerRetryBackoff` (also exposed at `dial_retry_backoff.go` +in the root). `OnConnect`, `MinIdleConns`, and the buffer-size options +(`ReadBufferSize`/`WriteBufferSize`, default 32 KiB since v9.12) flow through +here. + +### Protocol (`internal/proto`) + +RESP2/RESP3 reader and writer. Push notifications (RESP3 `>`-prefixed frames) are +peeked here and dispatched via the `push/` package. The `push.Registry` lets +callers register handlers for specific notification names; +`maintnotifications/push_notification_handler.go` is how `maintnotifications` +plugs in. + +### Maintenance notifications (`maintnotifications/`) + +This is a non-trivial subsystem worth understanding before touching +cluster/handoff code. It listens for RESP3 push notifications about cluster +maintenance (`MOVING`, `MIGRATING`, `MIGRATED`, `FAILING_OVER`, `FAILED_OVER` +for standalone; `SMIGRATING`, `SMIGRATED` for cluster) and performs seamless +connection handoff to new endpoints. Key pieces: + +- `manager.go` — coordinates state transitions. +- `handoff_worker.go` — moves in-flight ops to new connections. +- `pool_hook.go` — integrates with `internal/pool` to mark/replace connections. +- `circuit_breaker.go` — backs off when the upstream is unhealthy. +- `state.go` — per-connection state machine. +- E2E coverage lives in `maintnotifications/e2e/` and drives a fault-injector / + RESP proxy (`cae-resp-proxy`). + +Configuration is via `redis.Options.MaintNotificationsConfig`; modes are +`ModeAuto` (default), `ModeEnabled` (require server support), `ModeDisabled`. +RESP3 (`Protocol: 3`) is required. + +### Authentication (`auth/`, `internal/auth/streaming`) + +Four credential sources, in priority order: streaming provider (e.g. Entra ID +via `go-redis-entraid`), context-based provider, function provider, static +`Username`/`Password`. The streaming provider is what enables token rotation +without reconnecting — the listener in `auth/reauth_credentials_listener.go` +issues `AUTH` on each refresh. + +### Internal helpers + +- `internal/hscan` — struct scanning for `HGETALL` results (`Scan` interface + re-exported as `redis.Scanner`). +- `internal/hashtag` — extracts `{tag}` segments for cluster slot routing. +- `internal/routing` — aggregator policies and shard pickers used by + `ClusterClient` for multi-shard commands. +- `internal/otel` — small OpenTelemetry shim used to keep root free of telemetry + deps; full instrumentation lives in `extra/redisotel-native`. + +## Architectural specs + +Read the relevant design doc **before** changing code in that subsystem. They +cover invariants and decisions that aren't obvious from the code, and are plain +markdown any tool or editor can open: + +- `.claude/specs/pool.md` — connection pool: `wantConn` queue and FIFO + discipline, `ConnState` machine, dial retry/backoff, hook integration, the + re-auth/handoff coexistence contract. +- `.claude/specs/cluster-routing.md` — slot computation, MOVED/ASK redirection, + request/response policies, aggregators, replica routing, topology reload, + cross-slot rules. +- `.claude/specs/maintnotifications.md` — RESP3 push notification protocol, mode + handshake, per-conn state, handoff worker pool, circuit breaker, endpoint-type + resolution, cluster vs. standalone differences. + +## Conventions + +- New `Cmder` type → also implement `SetVal` (the custom vet `setval` check + enforces this; `SetErr` is on the embedded `baseCmd`). +- Wrap errors with custom error types that implement `Unwrap`, or use + `fmt.Errorf("...: %w", err)`. Always call `cmd.SetErr(...)` after wrapping so + typed-error checks still pass. +- `gofumpt` + `goimports -local github.com/redis/go-redis` is the formatter + (`make fmt`); CI runs both. +- Don't log directly — use `internal.Logger` (set via `redis.SetLogger`); + `logging.Disable()` is called in tests. +- Version-gate Redis-version-specific tests with `SkipBeforeRedisVersion` / + `SkipAfterRedisVersion` rather than skipping at the suite level. + +### Commits and PRs + +Conventional Commits, short and exact — `(): `. +Subject ≤50 chars (hard cap 72), imperative ("add", not "added"), no trailing +period. Body only when the *why* isn't obvious from the diff; wrap at 72. + +- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` (also + `build`, `ci`, `style`, `revert`). +- Scope = the subsystem touched, lowercase: `pool`, `conn`, `pubsub`, + `sentinel`, `retry`, `command`/`cmd`, `vectorset`, `otel`, `streams`, `push`, + `deps`, `ci`, `tests`, `docs`. Omit only for genuinely cross-cutting changes. +- Breaking change: `feat(scope)!: ...` plus a `BREAKING CHANGE:` body line. + Reference issues/PRs at the end — `Closes #42`, `Refs #17`. +- **No AI-attribution trailer.** Do not add `Co-Authored-By: …`, "Generated with + …", or any AI-attribution line to commits or PR bodies in this repo. + +## Repo-specific tooling + +`.claude/` holds shared AI config: + +- `commands/` — slash commands (e.g. `check-ci`, which summarizes a PR's CI). +- `skills/` — task playbooks: `testing`, `add-command`, `commit-style`, + `update-ci-image`, `prepare-release`. +- `specs/` — the architecture docs listed above. + +For Claude Code, the skills auto-trigger from their descriptions. For other +tools, each `SKILL.md` is plain markdown you can open and follow directly. diff --git a/vendor/github.com/redis/go-redis/v9/CLAUDE.md b/vendor/github.com/redis/go-redis/v9/CLAUDE.md new file mode 100644 index 0000000000..7e9eb08c55 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/CLAUDE.md @@ -0,0 +1,19 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +Repository guidance — layout, commands, architecture, and conventions — is +maintained in [`AGENTS.md`](AGENTS.md) so it stays shared across AI tools. It is +imported below; edit `AGENTS.md`, not this file, for that content. + +@AGENTS.md + +## Claude-specific + +- Repo-local **skills** under `.claude/skills/` auto-trigger from their + descriptions — no need to invoke them manually (the set is listed in + `AGENTS.md`). +- **Slash commands** under `.claude/commands/` (e.g. `/check-ci`) are available + in-session. +- Architectural **specs** under `.claude/specs/` are read on demand; open the + relevant one before changing that subsystem. diff --git a/vendor/github.com/redis/go-redis/v9/CONTRIBUTING.md b/vendor/github.com/redis/go-redis/v9/CONTRIBUTING.md new file mode 100644 index 0000000000..9f175644ab --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/CONTRIBUTING.md @@ -0,0 +1,118 @@ +# Contributing + +## Introduction + +We appreciate your interest in considering contributing to go-redis. +Community contributions mean a lot to us. + +## Contributions we need + +You may already know how you'd like to contribute, whether it's a fix for a bug you +encountered, or a new feature your team wants to use. + +If you don't know where to start, consider improving +documentation, bug triaging, and writing tutorials are all examples of +helpful contributions that mean less work for you. + +## Your First Contribution + +Unsure where to begin contributing? You can start by looking through +[help-wanted +issues](https://github.com/redis/go-redis/issues?q=is%3Aopen+is%3Aissue+label%3ahelp-wanted). + +Never contributed to open source before? Here are a couple of friendly +tutorials: + +- +- + +## Getting Started + +Here's how to get started with your code contribution: + +1. Create your own fork of go-redis +2. Do the changes in your fork +3. If you need a development environment, run `make docker.start`. + +> Note: this clones and builds the docker containers specified in `docker-compose.yml`, to understand more about +> the infrastructure that will be started you can check the `docker-compose.yml`. You also have the possiblity +> to specify the redis image that will be pulled with the env variable `CLIENT_LIBS_TEST_IMAGE`. +> By default the docker image that will be pulled and started is `redislabs/client-libs-test:8.10.0`. +> If you want to test with newer Redis version, using a newer version of `redislabs/client-libs-test` should work out of the box. + +4. While developing, make sure the tests pass by running `make test` (if you have the docker containers running, `make test.ci` may be sufficient). +> Note: `make test` will try to start all containers, run the tests with `make test.ci` and then stop all containers. +5. If you like the change and think the project could use it, send a + pull request + +To see what else is part of the automation, run `invoke -l` + + +## Testing + +### Setting up Docker +To run the tests, you need to have Docker installed and running. If you are using a host OS that does not support +docker host networks out of the box (e.g. Windows, OSX), you need to set up a docker desktop and enable docker host networks. + +### Running tests +Call `make test` to run all tests. + +Continuous Integration uses these same wrappers to run all of these +tests against multiple versions of redis. Feel free to test your +changes against all the go versions supported, as declared by the +[build.yml](./.github/workflows/build.yml) file. + +### Troubleshooting + +If you get any errors when running `make test`, make sure +that you are using supported versions of Docker and go. + +## How to Report a Bug + +### Security Vulnerabilities + +**NOTE**: If you find a security vulnerability, do NOT open an issue. +Email [Redis Open Source ()](mailto:oss@redis.com) instead. + +In order to determine whether you are dealing with a security issue, ask +yourself these two questions: + +- Can I access something that's not mine, or something I shouldn't + have access to? +- Can I disable something for other people? + +If the answer to either of those two questions are *yes*, then you're +probably dealing with a security issue. Note that even if you answer +*no* to both questions, you may still be dealing with a security +issue, so if you're unsure, just email [us](mailto:oss@redis.com). + +### Everything Else + +When filing an issue, make sure to answer these five questions: + +1. What version of go-redis are you using? +2. What version of redis are you using? +3. What did you do? +4. What did you expect to see? +5. What did you see instead? + +## Suggest a feature or enhancement + +If you'd like to contribute a new feature, make sure you check our +issue list to see if someone has already proposed it. Work may already +be underway on the feature you want or we may have rejected a +feature like it already. + +If you don't see anything, open a new issue that describes the feature +you would like and how it should work. + +## Code review process + +The core team regularly looks at pull requests. We will provide +feedback as soon as possible. After receiving our feedback, please respond +within two weeks. After that time, we may close your PR if it isn't +showing any activity. + +## Support + +Maintainers can provide limited support to contributors on discord: https://discord.gg/W4txy5AeKM diff --git a/vendor/github.com/redis/go-redis/v9/LICENSE b/vendor/github.com/redis/go-redis/v9/LICENSE new file mode 100644 index 0000000000..f4967dbc5c --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2013 The github.com/redis/go-redis Authors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/redis/go-redis/v9/Makefile b/vendor/github.com/redis/go-redis/v9/Makefile new file mode 100644 index 0000000000..8881f2d31f --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/Makefile @@ -0,0 +1,151 @@ +GO_MOD_DIRS := $(shell find . -type f -name 'go.mod' -exec dirname {} \; | sort) +REDIS_VERSION ?= 8.10 +RE_CLUSTER ?= false +RCE_DOCKER ?= true +CLIENT_LIBS_TEST_IMAGE ?= redislabs/client-libs-test:8.10.0 + +docker.start: + export RE_CLUSTER=$(RE_CLUSTER) && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ + export CLIENT_LIBS_TEST_IMAGE=$(CLIENT_LIBS_TEST_IMAGE) && \ + docker compose --profile all up -d --quiet-pull + +docker.stop: + docker compose --profile all down + +docker.e2e.start: + @echo "Starting Redis and cae-resp-proxy for E2E tests..." + docker compose --profile e2e up -d --quiet-pull + @echo "Waiting for services to be ready..." + @sleep 3 + @echo "Services ready!" + +docker.e2e.stop: + @echo "Stopping E2E services..." + docker compose --profile e2e down + +test: + $(MAKE) docker.start + @if [ -z "$(REDIS_VERSION)" ]; then \ + echo "REDIS_VERSION not set, running all tests"; \ + $(MAKE) test.ci; \ + else \ + MAJOR_VERSION=$$(echo "$(REDIS_VERSION)" | cut -d. -f1); \ + if [ "$$MAJOR_VERSION" -ge 8 ]; then \ + echo "REDIS_VERSION $(REDIS_VERSION) >= 8, running all tests"; \ + $(MAKE) test.ci; \ + else \ + echo "REDIS_VERSION $(REDIS_VERSION) < 8, skipping vector_sets tests"; \ + $(MAKE) test.ci.skip-vectorsets; \ + fi; \ + fi + $(MAKE) docker.stop + +test.ci: + set -e; for dir in $(GO_MOD_DIRS); do \ + echo "go test in $${dir}"; \ + (cd "$${dir}" && \ + export RE_CLUSTER=$(RE_CLUSTER) && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ + go mod tidy && \ + go vet && \ + go test -v -coverprofile=coverage.txt -covermode=atomic ./... -race -skip Example); \ + done + cd internal/customvet && go build . + go vet -vettool ./internal/customvet/customvet + +test.ci.skip-vectorsets: + set -e; for dir in $(GO_MOD_DIRS); do \ + echo "go test in $${dir} (skipping vector sets)"; \ + (cd "$${dir}" && \ + export RE_CLUSTER=$(RE_CLUSTER) && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ + go mod tidy && \ + go vet && \ + go test -v -coverprofile=coverage.txt -covermode=atomic ./... -race \ + -run '^(?!.*(?:VectorSet|vectorset|ExampleClient_vectorset)).*$$' -skip Example); \ + done + cd internal/customvet && go build . + go vet -vettool ./internal/customvet/customvet + +# Replay the parametrized command integration suites through the AutoPipeliner +# Cmdable faces (blocking + async) to prove the commands behave the same +# batched as on a plain client. Selected via GOREDIS_TEST_SUBJECT (see +# newUniversalSubject in main_test.go). Requires the docker env +# (make docker.start). +# +# The focus covers every parametrized suite (some via the Commands/BitCount +# substrings — Describe names differ in case). Skipped on purpose: DDL Commands (needs the cluster topology the +# lightweight BeforeSuite doesn't build), HotKeys Commands (same), and +# AutoPipeline Blocking Commands (the AP suite itself — running it through an +# AP subject would nest engines). +test.autopipeline-subjects: + # RE_CLUSTER=true forces the lightweight BeforeSuite (no sentinel/ring/cluster + # setup): the command suite only needs the standalone Redis, and running the + # full stateful BeforeSuite twice (once per subject) against the same server + # corrupts replication state and fails the second run. Both faces then run + # cleanly against the same env. The explicit -timeout keeps a hang from + # eating go test's 10m default per subject. + set -e; for subj in ap-blocking ap-async; do \ + echo "=== command suite via GOREDIS_TEST_SUBJECT=$$subj ==="; \ + (export RE_CLUSTER=true && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ + export GOREDIS_TEST_SUBJECT=$$subj && \ + go test -v . -race -skip Example -run TestGinkgoSuite -timeout 8m \ + -ginkgo.focus='Commands|RediSearch commands|Probabilistic commands|RedisTimeseries commands|Redis VectorSet commands|BitCount|ScanIterator|Advanced JSON' \ + -ginkgo.skip='AutoPipeline Blocking Commands|DDL Commands|HotKeys Commands'); \ + done + +bench: + export RE_CLUSTER=$(RE_CLUSTER) && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ + go test ./... -test.run=NONE -test.bench=. -test.benchmem -skip Example -timeout 11m + +test.e2e: + @echo "Running E2E tests with auto-start proxy..." + $(MAKE) docker.e2e.start + @echo "Running tests..." + @E2E_SCENARIO_TESTS=true go test -v ./maintnotifications/e2e/ -timeout 30m || ($(MAKE) docker.e2e.stop && exit 1) + $(MAKE) docker.e2e.stop + @echo "E2E tests completed!" + +test.e2e.docker: + @echo "Running Docker-compatible E2E tests..." + $(MAKE) docker.e2e.start + @echo "Running unified injector tests..." + @E2E_SCENARIO_TESTS=true go test -v -run "TestUnifiedInjector|TestCreateTestFaultInjectorLogic|TestFaultInjectorClientCreation" ./maintnotifications/e2e/ -timeout 10m || ($(MAKE) docker.e2e.stop && exit 1) + $(MAKE) docker.e2e.stop + @echo "Docker E2E tests completed!" + +test.e2e.logic: + @echo "Running E2E logic tests (no proxy required)..." + @E2E_SCENARIO_TESTS=true \ + REDIS_ENDPOINTS_CONFIG_PATH=/tmp/test_endpoints_verify.json \ + FAULT_INJECTION_API_URL=http://localhost:8080 \ + go test -v -run "TestCreateTestFaultInjectorLogic|TestFaultInjectorClientCreation" ./maintnotifications/e2e/ + @echo "Logic tests completed!" + +.PHONY: all test test.ci test.ci.skip-vectorsets test.autopipeline-subjects bench fmt test.e2e test.e2e.logic docker.e2e.start docker.e2e.stop + +build: + export RE_CLUSTER=$(RE_CLUSTER) && \ + export RCE_DOCKER=$(RCE_DOCKER) && \ + export REDIS_VERSION=$(REDIS_VERSION) && \ + go build . + +fmt: + gofumpt -w ./ + goimports -w -local github.com/redis/go-redis ./ + +go_mod_tidy: + set -e; for dir in $(GO_MOD_DIRS); do \ + echo "go mod tidy in $${dir}"; \ + (cd "$${dir}" && \ + go get -u ./... && \ + go mod tidy); \ + done diff --git a/vendor/github.com/redis/go-redis/v9/README.md b/vendor/github.com/redis/go-redis/v9/README.md new file mode 100644 index 0000000000..59a2eee8f7 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/README.md @@ -0,0 +1,806 @@ +# Redis client for Go + +[![build workflow](https://github.com/redis/go-redis/actions/workflows/build.yml/badge.svg)](https://github.com/redis/go-redis/actions) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/redis/go-redis/v9)](https://pkg.go.dev/github.com/redis/go-redis/v9?tab=doc) +[![Documentation](https://img.shields.io/badge/redis-documentation-informational)](https://redis.io/docs/latest/develop/clients/go/) +[![Go Report Card](https://goreportcard.com/badge/github.com/redis/go-redis/v9)](https://goreportcard.com/report/github.com/redis/go-redis/v9) +[![codecov](https://codecov.io/github/redis/go-redis/graph/badge.svg?token=tsrCZKuSSw)](https://codecov.io/github/redis/go-redis) + +[![Discord](https://img.shields.io/discord/697882427875393627.svg?style=social&logo=discord)](https://discord.gg/W4txy5AeKM) +[![Twitch](https://img.shields.io/twitch/status/redisinc?style=social)](https://www.twitch.tv/redisinc) +[![YouTube](https://img.shields.io/youtube/channel/views/UCD78lHSwYqMlyetR0_P4Vig?style=social)](https://www.youtube.com/redisinc) +[![Twitter](https://img.shields.io/twitter/follow/redisinc?style=social)](https://twitter.com/redisinc) +[![Stack Exchange questions](https://img.shields.io/stackexchange/stackoverflow/t/go-redis?style=social&logo=stackoverflow&label=Stackoverflow)](https://stackoverflow.com/questions/tagged/go-redis) + +> go-redis is the official Redis client library for the Go programming language. It offers a straightforward interface for interacting with Redis servers. + +## Supported versions + +In `go-redis` we are aiming to support the last three releases of Redis. Currently, this means we do support: +- [Redis 8.0](https://raw.githubusercontent.com/redis/redis/8.0/00-RELEASENOTES) - using Redis CE 8.0 +- [Redis 8.2](https://raw.githubusercontent.com/redis/redis/8.2/00-RELEASENOTES) - using Redis CE 8.2 +- [Redis 8.4](https://raw.githubusercontent.com/redis/redis/8.4/00-RELEASENOTES) - using Redis CE 8.4 +- [Redis 8.8](https://raw.githubusercontent.com/redis/redis/8.8/00-RELEASENOTES) - using Redis CE 8.8 +- [Redis 8.10](https://raw.githubusercontent.com/redis/redis/8.10/00-RELEASENOTES) - using Redis CE 8.10 + +Although the `go.mod` states it requires at minimum `go 1.24`, our CI is configured to run the tests against all supported +versions of Redis and multiple versions of Go ([1.24](https://go.dev/doc/devel/release#go1.24.0), oldstable, and stable). We observe that some modules related test may not pass with +Redis Stack 7.2 and some commands are changed with Redis CE 8.0. +Although it is not officially supported, `go-redis/v9` should be able to work with any Redis 7.0+. +Please do refer to the documentation and the tests if you experience any issues. + +### Array data type (Redis 8.8+) + +Starting with Redis 8.8, go-redis exposes the new array data type via the `AR*` command family +(`ARSET`, `ARGET`, `ARGETRANGE`, `ARMSET`, `ARMGET`, `ARINSERT`, `ARDEL`, `ARDELRANGE`, +`ARLEN`, `ARCOUNT`, `ARNEXT`, `ARSEEK`, `ARSCAN`, `ARGREP`, `ARRING`, `ARLASTITEMS`, +`ARINFO`/`ARINFOFULL`, and the `AROP*` reducers). See `array_commands.go` for the full +surface. The API is experimental and may change in a future release. + +## How do I Redis? + +[Learn for free at Redis University](https://university.redis.com/) + +[Build faster with the Redis Launchpad](https://launchpad.redis.com/) + +[Try the Redis Cloud](https://redis.com/try-free/) + +[Dive in developer tutorials](https://developer.redis.com/) + +[Join the Redis community](https://redis.com/community/) + +[Work at Redis](https://redis.com/company/careers/jobs/) + + +## Resources + +- [Discussions](https://github.com/redis/go-redis/discussions) +- [Chat](https://discord.gg/W4txy5AeKM) +- [Reference](https://pkg.go.dev/github.com/redis/go-redis/v9) +- [Examples](https://pkg.go.dev/github.com/redis/go-redis/v9#pkg-examples) +- [Release notes](./RELEASE-NOTES.md) ([GitHub Releases](https://github.com/redis/go-redis/releases)) + +## old documentation + +- [English](https://redis.uptrace.dev) +- [简体中文](https://redis.uptrace.dev/zh/) + +## Ecosystem + +- [Entra ID (Azure AD)](https://github.com/redis/go-redis-entraid) +- [Distributed Locks](https://github.com/bsm/redislock) +- [Redis Cache](https://github.com/go-redis/cache) +- [Rate limiting](https://github.com/go-redis/redis_rate) + +## Features + +- Redis commands except QUIT and SYNC. +- Automatic connection pooling. +- [StreamingCredentialsProvider (e.g. entra id, oauth)](#1-streaming-credentials-provider-highest-priority) (experimental) +- [Pub/Sub](https://redis.uptrace.dev/guide/go-redis-pubsub.html). +- [Pipelines and transactions](https://redis.uptrace.dev/guide/go-redis-pipelines.html). +- [Automatic pipelining](#automatic-pipelining) (experimental) — batches concurrent + commands into pipelines for you; meant for high-throughput / high-load / scale + use cases. +- [Scripting](https://redis.uptrace.dev/guide/lua-scripting.html). +- [Redis Sentinel](https://redis.uptrace.dev/guide/go-redis-sentinel.html). +- [Redis Cluster](https://redis.uptrace.dev/guide/go-redis-cluster.html). +- [Client-side caching](#client-side-caching). +- [Redis Performance Monitoring](https://redis.uptrace.dev/guide/redis-performance-monitoring.html). +- [Redis Probabilistic [RedisStack]](https://redis.io/docs/data-types/probabilistic/) +- [Customizable read and write buffers size.](#custom-buffer-sizes) + +## Installation + +go-redis supports 2 last Go versions and requires a Go version with +[modules](https://github.com/golang/go/wiki/Modules) support. So make sure to initialize a Go +module: + +```shell +go mod init github.com/my/repo +``` + +Then install go-redis/**v9**: + +```shell +go get github.com/redis/go-redis/v9 +``` + +## Quickstart + +```go +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" +) + +var ctx = context.Background() + +func ExampleClient() { + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password set + DB: 0, // use default DB + }) + defer rdb.Close() + + err := rdb.Set(ctx, "key", "value", 0).Err() + if err != nil { + panic(err) + } + + val, err := rdb.Get(ctx, "key").Result() + if err != nil { + panic(err) + } + fmt.Println("key", val) + + val2, err := rdb.Get(ctx, "key2").Result() + if err == redis.Nil { + fmt.Println("key2 does not exist") + } else if err != nil { + panic(err) + } else { + fmt.Println("key2", val2) + } + // Output: key value + // key2 does not exist +} +``` + +### Dial retries and backoff + +Connection establishment can be retried by the connection pool when dialing fails. + +- **`DialerRetries`**: maximum number of dial attempts (default: 5). +- **`DialerRetryTimeout`**: default delay between attempts when no custom backoff is provided (default: 100ms). +- **`DialerRetryBackoff`**: optional function hook to control the delay between attempts. + +Example: + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + + DialerRetries: 5, + DialerRetryTimeout: 100 * time.Millisecond, // used when DialerRetryBackoff is nil + + // Optional: exponential backoff with jitter and a cap. + DialerRetryBackoff: redis.DialRetryBackoffExponential(100*time.Millisecond, 2*time.Second), +}) +defer rdb.Close() +``` + +### Authentication + +The Redis client supports multiple ways to provide authentication credentials, with a clear priority order. Here are the available options: + +#### 1. Streaming Credentials Provider (Highest Priority) - Experimental feature + +The streaming credentials provider allows for dynamic credential updates during the connection lifetime. This is particularly useful for managed identity services and token-based authentication. + +```go +type StreamingCredentialsProvider interface { + Subscribe(listener CredentialsListener) (Credentials, UnsubscribeFunc, error) +} + +type CredentialsListener interface { + OnNext(credentials Credentials) // Called when credentials are updated + OnError(err error) // Called when an error occurs +} + +type Credentials interface { + BasicAuth() (username string, password string) + RawCredentials() string +} +``` + +Example usage: +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + StreamingCredentialsProvider: &MyCredentialsProvider{}, +}) +``` + +**Note:** The streaming credentials provider can be used with [go-redis-entraid](https://github.com/redis/go-redis-entraid) to enable Entra ID (formerly Azure AD) authentication. This allows for seamless integration with Azure's managed identity services and token-based authentication. + +Example with Entra ID: +```go +import ( + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis-entraid" +) + +// Create an Entra ID credentials provider +provider := entraid.NewDefaultAzureIdentityProvider() + +// Configure Redis client with Entra ID authentication +rdb := redis.NewClient(&redis.Options{ + Addr: "your-redis-server.redis.cache.windows.net:6380", + StreamingCredentialsProvider: provider, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, +}) +``` + +#### 2. Context-based Credentials Provider + +The context-based provider allows credentials to be determined at the time of each operation, using the context. + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + CredentialsProviderContext: func(ctx context.Context) (string, string, error) { + // Return username, password, and any error + return "user", "pass", nil + }, +}) +``` + +#### 3. Regular Credentials Provider + +A simple function-based provider that returns static credentials. + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + CredentialsProvider: func() (string, string) { + // Return username and password + return "user", "pass" + }, +}) +``` + +#### 4. Username/Password Fields (Lowest Priority) + +The most basic way to provide credentials is through the `Username` and `Password` fields in the options. + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Username: "user", + Password: "pass", +}) +``` + +#### Priority Order + +The client will use credentials in the following priority order: +1. Streaming Credentials Provider (if set) +2. Context-based Credentials Provider (if set) +3. Regular Credentials Provider (if set) +4. Username/Password fields (if set) + +If none of these are set, the client will attempt to connect without authentication. + +### Protocol Version + +The client supports both RESP2 and RESP3 protocols. You can specify the protocol version in the options: + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password set + DB: 0, // use default DB + Protocol: 3, // specify 2 for RESP 2 or 3 for RESP 3 +}) +``` + +### Client-side caching + +go-redis supports server-assisted client-side caching for standalone clients. +Eligible read replies are stored in the application's memory, so repeated reads +can avoid a Redis round trip. Redis tracks which keys each connection has read +and sends RESP3 invalidation notifications when those keys change. go-redis +uses those notifications to evict affected entries automatically. + +> **Experimental:** The client-side caching API may change in a minor release. + +Enable the built-in bounded cache with `ClientSideCacheConfig`: + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Protocol: 3, + DB: 0, + ClientSideCacheConfig: &redis.ClientSideCacheConfig{ + MaxEntries: 10_000, + }, +}) +defer rdb.Close() +``` + +Client-side caching currently requires RESP3, a standalone client, and database +0. Fixed `Username` and `Password` values are supported. It is disabled when a +dynamic credential provider is configured, because cached data must never be +reused after the client's ACL identity changes. Only deterministic read +commands supported by the cache are stored; writes and streaming responses +bypass it. + +While client-side caching is enabled, go-redis rejects `SELECT`, `AUTH`, +`HELLO` with arguments, `RESET`, `CLIENT TRACKING`, and raw `SUBSCRIBE`, +`PSUBSCRIBE`, or `SSUBSCRIBE` commands because they would change connection +state that the cache relies on. A guarded command also fails its whole +pipeline. The typed `Subscribe`, `PSubscribe`, and `SSubscribe` APIs remain +supported because they use dedicated connections. + +Invalidations are processed asynchronously. `DrainInterval` controls how often +idle connections are checked for them, while `MaxStaleness` can provide an +optional upper bound on an entry's lifetime. See the +[client-side caching example](./example/client-side-caching) for a working +demonstration. + +### Connecting via a redis url + +go-redis also supports connecting via the +[redis uri specification](https://github.com/redis/redis-specifications/tree/master/uri/redis.txt). +The example below demonstrates how the connection can easily be configured using a string, adhering +to this specification. + +```go +import ( + "github.com/redis/go-redis/v9" +) + +func ExampleClient() *redis.Client { + url := "redis://user:password@localhost:6379/0?protocol=3" + opts, err := redis.ParseURL(url) + if err != nil { + panic(err) + } + + return redis.NewClient(opts) +} + +``` + +### Instrument with OpenTelemetry + +```go +import ( + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/extra/redisotel/v9" + "errors" +) + +func main() { + ... + rdb := redis.NewClient(&redis.Options{...}) + + if err := errors.Join(redisotel.InstrumentTracing(rdb), redisotel.InstrumentMetrics(rdb)); err != nil { + log.Fatal(err) + } +``` + + +### Buffer Size Configuration + +go-redis uses 32KiB read and write buffers by default for optimal performance. For high-throughput applications or large pipelines, you can customize buffer sizes: + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + ReadBufferSize: 1024 * 1024, // 1MiB read buffer + WriteBufferSize: 1024 * 1024, // 1MiB write buffer +}) +``` + +### Automatic pipelining + +**Experimental** — the API may still change. Reach for autopipelining in +high-throughput / high-load / scale scenarios; at low concurrency a plain +client is simpler and just as fast. A runnable usage tour and throughput +comparison live in [`example/autopipeline`](example/autopipeline). + +> **EXPERIMENTAL:** the autopipelining API is subject to change in a future +> release as we gather feedback — pin your go-redis version if you adopt it. + +When many goroutines issue commands concurrently, autopipelining batches them +into Redis pipelines automatically — without you writing any pipeline code. It +comes in two faces: + +- **`AutoPipeline()` — blocking, drop-in.** Each command call blocks until it + executes and returns its own value/error, exactly like a normal client, so + existing code keeps working unchanged. Under concurrency the engine coalesces + commands from all goroutines into deep, back-to-back pipelines (a single + ordered batch stream by default), reaching several times a plain client's + executed commands per second in the same environment — roughly an order of + magnitude with a parallel-batch config (`MaxConcurrentBatches` > 1 with + `Unordered`). Per-goroutine ordering is preserved. +- **`AsyncAutoPipeline()` — deferred, highest throughput.** Command calls return + immediately; you submit a window of commands and read their results afterward, + which keeps each pipeline deep — tens of times a plain client's throughput. + Ordered by default. Absolute numbers depend heavily on the machine, network + path and server; see `autopipeline_bench_README.md` for the benchmark + methodology and multipliers. + +```go +rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"}) +defer rdb.Close() +ctx := context.Background() + +// Blocking face: drop-in for a normal client, batched under the hood. +ap, err := rdb.AutoPipeline() +if err != nil { // invalid AutoPipelineOptions, or the client is closed + log.Fatal(err) +} +defer ap.Close() + +var wg sync.WaitGroup +for i := 0; i < 1000; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + key := fmt.Sprintf("key:%d", i) + if err := ap.Set(ctx, key, i, 0).Err(); err != nil { // blocks until executed + log.Printf("set %s: %v", key, err) + } + }(i) +} +wg.Wait() +``` + +For maximum throughput, submit a window on the async face and read later: + +```go +ctx := context.Background() +ap, err := rdb.AsyncAutoPipeline() // ordered by default +if err != nil { + log.Fatal(err) +} +defer ap.Close() + +cmds := make([]*redis.StatusCmd, 0, 200) +for i := 0; i < 200; i++ { + cmds = append(cmds, ap.Set(ctx, fmt.Sprintf("key:%d", i), i, 0)) // returns immediately +} +for _, cmd := range cmds { + if err := cmd.Err(); err != nil { // blocks until executed + log.Printf("set: %v", err) + } +} +``` + +Each face has a no-argument form that uses `Options.AutoPipelineOptions` (or the +built-in default) and a `WithOptions` form that takes an explicit +`*AutoPipelineOptions`; both return `(*AutoPipeliner, error)` — the error is +non-nil for an invalid config or a closed client (e.g. +`ap, err := rdb.AsyncAutoPipelineWithOptions(&redis.AutoPipelineOptions{MaxConcurrentBatches: 8, Unordered: true})`); +a handful of parallel batches saturates the link — more permits only add +overlapping batches without deepening them. +They work on `ClusterClient` too: commands are routed to the correct shard per +key, so a single batch may span many slots; ordering across nodes is per key +(same-key commands stay in order, different nodes' sub-pipelines run +concurrently). Because batches share a few pipeline connections, autopipelining +also needs far fewer connections than a plain client at the same concurrency +(see `PipelinePoolSize`). Autopipelining is only a win under concurrency (or +windowed submission) — a single goroutine issuing one blocking command at a +time sees little benefit, and a hand-written `Pipeline()` is still fastest when +you can batch by hand. + +Caveats: a command's context is not honored once it is queued (batches execute +on the autopipeliner's own context) — use a plain client for per-command +deadlines. Blocking commands (`BLPOP`, `WAIT`, ...) are never batched and run +directly on your context — as are `SHUTDOWN` and `MONITOR`, which would +poison a shared pipeline connection — and `Do` also bypasses batching with plain +`Client.Do` semantics — prefer the typed methods (`ap.Set`, `ap.Get`, ...). On +a dropped connection a batch is retried whole (up to `MaxRetries`), so +non-idempotent commands may execute twice. Both faces return a cached, +client-shared instance: the first call's config wins and `Close` stops it for +all callers. Hooks may read command results (the engine hands a hook running +on the dispatch goroutine the same view a plain pipeline hook gets), but a +hook must never issue a command on the same autopipeliner and wait for it — +the nested command needs the very dispatch slot the hook is holding, and the +engine only recovers by failing that flush after its 30s permit backstops. +`Options.Limiter` is consulted once per batch dispatch (as with a manual +pipeline), not once per command. An autopipeliner created on a +`WithTimeout`/`WithReadTimeout` clone is not stopped by the parent's `Close` — +close it explicitly. + +### Advanced Configuration + +go-redis supports extending the client identification phase to allow projects to send their own custom client identification. + +#### Default Client Identification + +By default, go-redis automatically sends the client library name and version during the connection process. This feature is available in redis-server as of version 7.2. As a result, the command is "fire and forget", meaning it should fail silently, in the case that the redis server does not support this feature. + +#### Disabling Identity Verification + +When connection identity verification is not required or needs to be explicitly disabled, a `DisableIdentity` configuration option exists. +Initially there was a typo and the option was named `DisableIndentity` instead of `DisableIdentity`. The misspelled option is marked as Deprecated and will be removed in V10 of this library. +Although both options will work at the moment, the correct option is `DisableIdentity`. The deprecated option will be removed in V10 of this library, so please use the correct option name to avoid any issues. + +To disable verification, set the `DisableIdentity` option to `true` in the Redis client options: + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", + DB: 0, + DisableIdentity: true, // Disable set-info on connect +}) +``` + +#### RESP3 for RediSearch Commands (`UnstableResp3` is deprecated) +As of v9.20, `FT.SEARCH`, `FT.AGGREGATE`, `FT.INFO`, `FT.SPELLCHECK`, and `FT.SYNDUMP` +parse RESP3 (map) responses into the same typed result objects as RESP2. **No flag +is required — `Val()` / `Result()` work uniformly on both protocols.** + +The legacy `UnstableResp3` option is now a **no-op** and is retained on every +options struct only for backwards compatibility. It will be removed in a future +release; new code should not set it. + +`RawResult()` / `RawVal()` continue to work for callers that prefer the raw RESP +payload directly: + +```go +res1, err := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{}).RawResult() +val1 := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{}).RawVal() +``` + +#### Redis-Search Default Dialect + +In the Redis-Search module, **the default dialect is 2**. If needed, you can explicitly specify a different dialect using the appropriate configuration in your queries. + +**Important**: Be aware that the query dialect may impact the results returned. If needed, you can revert to a different dialect version by passing the desired dialect in the arguments of the command you want to execute. +For example: +``` + res2, err := rdb.FTSearchWithArgs(ctx, + "idx:bicycle", + "@pickup_zone:[CONTAINS $bike]", + &redis.FTSearchOptions{ + Params: map[string]interface{}{ + "bike": "POINT(-0.1278 51.5074)", + }, + DialectVersion: 3, + }, + ).Result() +``` +You can find further details in the [query dialect documentation](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/dialects/). + +#### Custom buffer sizes +Prior to v9.12, the buffer size was the default go value of 4096 bytes. Starting from v9.12, +go-redis uses 32KiB read and write buffers by default for optimal performance. +For high-throughput applications or large pipelines, you can customize buffer sizes: + +```go +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + ReadBufferSize: 1024 * 1024, // 1MiB read buffer + WriteBufferSize: 1024 * 1024, // 1MiB write buffer +}) +``` + +**Important**: If you experience any issues with the default buffer sizes, please try setting them to the go default of 4096 bytes. + +## Contributing +We welcome contributions to the go-redis library! If you have a bug fix, feature request, or improvement, please open an issue or pull request on GitHub. +We appreciate your help in making go-redis better for everyone. +If you are interested in contributing to the go-redis library, please check out our [contributing guidelines](CONTRIBUTING.md) for more information on how to get started. + +## Look and feel + +Some corner cases: + +```go +// SET key value EX 10 NX +set, err := rdb.SetNX(ctx, "key", "value", 10*time.Second).Result() + +// SET key value keepttl NX +set, err := rdb.SetNX(ctx, "key", "value", redis.KeepTTL).Result() + +// SORT list LIMIT 0 2 ASC +vals, err := rdb.Sort(ctx, "list", &redis.Sort{Offset: 0, Count: 2, Order: "ASC"}).Result() + +// ZRANGEBYSCORE zset -inf +inf WITHSCORES LIMIT 0 2 +vals, err := rdb.ZRangeByScoreWithScores(ctx, "zset", &redis.ZRangeBy{ + Min: "-inf", + Max: "+inf", + Offset: 0, + Count: 2, +}).Result() + +// ZINTERSTORE out 2 zset1 zset2 WEIGHTS 2 3 AGGREGATE SUM +vals, err := rdb.ZInterStore(ctx, "out", &redis.ZStore{ + Keys: []string{"zset1", "zset2"}, + Weights: []int64{2, 3} +}).Result() + +// EVAL "return {KEYS[1],ARGV[1]}" 1 "key" "hello" +vals, err := rdb.Eval(ctx, "return {KEYS[1],ARGV[1]}", []string{"key"}, "hello").Result() + +// custom command +res, err := rdb.Do(ctx, "set", "key", "value").Result() +``` + +### Raw commands and connection state + +`Do` sends the command verbatim on whichever pooled connection happens to be +free. For keyspace commands that is all you need. It is the wrong tool for +any command that alters **connection session state** — `SELECT`, +`CLIENT SETNAME`, `CLIENT TRACKING`, `RESET`, `HIMPORT PREPARE`/`DISCARD`, +and similar: the state lands on (or is wiped from) a single arbitrary +connection, later commands are served by other connections that don't share +it, and the affected connection eventually returns to the pool and serves +unrelated callers. The result is nondeterministic behavior that typed APIs +manage for you — for example, the typed `HImport*` methods keep a +client-side registry and replay fieldsets onto every connection that needs +them, while a raw `Do(ctx, "himport", "prepare", ...)` bypasses that +entirely, with no replay, recovery, or discard propagation. + +For session-scoped work without a typed API, hold a dedicated connection +(`client.Conn()`) for its whole lifetime and close it afterwards. + +## Typed Errors + +go-redis provides typed error checking functions for common Redis errors: + +```go +// Cluster and replication errors +redis.IsLoadingError(err) // Redis is loading the dataset +redis.IsReadOnlyError(err) // Write to read-only replica +redis.IsClusterDownError(err) // Cluster is down +redis.IsTryAgainError(err) // Command should be retried +redis.IsMasterDownError(err) // Master is down +redis.IsMovedError(err) // Returns (address, true) if key moved +redis.IsAskError(err) // Returns (address, true) if key being migrated + +// Connection and resource errors +redis.IsMaxClientsError(err) // Maximum clients reached +redis.IsAuthError(err) // Authentication failed (NOAUTH, WRONGPASS, unauthenticated) +redis.IsPermissionError(err) // Permission denied (NOPERM) +redis.IsOOMError(err) // Out of memory (OOM) + +// Transaction errors +redis.IsExecAbortError(err) // Transaction aborted (EXECABORT) +``` + +### Error Wrapping in Hooks + +When wrapping errors in hooks, use custom error types with `Unwrap()` method (preferred) or `fmt.Errorf` with `%w`. Always call `cmd.SetErr()` to preserve error type information: + +```go +// Custom error type (preferred) +type AppError struct { + Code string + RequestID string + Err error +} + +func (e *AppError) Error() string { + return fmt.Sprintf("[%s] request_id=%s: %v", e.Code, e.RequestID, e.Err) +} + +func (e *AppError) Unwrap() error { + return e.Err +} + +// Hook implementation +func (h MyHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook { + return func(ctx context.Context, cmd redis.Cmder) error { + err := next(ctx, cmd) + if err != nil { + // Wrap with custom error type + wrappedErr := &AppError{ + Code: "REDIS_ERROR", + RequestID: getRequestID(ctx), + Err: err, + } + cmd.SetErr(wrappedErr) + return wrappedErr // Return wrapped error to preserve it + } + return nil + } +} + +// Typed error detection works through wrappers +if redis.IsLoadingError(err) { + // Retry logic +} + +// Extract custom error if needed +var appErr *AppError +if errors.As(err, &appErr) { + log.Printf("Request: %s", appErr.RequestID) +} +``` + +Alternatively, use `fmt.Errorf` with `%w`: +```go +wrappedErr := fmt.Errorf("context: %w", err) +cmd.SetErr(wrappedErr) +``` + +### Pipeline Hook Example + +For pipeline operations, use `ProcessPipelineHook`: + +```go +type PipelineLoggingHook struct{} + +func (h PipelineLoggingHook) DialHook(next redis.DialHook) redis.DialHook { + return next +} + +func (h PipelineLoggingHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook { + return next +} + +func (h PipelineLoggingHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook { + return func(ctx context.Context, cmds []redis.Cmder) error { + start := time.Now() + + // Execute the pipeline + err := next(ctx, cmds) + + duration := time.Since(start) + log.Printf("Pipeline executed %d commands in %v", len(cmds), duration) + + // Process individual command errors + // Note: Individual command errors are already set on each cmd by the pipeline execution + for _, cmd := range cmds { + if cmdErr := cmd.Err(); cmdErr != nil { + // Check for specific error types using typed error functions + if redis.IsAuthError(cmdErr) { + log.Printf("Auth error in pipeline command %s: %v", cmd.Name(), cmdErr) + } else if redis.IsPermissionError(cmdErr) { + log.Printf("Permission error in pipeline command %s: %v", cmd.Name(), cmdErr) + } + + // Optionally wrap individual command errors to add context + // The wrapped error preserves type information through errors.As() + wrappedErr := fmt.Errorf("pipeline cmd %s failed: %w", cmd.Name(), cmdErr) + cmd.SetErr(wrappedErr) + } + } + + // Return the pipeline-level error (connection errors, etc.) + // You can wrap it if needed, or return it as-is + return err + } +} + +// Register the hook +rdb.AddHook(PipelineLoggingHook{}) + +// Use pipeline - errors are still properly typed +pipe := rdb.Pipeline() +pipe.Set(ctx, "key1", "value1", 0) +pipe.Get(ctx, "key2") +_, err := pipe.Exec(ctx) +``` + +## Run the test + +Recommended to use Docker, just need to run: +```shell +make test +``` + +## See also + +- [Golang ORM](https://bun.uptrace.dev) for PostgreSQL, MySQL, MSSQL, and SQLite +- [Golang PostgreSQL](https://bun.uptrace.dev/postgres/) +- [Golang HTTP router](https://bunrouter.uptrace.dev/) +- [Golang ClickHouse ORM](https://github.com/uptrace/go-clickhouse) + +## Contributors + +> The go-redis project was originally initiated by :star: [**uptrace/uptrace**](https://github.com/uptrace/uptrace). +> Uptrace is an open-source APM tool that supports distributed tracing, metrics, and logs. You can +> use it to monitor applications and set up automatic alerts to receive notifications via email, +> Slack, Telegram, and others. +> +> See [OpenTelemetry](https://github.com/redis/go-redis/tree/master/example/otel) example which +> demonstrates how you can use Uptrace to monitor go-redis. + +Thanks to all the people who already contributed! + + + + diff --git a/vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md b/vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md new file mode 100644 index 0000000000..0ebed09271 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/RELEASE-NOTES.md @@ -0,0 +1,1348 @@ +# Release Notes + +# 9.22.0 (2026-08-03) + +This is a minor release introducing two flagship (experimental) features — **client-side caching** and **automatic pipelining** — alongside support for Redis 8.10, new commands, and a large batch of stability and parser-robustness fixes. It consolidates everything shipped in 9.22.0-beta.1, so the notes below cover the full 9.21.0 → 9.22.0 upgrade. + +⚠️ Two changes to be aware of when upgrading from 9.21.0: + +- **Default configuration values changed** ([#3918](https://github.com/redis/go-redis/pull/3918)): read/write timeouts, retry backoff, cluster state reload interval, and TCP keep-alive defaults are now aligned with the cross-SDK configuration proposal (see the highlight below). Explicitly configured values are unaffected. +- **`WaitAOF` return type corrected** ([#3888](https://github.com/redis/go-redis/pull/3888)): `WaitAOF` now returns `*IntSliceCmd`, matching the two-integer reply of `WAITAOF` (previously `*IntCmd`, which failed to parse the reply at runtime). Code referencing the old return type needs a one-line update. + +## 🚀 Highlights + +### Client-Side Caching (Experimental) + +The standalone `Client` gains server-assisted client-side caching built on RESP3 `CLIENT TRACKING`. Enable it by setting `ClientSideCacheConfig` in `Options` (or supply your own cache via `ClientSideCache` — e.g. to share one cache across clients). Cacheable read results are served from a local in-process cache and invalidated automatically when the server reports a change, cutting round trips for read-heavy workloads. + +The invalidation architecture is selected by `ClientSideCacheStrategy`; the default (and currently only) strategy is `CSCStrategySharedTracking`: one shared cache, every pool connection runs plain `CLIENT TRACKING ON`, and a background drainer applies buffered invalidations — portable (no BCAST) and consistent with the other Redis client libraries. Requirements and guardrails: RESP3 (`Protocol: 3`), standalone client, DB 0 only; commands that would change the connection identity (`SELECT`, `AUTH`, ...) are rejected while caching is enabled, and CSC is disabled when a credentials provider is set (fixed `Username`/`Password` work and are namespaced). See the README's [client-side caching section](README.md#client-side-caching) and the runnable [example](example/client-side-caching). + +**Experimental:** the API may change in a minor release. + +([#3941](https://github.com/redis/go-redis/pull/3941)) by [@ofekshenawa](https://github.com/ofekshenawa) + +### Automatic Pipelining (Experimental) + +`AutoPipeliner` is a background batcher that coalesces commands from many concurrent goroutines into Redis pipelines, multiplying throughput without any manual pipeline management. It comes in two faces, available on `Client` and `ClusterClient` (and configurable via `Options.AutoPipelineOptions` / `UniversalOptions.AutoPipelineOptions`): + +- **`AutoPipeline()`** — the blocking face: a drop-in `Cmdable` where each call blocks until executed, exactly like a plain client, while concurrent callers' commands batch together under the hood (measured locally over loopback: ~1M+ SET/sec vs ~100k unpipelined; indicative, not a guarantee). Per-goroutine command order is preserved. +- **`AsyncAutoPipeline()`** — the deferred face: command calls return immediately and every typed result accessor (`Val`/`Result`/`Err`/...) blocks until the command has executed. Submit a window of commands, then read the results, to keep pipelines deep (~2–3M SET/sec locally; indicative). + +`AutoPipelineOptions` controls batching: `MaxBatchSize` (soft target, default 200; the blocking face's preset uses 300), `MaxBatchBytes` (approximate payload cap so huge values flush as several bounded writes), `MaxFlushDelay` with optional `AdaptiveDelay` (delay scales down as the queue fills), and `MaxConcurrentBatches` (default 1 = a single ordered batch stream; raising it requires `Unordered: true`, so ordering is never lost by accident — `Validate()` rejects the combination otherwise). A usage tour and throughput comparison live in [`example/autopipeline`](example/autopipeline). + +**Experimental:** the API may change in a future release — pin your go-redis version if you adopt it. + +([#3942](https://github.com/redis/go-redis/pull/3942)) by [@ndyakov](https://github.com/ndyakov), with help from [@cxljs](https://github.com/cxljs) + +### Redis 8.10 Support + +This release adds support for **Redis 8.10**. The README's supported-versions list now includes Redis 8.10, and CI runs the full suite against the `redislabs/client-libs-test:8.10.0` image by default ([#3920](https://github.com/redis/go-redis/pull/3920), [#3940](https://github.com/redis/go-redis/pull/3940)). + +Coverage for the new commands and options that ship with Redis 8.10: + +- **`HIMPORT`** ([#3919](https://github.com/redis/go-redis/pull/3919)) — bulk hash import via server-side fieldsets, exposed as `HImportPrepare`, `HImportSet`, `HImportDiscard`, and `HImportDiscardAll`. Fieldsets are session state scoped to a single physical connection, which does not mix well with connection pooling — so the client keeps a versioned fieldset registry and lazily replays the `PREPARE` on whichever pooled connection executes a `SET` that needs it, at most once per connection, with no extra round trip (the `PREPARE` is injected into the same write as the `SET`). +- **`LMOVEM` / `BLMOVEM`** ([#3913](https://github.com/redis/go-redis/pull/3913)) — move multiple elements between lists in one call. +- **`SUNIONCARD` / `SDIFFCARD`** ([#3897](https://github.com/redis/go-redis/pull/3897)) — cardinality of set union/difference without materializing the result. +- **`XREAD` / `XREADGROUP` `MAXCOUNT` and `MAXSIZE`** ([#3898](https://github.com/redis/go-redis/pull/3898)) — bound how much data a stream read returns. +- **`TS.READ`** ([#3896](https://github.com/redis/go-redis/pull/3896)), **`TS.QUERYLABELS`** ([#3926](https://github.com/redis/go-redis/pull/3926)), **`TS.NRANGE` / `TS.NREVRANGE`** ([#3870](https://github.com/redis/go-redis/pull/3870)) with multiple aggregators per key ([#3937](https://github.com/redis/go-redis/pull/3937)), and **`EXCLUDEEMPTY`** on `TS.MRANGE` / `TS.MREVRANGE` ([#3912](https://github.com/redis/go-redis/pull/3912)) — new time-series query surface. +- **`FT.ALIASLIST`** ([#3925](https://github.com/redis/go-redis/pull/3925)), **`COLLECT` reducer for `FT.AGGREGATE`** ([#3886](https://github.com/redis/go-redis/pull/3886)), **`RERANK` on HNSW vector fields in `FT.CREATE`** ([#3927](https://github.com/redis/go-redis/pull/3927)), and **`FT.HYBRID` timeout warnings** ([#3911](https://github.com/redis/go-redis/pull/3911)) — search coverage. + +### Cross-SDK Aligned Defaults + +Default configuration values now follow the cross-SDK configuration proposal shared by all Redis client libraries ([#3918](https://github.com/redis/go-redis/pull/3918)): + +| Setting | Old default | New default | +|---|---|---| +| `ReadTimeout` / `WriteTimeout` | 3s | 5s | +| Retry backoff (min/max) | 8ms / 512ms | 10ms / 1s | +| Cluster state reload interval | 10s | 60s | +| TCP keep-alive | 5min period | 30s idle / 5s interval / 3 probes (`net.KeepAliveConfig`) | + +Applications that set these values explicitly are unaffected; applications relying on the old defaults inherit the new ones. + +### Data-Race and Parser Hardening Sweep + +A systematic audit fixed data races across the client — hooks (`AddHook`, [#3868](https://github.com/redis/go-redis/pull/3868)), `Ring.SetAddrs` ([#3862](https://github.com/redis/go-redis/pull/3862)), cluster node slices ([#3861](https://github.com/redis/go-redis/pull/3861)), pub/sub reconnect ([#3906](https://github.com/redis/go-redis/pull/3906)), maintenance notifications ([#3894](https://github.com/redis/go-redis/pull/3894), [#3872](https://github.com/redis/go-redis/pull/3872)), pool handoff ([#3876](https://github.com/redis/go-redis/pull/3876)), and `redisotel` ([#3881](https://github.com/redis/go-redis/pull/3881)) — and hardened the RESP parsers against malformed or unexpected replies: over-reads on nil replies ([#3874](https://github.com/redis/go-redis/pull/3874)), integer overflow when skipping map/attribute bodies ([#3877](https://github.com/redis/go-redis/pull/3877)), unhashable RESP3 map keys ([#3873](https://github.com/redis/go-redis/pull/3873)), odd-length flat replies ([#3900](https://github.com/redis/go-redis/pull/3900)), mismatched declared array lengths ([#3907](https://github.com/redis/go-redis/pull/3907)), unexpected extra reply frames ([#3884](https://github.com/redis/go-redis/pull/3884)), and nil elements in numeric/bool slice replies ([#3922](https://github.com/redis/go-redis/pull/3922)). + +### PubSub `Receive` Hang Fix + +`PeekPushNotificationName` blocked until 36 bytes were buffered, so a short subscribe confirmation (channel name of six or fewer characters) on an otherwise idle connection hung `PubSub.Receive` forever — a regression introduced in 9.20.1 by [#3842](https://github.com/redis/go-redis/pull/3842). The peek now parses whatever is already buffered and only waits for one more byte when the frame prefix is valid but incomplete. Fixes [#3935](https://github.com/redis/go-redis/issues/3935). + +([#3936](https://github.com/redis/go-redis/pull/3936)) by [@ndyakov](https://github.com/ndyakov) + +### Correct Cluster Transaction Retries + +The cluster transaction pipeline treated a `MULTI`...`EXEC` block as independently retryable commands, which could scatter a transaction across nodes or send malformed transactions on retry. Redirects (`MOVED`/`ASK`/`TRYAGAIN`) and aborts are now handled at the whole-transaction level, matching Redis transaction semantics: the transaction is re-routed and retried as a unit, never partially ([#3909](https://github.com/redis/go-redis/pull/3909)) by [@cxljs](https://github.com/cxljs). + +### Credential Redaction in Command Tracing + +`rediscmd.AppendCmd` — used by `redisotel` and `rediscensus` to render commands into span attributes — now redacts credential arguments as ``: `AUTH`, `HELLO ... AUTH`, `CONFIG SET` of `requirepass` / `masterauth` / TLS key passphrases, `ACL SETUSER` password rules, and `MIGRATE ... AUTH`/`AUTH2`. The client sends `HELLO ... AUTH` on every handshake and `AUTH` on every streaming-credentials rotation through the regular hook chain, so tracing hooks previously captured credentials even when the application never issued an auth command itself ([#3939](https://github.com/redis/go-redis/pull/3939)) by [@saddamr3e](https://github.com/saddamr3e). + +## ✨ New Features + +- **Client-side caching**: server-assisted caching for the standalone client via `ClientSideCacheConfig` / `ClientSideCache`, with the `CSCStrategySharedTracking` invalidation strategy ([#3941](https://github.com/redis/go-redis/pull/3941)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **Automatic pipelining**: `AutoPipeline()` (blocking) and `AsyncAutoPipeline()` (deferred results) on `Client` and `ClusterClient`, configured via `AutoPipelineOptions` ([#3942](https://github.com/redis/go-redis/pull/3942)) by [@ndyakov](https://github.com/ndyakov), with help from [@cxljs](https://github.com/cxljs) +- **`HIMPORT` command family**: `HImportPrepare` / `HImportSet` / `HImportDiscard` / `HImportDiscardAll` with lazy per-connection fieldset prepare replay ([#3919](https://github.com/redis/go-redis/pull/3919)) by [@ndyakov](https://github.com/ndyakov) +- **`LMOVEM` / `BLMOVEM`**: move multiple list elements in one call, with `COUNT` (up to N) or `EXACTLY` (all-or-nothing) semantics via `LMoveMArgs` ([#3913](https://github.com/redis/go-redis/pull/3913)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`SUnionCard` / `SDiffCard`**: cardinality of set union/difference ([#3897](https://github.com/redis/go-redis/pull/3897)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`XRead` / `XReadGroup` `MAXCOUNT` / `MAXSIZE`**: bound stream read responses by entry count or payload size ([#3898](https://github.com/redis/go-redis/pull/3898)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`TS.READ`**: read samples from a series starting at a given timestamp, with `TSReadEarliest` (`-`), `TSReadLatest` (`+`), and `TSReadNew` (`$`) sentinels ([#3896](https://github.com/redis/go-redis/pull/3896)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`TS.QUERYLABELS`**: query label names/values across time series ([#3926](https://github.com/redis/go-redis/pull/3926)) by [@ndyakov](https://github.com/ndyakov) +- **`TS.NRANGE` / `TS.NREVRANGE`**: range queries across multiple series ([#3870](https://github.com/redis/go-redis/pull/3870)) by [@ofekshenawa](https://github.com/ofekshenawa), with multiple aggregators per key ([#3937](https://github.com/redis/go-redis/pull/3937)) by [@ndyakov](https://github.com/ndyakov) +- **`TS.MRANGE` / `TS.MREVRANGE` `EXCLUDEEMPTY`**: skip series with no samples in the result ([#3912](https://github.com/redis/go-redis/pull/3912)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`FT.ALIASLIST`**: list all index aliases ([#3925](https://github.com/redis/go-redis/pull/3925)) by [@ndyakov](https://github.com/ndyakov) +- **`FT.AGGREGATE` `COLLECT` reducer**: collect grouped values into an array ([#3886](https://github.com/redis/go-redis/pull/3886)) by [@ndyakov](https://github.com/ndyakov) +- **`FT.CREATE` `RERANK`**: `RERANK` parameter on HNSW vector field definitions ([#3927](https://github.com/redis/go-redis/pull/3927)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`FT.HYBRID` timeout warnings**: timeout warnings are now populated in hybrid search results ([#3911](https://github.com/redis/go-redis/pull/3911)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`FT.HYBRID` KNN `SHARD_K_RATIO`** (Redis 8.8+): per-shard K ratio for KNN clauses ([#3841](https://github.com/redis/go-redis/pull/3841)) by [@ndyakov](https://github.com/ndyakov) + +## 🐛 Bug Fixes + +- **PubSub `Receive` hang**: peek push-notification names without demanding 36 buffered bytes, fixing a hang on short subscribe confirmations (fixes [#3935](https://github.com/redis/go-redis/issues/3935), regression from 9.20.1) ([#3936](https://github.com/redis/go-redis/pull/3936)) by [@ndyakov](https://github.com/ndyakov) +- **Cluster transactions**: re-route the whole tx pipeline on redirect/abort instead of per-command ([#3909](https://github.com/redis/go-redis/pull/3909)) by [@cxljs](https://github.com/cxljs) +- **Credential leak in traces**: `rediscmd.AppendCmd` redacts credential arguments (`AUTH`, `HELLO ... AUTH`, `CONFIG SET` secret params, `ACL SETUSER` password rules, `MIGRATE AUTH`/`AUTH2`), so `redisotel` / `rediscensus` span attributes no longer contain passwords ([#3939](https://github.com/redis/go-redis/pull/3939)) by [@saddamr3e](https://github.com/saddamr3e) +- **`WaitAOF` return type**: returns `*IntSliceCmd` matching the two-integer `WAITAOF` reply ([#3888](https://github.com/redis/go-redis/pull/3888)) by [@CipherN9](https://github.com/CipherN9) +- **`Ring.Publish` routing**: publish to the shard that owns the topic instead of a round-robined one ([#3893](https://github.com/redis/go-redis/pull/3893)) by [@dkindel](https://github.com/dkindel) +- **Pool `OnRemove` hooks**: fire `OnRemove` on `putConn` eviction paths so removal hooks see every evicted connection ([#3932](https://github.com/redis/go-redis/pull/3932)) by [@cxljs](https://github.com/cxljs) +- **`UniversalClient` `InfoMap`**: added `InfoMap` to the `Cmdable` interface ([#3904](https://github.com/redis/go-redis/pull/3904)) by [@nazarli-shabnam](https://github.com/nazarli-shabnam) +- **`SlowLogGet` context**: pass the caller's context instead of a background one ([#3915](https://github.com/redis/go-redis/pull/3915)) by [@sonnemusk](https://github.com/sonnemusk) +- **`ModuleLoadex` nil config**: return an error instead of panicking on nil config ([#3916](https://github.com/redis/go-redis/pull/3916)) by [@sonnemusk](https://github.com/sonnemusk) +- **`ParseURL` IPv6 hosts**: keep single brackets for IPv6 hosts without a port ([#3882](https://github.com/redis/go-redis/pull/3882)) by [@sueun-dev](https://github.com/sueun-dev) +- **`ParseURL` durations**: treat unit durations `<= 0` as disabled ([#3866](https://github.com/redis/go-redis/pull/3866)) by [@sueun-dev](https://github.com/sueun-dev) +- **Nil `*uint8` encoding**: encode nil `*uint8` as `"0"` like other numeric pointers ([#3869](https://github.com/redis/go-redis/pull/3869)) by [@sueun-dev](https://github.com/sueun-dev) +- **`JSONSliceCmd` read errors**: return the read error from `readReply` instead of swallowing it ([#3903](https://github.com/redis/go-redis/pull/3903)) by [@saddamr3e](https://github.com/saddamr3e) +- **RESP parser hardening**: reconcile declared entry-array lengths ([#3907](https://github.com/redis/go-redis/pull/3907)), handle nil elements in int/uint/bool slice parsers ([#3922](https://github.com/redis/go-redis/pull/3922)), drain unexpected reply frames ([#3884](https://github.com/redis/go-redis/pull/3884)), reject odd-length flat replies in Z/KeyValue parsers ([#3900](https://github.com/redis/go-redis/pull/3900)), avoid int overflow when skipping map/attr bodies ([#3877](https://github.com/redis/go-redis/pull/3877)), don't over-read nil replies in `Reader.Discard` ([#3874](https://github.com/redis/go-redis/pull/3874)) by [@saddamr3e](https://github.com/saddamr3e); reject unhashable keys in RESP3 map parsing ([#3873](https://github.com/redis/go-redis/pull/3873)) by [@iabdullah215](https://github.com/iabdullah215) +- **Data races**: hook state during `AddHook` ([#3868](https://github.com/redis/go-redis/pull/3868)), `onNewNode` during `Ring.SetAddrs` ([#3862](https://github.com/redis/go-redis/pull/3862)), shared masters/slaves slices in cluster ([#3861](https://github.com/redis/go-redis/pull/3861)), shared `opt.Addr` during pub/sub reconnect ([#3906](https://github.com/redis/go-redis/pull/3906)), `clusterStateReloadCallback` in maintnotifications ([#3894](https://github.com/redis/go-redis/pull/3894)), conn reader in `isHealthyConn` during handoff ([#3876](https://github.com/redis/go-redis/pull/3876)) by [@saddamr3e](https://github.com/saddamr3e); handoff race window in maintnotifications ([#3872](https://github.com/redis/go-redis/pull/3872)) by [@ndyakov](https://github.com/ndyakov) +- **`redisotel`**: use `ObservableCounter` for cumulative pool stats ([#3914](https://github.com/redis/go-redis/pull/3914)) by [@Solaris-star](https://github.com/Solaris-star); avoid a data race on shared attributes during `MinIdleConns` warmup ([#3881](https://github.com/redis/go-redis/pull/3881)) by [@ndyakov](https://github.com/ndyakov) + +## 🧰 Maintenance + +- **Cross-SDK default alignment**: new defaults for timeouts, retry backoff, cluster state reload, and TCP keep-alive ([#3918](https://github.com/redis/go-redis/pull/3918)) by [@ndyakov](https://github.com/ndyakov) +- **CI on Redis 8.10**: 8.10 made the default test version ([#3920](https://github.com/redis/go-redis/pull/3920)) with version gating by major.minor ([#3908](https://github.com/redis/go-redis/pull/3908)) by [@ofekshenawa](https://github.com/ofekshenawa); the test stack now runs the GA `redislabs/client-libs-test:8.10.0` image and 8.8 was dropped from the CI matrix ([#3940](https://github.com/redis/go-redis/pull/3940)) +- **Type-safe atomics**: use typed `sync/atomic` value types ([#3860](https://github.com/redis/go-redis/pull/3860)) and remove the dead `assertUnstableCommand` RESP3 path ([#3928](https://github.com/redis/go-redis/pull/3928)) by [@cxljs](https://github.com/cxljs) +- **Docs**: clarify that `ExpireTime` / `PExpireTime` return Unix timestamps ([#3917](https://github.com/redis/go-redis/pull/3917)) by [@sonnemusk](https://github.com/sonnemusk); remove a duplicate example step ([#3875](https://github.com/redis/go-redis/pull/3875)) by [@andy-stark-redis](https://github.com/andy-stark-redis) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@andy-stark-redis](https://github.com/andy-stark-redis), [@CipherN9](https://github.com/CipherN9), [@cxljs](https://github.com/cxljs), [@dkindel](https://github.com/dkindel), [@iabdullah215](https://github.com/iabdullah215), [@nazarli-shabnam](https://github.com/nazarli-shabnam), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@saddamr3e](https://github.com/saddamr3e), [@Solaris-star](https://github.com/Solaris-star), [@sonnemusk](https://github.com/sonnemusk), [@sueun-dev](https://github.com/sueun-dev) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.21.0...v9.22.0 + +# 9.22.0-beta.1 (2026-07-29) + +This is a **beta** release adding support for Redis 8.10, new commands, and a large batch of stability and parser-robustness fixes. The 9.22.0 GA release will follow once client-side caching and auto-pipelining are merged. + +⚠️ Two changes to be aware of when upgrading from 9.21.0: + +- **Default configuration values changed** ([#3918](https://github.com/redis/go-redis/pull/3918)): read/write timeouts, retry backoff, cluster state reload interval, and TCP keep-alive defaults are now aligned with the cross-SDK configuration proposal (see the highlight below). Explicitly configured values are unaffected. +- **`WaitAOF` return type corrected** ([#3888](https://github.com/redis/go-redis/pull/3888)): `WaitAOF` now returns `*IntSliceCmd`, matching the two-integer reply of `WAITAOF` (previously `*IntCmd`, which failed to parse the reply at runtime). Code referencing the old return type needs a one-line update. + +## 🚀 Highlights + +### Redis 8.10 Support + +This release adds support for **Redis 8.10**. The README's supported-versions list now includes Redis 8.10, and CI runs the full suite against the `redislabs/client-libs-test:8.10.0` image by default ([#3920](https://github.com/redis/go-redis/pull/3920), [#3940](https://github.com/redis/go-redis/pull/3940)). + +Coverage for the new commands and options that ship with Redis 8.10: + +- **`HIMPORT`** ([#3919](https://github.com/redis/go-redis/pull/3919)) — bulk hash import via server-side fieldsets, exposed as `HImportPrepare`, `HImportSet`, `HImportDiscard`, and `HImportDiscardAll`. Fieldsets are session state scoped to a single physical connection, which does not mix well with connection pooling — so the client keeps a versioned fieldset registry and lazily replays the `PREPARE` on whichever pooled connection executes a `SET` that needs it, at most once per connection, with no extra round trip (the `PREPARE` is injected into the same write as the `SET`). +- **`LMOVEM` / `BLMOVEM`** ([#3913](https://github.com/redis/go-redis/pull/3913)) — move multiple elements between lists in one call. +- **`SUNIONCARD` / `SDIFFCARD`** ([#3897](https://github.com/redis/go-redis/pull/3897)) — cardinality of set union/difference without materializing the result. +- **`XREAD` / `XREADGROUP` `MAXCOUNT` and `MAXSIZE`** ([#3898](https://github.com/redis/go-redis/pull/3898)) — bound how much data a stream read returns. +- **`TS.READ`** ([#3896](https://github.com/redis/go-redis/pull/3896)), **`TS.QUERYLABELS`** ([#3926](https://github.com/redis/go-redis/pull/3926)), **`TS.NRANGE` / `TS.NREVRANGE`** ([#3870](https://github.com/redis/go-redis/pull/3870)) with multiple aggregators per key ([#3937](https://github.com/redis/go-redis/pull/3937)), and **`EXCLUDEEMPTY`** on `TS.MRANGE` / `TS.MREVRANGE` ([#3912](https://github.com/redis/go-redis/pull/3912)) — new time-series query surface. +- **`FT.ALIASLIST`** ([#3925](https://github.com/redis/go-redis/pull/3925)), **`COLLECT` reducer for `FT.AGGREGATE`** ([#3886](https://github.com/redis/go-redis/pull/3886)), **`RERANK` on HNSW vector fields in `FT.CREATE`** ([#3927](https://github.com/redis/go-redis/pull/3927)), and **`FT.HYBRID` timeout warnings** ([#3911](https://github.com/redis/go-redis/pull/3911)) — search coverage. + +### Cross-SDK Aligned Defaults + +Default configuration values now follow the cross-SDK configuration proposal shared by all Redis client libraries ([#3918](https://github.com/redis/go-redis/pull/3918)): + +| Setting | Old default | New default | +|---|---|---| +| `ReadTimeout` / `WriteTimeout` | 3s | 5s | +| Retry backoff (min/max) | 8ms / 512ms | 10ms / 1s | +| Cluster state reload interval | 10s | 60s | +| TCP keep-alive | 5min period | 30s idle / 5s interval / 3 probes (`net.KeepAliveConfig`) | + +Applications that set these values explicitly are unaffected; applications relying on the old defaults inherit the new ones. + +### Data-Race and Parser Hardening Sweep + +A systematic audit fixed data races across the client — hooks (`AddHook`, [#3868](https://github.com/redis/go-redis/pull/3868)), `Ring.SetAddrs` ([#3862](https://github.com/redis/go-redis/pull/3862)), cluster node slices ([#3861](https://github.com/redis/go-redis/pull/3861)), pub/sub reconnect ([#3906](https://github.com/redis/go-redis/pull/3906)), maintenance notifications ([#3894](https://github.com/redis/go-redis/pull/3894), [#3872](https://github.com/redis/go-redis/pull/3872)), pool handoff ([#3876](https://github.com/redis/go-redis/pull/3876)), and `redisotel` ([#3881](https://github.com/redis/go-redis/pull/3881)) — and hardened the RESP parsers against malformed or unexpected replies: over-reads on nil replies ([#3874](https://github.com/redis/go-redis/pull/3874)), integer overflow when skipping map/attribute bodies ([#3877](https://github.com/redis/go-redis/pull/3877)), unhashable RESP3 map keys ([#3873](https://github.com/redis/go-redis/pull/3873)), odd-length flat replies ([#3900](https://github.com/redis/go-redis/pull/3900)), mismatched declared array lengths ([#3907](https://github.com/redis/go-redis/pull/3907)), unexpected extra reply frames ([#3884](https://github.com/redis/go-redis/pull/3884)), and nil elements in numeric/bool slice replies ([#3922](https://github.com/redis/go-redis/pull/3922)). + +### PubSub `Receive` Hang Fix + +`PeekPushNotificationName` blocked until 36 bytes were buffered, so a short subscribe confirmation (channel name of six or fewer characters) on an otherwise idle connection hung `PubSub.Receive` forever — a regression introduced in 9.20.1 by [#3842](https://github.com/redis/go-redis/pull/3842). The peek now parses whatever is already buffered and only waits for one more byte when the frame prefix is valid but incomplete. Fixes [#3935](https://github.com/redis/go-redis/issues/3935). + +([#3936](https://github.com/redis/go-redis/pull/3936)) by [@ndyakov](https://github.com/ndyakov) + +### Correct Cluster Transaction Retries + +The cluster transaction pipeline treated a `MULTI`...`EXEC` block as independently retryable commands, which could scatter a transaction across nodes or send malformed transactions on retry. Redirects (`MOVED`/`ASK`/`TRYAGAIN`) and aborts are now handled at the whole-transaction level, matching Redis transaction semantics: the transaction is re-routed and retried as a unit, never partially ([#3909](https://github.com/redis/go-redis/pull/3909)) by [@cxljs](https://github.com/cxljs). + +### Credential Redaction in Command Tracing + +`rediscmd.AppendCmd` — used by `redisotel` and `rediscensus` to render commands into span attributes — now redacts credential arguments as ``: `AUTH`, `HELLO ... AUTH`, `CONFIG SET` of `requirepass` / `masterauth` / TLS key passphrases, `ACL SETUSER` password rules, and `MIGRATE ... AUTH`/`AUTH2`. The client sends `HELLO ... AUTH` on every handshake and `AUTH` on every streaming-credentials rotation through the regular hook chain, so tracing hooks previously captured credentials even when the application never issued an auth command itself ([#3939](https://github.com/redis/go-redis/pull/3939)) by [@saddamr3e](https://github.com/saddamr3e). + +## ✨ New Features + +- **`HIMPORT` command family**: `HImportPrepare` / `HImportSet` / `HImportDiscard` / `HImportDiscardAll` with lazy per-connection fieldset prepare replay ([#3919](https://github.com/redis/go-redis/pull/3919)) by [@ndyakov](https://github.com/ndyakov) +- **`LMOVEM` / `BLMOVEM`**: move multiple list elements in one call, with `COUNT` (up to N) or `EXACTLY` (all-or-nothing) semantics via `LMoveMArgs` ([#3913](https://github.com/redis/go-redis/pull/3913)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`SUnionCard` / `SDiffCard`**: cardinality of set union/difference ([#3897](https://github.com/redis/go-redis/pull/3897)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`XRead` / `XReadGroup` `MAXCOUNT` / `MAXSIZE`**: bound stream read responses by entry count or payload size ([#3898](https://github.com/redis/go-redis/pull/3898)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`TS.READ`**: read samples from a series starting at a given timestamp, with `TSReadEarliest` (`-`), `TSReadLatest` (`+`), and `TSReadNew` (`$`) sentinels ([#3896](https://github.com/redis/go-redis/pull/3896)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`TS.QUERYLABELS`**: query label names/values across time series ([#3926](https://github.com/redis/go-redis/pull/3926)) by [@ndyakov](https://github.com/ndyakov) +- **`TS.NRANGE` / `TS.NREVRANGE`**: range queries across multiple series ([#3870](https://github.com/redis/go-redis/pull/3870)) by [@ofekshenawa](https://github.com/ofekshenawa), with multiple aggregators per key ([#3937](https://github.com/redis/go-redis/pull/3937)) by [@ndyakov](https://github.com/ndyakov) +- **`TS.MRANGE` / `TS.MREVRANGE` `EXCLUDEEMPTY`**: skip series with no samples in the result ([#3912](https://github.com/redis/go-redis/pull/3912)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`FT.ALIASLIST`**: list all index aliases ([#3925](https://github.com/redis/go-redis/pull/3925)) by [@ndyakov](https://github.com/ndyakov) +- **`FT.AGGREGATE` `COLLECT` reducer**: collect grouped values into an array ([#3886](https://github.com/redis/go-redis/pull/3886)) by [@ndyakov](https://github.com/ndyakov) +- **`FT.CREATE` `RERANK`**: `RERANK` parameter on HNSW vector field definitions ([#3927](https://github.com/redis/go-redis/pull/3927)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`FT.HYBRID` timeout warnings**: timeout warnings are now populated in hybrid search results ([#3911](https://github.com/redis/go-redis/pull/3911)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`FT.HYBRID` KNN `SHARD_K_RATIO`** (Redis 8.8+): per-shard K ratio for KNN clauses ([#3841](https://github.com/redis/go-redis/pull/3841)) by [@ndyakov](https://github.com/ndyakov) + +## 🐛 Bug Fixes + +- **PubSub `Receive` hang**: peek push-notification names without demanding 36 buffered bytes, fixing a hang on short subscribe confirmations (fixes [#3935](https://github.com/redis/go-redis/issues/3935), regression from 9.20.1) ([#3936](https://github.com/redis/go-redis/pull/3936)) by [@ndyakov](https://github.com/ndyakov) +- **Cluster transactions**: re-route the whole tx pipeline on redirect/abort instead of per-command ([#3909](https://github.com/redis/go-redis/pull/3909)) by [@cxljs](https://github.com/cxljs) +- **Credential leak in traces**: `rediscmd.AppendCmd` redacts credential arguments (`AUTH`, `HELLO ... AUTH`, `CONFIG SET` secret params, `ACL SETUSER` password rules, `MIGRATE AUTH`/`AUTH2`), so `redisotel` / `rediscensus` span attributes no longer contain passwords ([#3939](https://github.com/redis/go-redis/pull/3939)) by [@saddamr3e](https://github.com/saddamr3e) +- **`WaitAOF` return type**: returns `*IntSliceCmd` matching the two-integer `WAITAOF` reply ([#3888](https://github.com/redis/go-redis/pull/3888)) by [@CipherN9](https://github.com/CipherN9) +- **`Ring.Publish` routing**: publish to the shard that owns the topic instead of a round-robined one ([#3893](https://github.com/redis/go-redis/pull/3893)) by [@dkindel](https://github.com/dkindel) +- **Pool `OnRemove` hooks**: fire `OnRemove` on `putConn` eviction paths so removal hooks see every evicted connection ([#3932](https://github.com/redis/go-redis/pull/3932)) by [@cxljs](https://github.com/cxljs) +- **`UniversalClient` `InfoMap`**: added `InfoMap` to the `Cmdable` interface ([#3904](https://github.com/redis/go-redis/pull/3904)) by [@nazarli-shabnam](https://github.com/nazarli-shabnam) +- **`SlowLogGet` context**: pass the caller's context instead of a background one ([#3915](https://github.com/redis/go-redis/pull/3915)) by [@sonnemusk](https://github.com/sonnemusk) +- **`ModuleLoadex` nil config**: return an error instead of panicking on nil config ([#3916](https://github.com/redis/go-redis/pull/3916)) by [@sonnemusk](https://github.com/sonnemusk) +- **`ParseURL` IPv6 hosts**: keep single brackets for IPv6 hosts without a port ([#3882](https://github.com/redis/go-redis/pull/3882)) by [@sueun-dev](https://github.com/sueun-dev) +- **`ParseURL` durations**: treat unit durations `<= 0` as disabled ([#3866](https://github.com/redis/go-redis/pull/3866)) by [@sueun-dev](https://github.com/sueun-dev) +- **Nil `*uint8` encoding**: encode nil `*uint8` as `"0"` like other numeric pointers ([#3869](https://github.com/redis/go-redis/pull/3869)) by [@sueun-dev](https://github.com/sueun-dev) +- **`JSONSliceCmd` read errors**: return the read error from `readReply` instead of swallowing it ([#3903](https://github.com/redis/go-redis/pull/3903)) by [@saddamr3e](https://github.com/saddamr3e) +- **RESP parser hardening**: reconcile declared entry-array lengths ([#3907](https://github.com/redis/go-redis/pull/3907)), handle nil elements in int/uint/bool slice parsers ([#3922](https://github.com/redis/go-redis/pull/3922)), drain unexpected reply frames ([#3884](https://github.com/redis/go-redis/pull/3884)), reject odd-length flat replies in Z/KeyValue parsers ([#3900](https://github.com/redis/go-redis/pull/3900)), avoid int overflow when skipping map/attr bodies ([#3877](https://github.com/redis/go-redis/pull/3877)), don't over-read nil replies in `Reader.Discard` ([#3874](https://github.com/redis/go-redis/pull/3874)) by [@saddamr3e](https://github.com/saddamr3e); reject unhashable keys in RESP3 map parsing ([#3873](https://github.com/redis/go-redis/pull/3873)) by [@iabdullah215](https://github.com/iabdullah215) +- **Data races**: hook state during `AddHook` ([#3868](https://github.com/redis/go-redis/pull/3868)), `onNewNode` during `Ring.SetAddrs` ([#3862](https://github.com/redis/go-redis/pull/3862)), shared masters/slaves slices in cluster ([#3861](https://github.com/redis/go-redis/pull/3861)), shared `opt.Addr` during pub/sub reconnect ([#3906](https://github.com/redis/go-redis/pull/3906)), `clusterStateReloadCallback` in maintnotifications ([#3894](https://github.com/redis/go-redis/pull/3894)), conn reader in `isHealthyConn` during handoff ([#3876](https://github.com/redis/go-redis/pull/3876)) by [@saddamr3e](https://github.com/saddamr3e); handoff race window in maintnotifications ([#3872](https://github.com/redis/go-redis/pull/3872)) by [@ndyakov](https://github.com/ndyakov) +- **`redisotel`**: use `ObservableCounter` for cumulative pool stats ([#3914](https://github.com/redis/go-redis/pull/3914)) by [@Solaris-star](https://github.com/Solaris-star); avoid a data race on shared attributes during `MinIdleConns` warmup ([#3881](https://github.com/redis/go-redis/pull/3881)) by [@ndyakov](https://github.com/ndyakov) + +## 🧰 Maintenance + +- **Cross-SDK default alignment**: new defaults for timeouts, retry backoff, cluster state reload, and TCP keep-alive ([#3918](https://github.com/redis/go-redis/pull/3918)) by [@ndyakov](https://github.com/ndyakov) +- **CI on Redis 8.10**: 8.10 made the default test version ([#3920](https://github.com/redis/go-redis/pull/3920)) with version gating by major.minor ([#3908](https://github.com/redis/go-redis/pull/3908)) by [@ofekshenawa](https://github.com/ofekshenawa); the test stack now runs the GA `redislabs/client-libs-test:8.10.0` image and 8.8 was dropped from the CI matrix ([#3940](https://github.com/redis/go-redis/pull/3940)) +- **Type-safe atomics**: use typed `sync/atomic` value types ([#3860](https://github.com/redis/go-redis/pull/3860)) and remove the dead `assertUnstableCommand` RESP3 path ([#3928](https://github.com/redis/go-redis/pull/3928)) by [@cxljs](https://github.com/cxljs) +- **Docs**: clarify that `ExpireTime` / `PExpireTime` return Unix timestamps ([#3917](https://github.com/redis/go-redis/pull/3917)) by [@sonnemusk](https://github.com/sonnemusk); remove a duplicate example step ([#3875](https://github.com/redis/go-redis/pull/3875)) by [@andy-stark-redis](https://github.com/andy-stark-redis) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@andy-stark-redis](https://github.com/andy-stark-redis), [@CipherN9](https://github.com/CipherN9), [@cxljs](https://github.com/cxljs), [@dkindel](https://github.com/dkindel), [@iabdullah215](https://github.com/iabdullah215), [@nazarli-shabnam](https://github.com/nazarli-shabnam), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@saddamr3e](https://github.com/saddamr3e), [@Solaris-star](https://github.com/Solaris-star), [@sonnemusk](https://github.com/sonnemusk), [@sueun-dev](https://github.com/sueun-dev) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.21.0...v9.22.0-beta.1 + +# 9.21.0 (2026-06-18) + +This is a minor release adding new features and bug fixes. There are no breaking changes; upgrading from 9.20.x is a drop-in replacement. + +## 🚀 Highlights + +### Zero-copy `GetToBuffer` / `SetFromBuffer` + +Two new `StringCmdable` methods let callers read and write Redis string values directly into and from pre-allocated byte buffers, eliminating the per-call payload allocation that `Get`/`Set` incur: + +```go +GetToBuffer(ctx, key, buf) *ZeroCopyStringCmd // reads into buf; ZeroCopyStringCmd { Val() int; Bytes() []byte; Result() (int, error) } +SetFromBuffer(ctx, key, buf) *StatusCmd +``` + +`GetToBuffer` decodes the bulk reply straight into the caller-owned `buf` (no intermediate allocation); a buffer that is too small returns an error after draining the payload, so the connection stays aligned for the next reply. `SetFromBuffer` is provided for API symmetry — it dispatches to the same `[]byte` writer path as `Set(ctx, key, buf, 0)` and produces byte-identical output on the wire. Available on `*Client`, `*ClusterClient`, `*Ring`, `*Conn` and `Pipeliner`. + +([#3834](https://github.com/redis/go-redis/pull/3834)) by [@ndyakov](https://github.com/ndyakov) + +### Explicit `LIMIT 0` for stream trimming + +Redis treats `XTRIM`/`XADD` approximate-trim (`~`) `LIMIT 0` as "disable the trimming effort cap entirely", which differs from omitting `LIMIT` (the implicit `100 * stream-node-max-entries` default). The command builders previously only emitted `LIMIT` when `limit > 0`, so callers could never send an explicit `LIMIT 0`. Following the `KeepTTL = -1` precedent, the new `XTrimLimitDisabled = -1` sentinel now emits an explicit `LIMIT 0`; `limit == 0` keeps the historical no-`LIMIT` behavior, so existing callers produce byte-identical commands. + +([#3848](https://github.com/redis/go-redis/pull/3848)) by [@TheRealMal](https://github.com/TheRealMal) + +## ✨ New Features + +- **Zero-copy buffer string commands**: new `GetToBuffer` / `SetFromBuffer` on `StringCmdable` and the `ZeroCopyStringCmd` result type, reading/writing string values into caller-owned buffers without per-call payload allocation ([#3834](https://github.com/redis/go-redis/pull/3834)) by [@ndyakov](https://github.com/ndyakov) +- **`XTrimLimitDisabled` sentinel**: `XTRIM`/`XADD` approximate trimming can now send an explicit `LIMIT 0` to disable the trim effort cap, via the new `XTrimLimitDisabled = -1` sentinel ([#3848](https://github.com/redis/go-redis/pull/3848)) by [@TheRealMal](https://github.com/TheRealMal) +- **PubSub health-check timeouts**: `channel.initHealthCheck` now bounds the `Ping` it issues with a fresh per-check timeout context (the exported `pingTimeout` / `reconnectTimeout`) instead of `context.TODO()`, so a stuck health-check Ping can no longer block indefinitely ([#3819](https://github.com/redis/go-redis/pull/3819)) by [@abdellani](https://github.com/abdellani) +- **Skip redundant `UNWATCH` in `Tx.Close`**: a transaction now tracks whether a `WATCH` is still active (`watchArmed`) and only issues `UNWATCH` on `Close` when it is, removing an extra round trip on the common `WATCH`/.../`EXEC` and no-key `Watch` paths while never returning a connection to the pool with an active watch ([#3854](https://github.com/redis/go-redis/pull/3854)) by [@fcostaoliveira](https://github.com/fcostaoliveira) + +## 🐛 Bug Fixes + +- **`maintnotifications` `ModeAuto` fail-open**: `ModeAuto` now stays fail-open when the server does not support maintenance notifications — connections are retired and tracking is guarded during downgrade so the client keeps working instead of erroring ([#3853](https://github.com/redis/go-redis/pull/3853)) by [@terrorobe](https://github.com/terrorobe) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@abdellani](https://github.com/abdellani), [@fcostaoliveira](https://github.com/fcostaoliveira), [@ndyakov](https://github.com/ndyakov), [@terrorobe](https://github.com/terrorobe), [@TheRealMal](https://github.com/TheRealMal) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.20.1...v9.21.0 + +# 9.20.1 (2026-06-11) + +This is a patch release containing bug fixes only. There are no new features or breaking changes; upgrading from 9.20.0 is a drop-in replacement. + +## 🚀 Highlights + +### RESP3 pub/sub message loss fixed + +`PeekPushNotificationName` previously inspected only the bytes already buffered by `bufio`, so when a push frame header straddled a buffer fill boundary it could return a **truncated** notification name (e.g. `"messa"` instead of `"message"`). The push processor then mis-routed the frame and `ReadReply` silently dropped it, causing intermittent RESP3 pub/sub message loss. The peek now grows its window (36 bytes → up to 4 KiB) and reads more from the connection until the header is complete, cleanly separating incomplete prefixes from corrupt frames (including overflow-safe bulk-length handling). Fixes [#3839](https://github.com/redis/go-redis/issues/3839). + +([#3842](https://github.com/redis/go-redis/pull/3842)) by [@ndyakov](https://github.com/ndyakov) + +## 🐛 Bug Fixes + +- **RESP3 push peeking**: `PeekPushNotificationName` no longer returns a truncated notification name when a push frame header spans a buffer boundary, preventing silent RESP3 pub/sub message loss (fixes [#3839](https://github.com/redis/go-redis/issues/3839)) ([#3842](https://github.com/redis/go-redis/pull/3842)) by [@ndyakov](https://github.com/ndyakov) +- **`FT.HYBRID` vector params**: Vector data is now always sent via `PARAMS` with auto-generated param names (`__vector_param_N`, with collision avoidance) when `VectorParamName` is omitted, since Redis no longer accepts inline vector blobs; the `FTHybridOptions.Params` map is no longer mutated, so the same options struct can be reused across calls ([#3844](https://github.com/redis/go-redis/pull/3844)) by [@ndyakov](https://github.com/ndyakov) +- **`CLUSTER SHARDS` forward compatibility**: Unknown shard- and node-level attributes in the `CLUSTER SHARDS` reply are now skipped via `DiscardNext()` instead of erroring, so clients keep working when the server introduces new fields ([#3843](https://github.com/redis/go-redis/pull/3843)) by [@madolson](https://github.com/madolson) +- **PubSub double reconnect**: `PubSub.releaseConn` no longer reconnects twice when a connection is both unusable (or pending handoff) and reports a bad-connection error, avoiding a wasted connection establish-then-close cycle ([#3833](https://github.com/redis/go-redis/pull/3833)) by [@cxljs](https://github.com/cxljs) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@cxljs](https://github.com/cxljs), [@madolson](https://github.com/madolson), [@ndyakov](https://github.com/ndyakov) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.20.0...v9.20.1 + +# 9.20.0 (2026-05-28) + +## 🚀 Highlights + +### Redis 8.8 Support + +This release adds support for **Redis 8.8**. The README's supported-versions list now includes Redis 8.8 alongside 8.0/8.2/8.4, and CI exercises the `8.8-rc1` client-libs-test image across the full suite (Makefile, build workflow, doctests, run-tests action, and docker-compose). + +Coverage for the new commands that ship in the 8.x line, rounded out in this release: + +- **`AR*` array data type** ([#3813](https://github.com/redis/go-redis/pull/3813)) — new array data structure, exposed via the `ArrayCmdable` interface (see the experimental-features highlight below). +- **`INCREX`** ([#3816](https://github.com/redis/go-redis/pull/3816)) — atomic increment with expiration in a single round-trip. +- **`XNACK`** ([#3790](https://github.com/redis/go-redis/pull/3790)) — explicit negative-acknowledge of pending stream entries. +- **`XAUTOCLAIM` PEL deletes** ([#3798](https://github.com/redis/go-redis/pull/3798)) — `XAUTOCLAIM`/`XAUTOCLAIMJUSTID` now return the list of deleted message IDs from the pending entries list. +- **`TS.RANGE` multiple aggregators** ([#3791](https://github.com/redis/go-redis/pull/3791)) — `TS.RANGE`/`TS.REVRANGE`/`TS.MRANGE`/`TS.MREVRANGE` accept multiple aggregators in a single call. +- **`Z(UNION|INTER|DIFF)` `COUNT` aggregator** ([#3802](https://github.com/redis/go-redis/pull/3802)) — `COUNT` reducer for sorted-set set operations. +- **`JSON.SET FPHA`** ([#3797](https://github.com/redis/go-redis/pull/3797)) — new `FPHA` argument that specifies the floating-point type for homogeneous FP arrays. + +CI image bump ([#3814](https://github.com/redis/go-redis/pull/3814)) by [@ofekshenawa](https://github.com/ofekshenawa). Command coverage contributions by [@cxljs](https://github.com/cxljs), [@elena-kolevska](https://github.com/elena-kolevska), [@Khukharr](https://github.com/Khukharr), [@ndyakov](https://github.com/ndyakov), and [@ofekshenawa](https://github.com/ofekshenawa). + +### Stable RESP3 for RediSearch (`UnstableResp3` deprecated) + +`FT.SEARCH`, `FT.AGGREGATE`, `FT.INFO`, `FT.SPELLCHECK`, and `FT.SYNDUMP` now parse RESP3 (map) responses into the same typed result objects as RESP2 — `Val()` and `Result()` work uniformly on both protocols, no flag required. Previously, RESP3 search responses required `UnstableResp3: true` and were returned as opaque maps accessible only via `RawResult()` / `RawVal()`. + +As a result, the `UnstableResp3` option is now a **no-op** across every options struct (`Options`, `ClusterOptions`, `UniversalOptions`, `FailoverOptions`, `RingOptions`) and has been marked `// Deprecated:`. The field is retained for backwards compatibility — existing code that sets `UnstableResp3: true` will continue to compile and behave identically — but it will be removed in a future release and new code should not set it. `RawResult()` / `RawVal()` continue to work for callers that prefer the raw RESP payload. + +([#3741](https://github.com/redis/go-redis/pull/3741)) by [@ndyakov](https://github.com/ndyakov) + +### Experimental Array Data Structure Commands + +Adds an experimental `ArrayCmdable` interface with the `AR*` command family (`ARSet`, `ARGet`, `ARGetRange`, `ARMSet`, `ARMGet`, `ARDel`, `ARDelRange`, `ARScan`, `ARSeek`, `ARNext`, `ARLastItems`, `ARGrep`, `ARGrepWithValues`, `ARInfo`/`ARInfoFull`, and typed reducers `AROpSum`/`AROpMin`/`AROpMax`/`AROpAnd`/`AROpOr`/`AROpXor`/`AROpMatch`/`AROpUsed`) for working with Redis 8.8's new array data type. **API is experimental and may change in a future release.** + +([#3813](https://github.com/redis/go-redis/pull/3813)) by [@cxljs](https://github.com/cxljs) + +## ✨ New Features + +- **RESP3 search parser**: First-class RESP3 parsing for `FT.SEARCH`/`FT.AGGREGATE`/`FT.INFO`/`FT.SPELLCHECK`/`FT.SYNDUMP` responses with backwards compatibility for RESP2 ([#3741](https://github.com/redis/go-redis/pull/3741)) by [@ndyakov](https://github.com/ndyakov) +- **INCREX**: New `INCREX` command support — atomic increment with expiration ([#3816](https://github.com/redis/go-redis/pull/3816)) by [@ndyakov](https://github.com/ndyakov) +- **XNACK**: Client support for the `XNACK` stream command for explicitly negative-acknowledging pending entries ([#3790](https://github.com/redis/go-redis/pull/3790)) by [@elena-kolevska](https://github.com/elena-kolevska) +- **TS range multiple aggregators**: `TS.RANGE`/`TS.REVRANGE`/`TS.MRANGE`/`TS.MREVRANGE` now accept multiple aggregators in a single call ([#3791](https://github.com/redis/go-redis/pull/3791)) by [@elena-kolevska](https://github.com/elena-kolevska) +- **`XAutoClaim` deleted IDs**: `XAUTOCLAIM`/`XAUTOCLAIMJUSTID` now return the list of deleted message IDs from the PEL ([#3798](https://github.com/redis/go-redis/pull/3798)) by [@Khukharr](https://github.com/Khukharr) +- **`JSON.SET FPHA`**: `JSON.SET` accepts a new `FPHA` argument that specifies the floating-point type for homogeneous floating-point arrays ([#3797](https://github.com/redis/go-redis/pull/3797)) by [@ndyakov](https://github.com/ndyakov) +- **Sorted-set union/intersection COUNT**: `ZUNION`/`ZINTER`/`ZDIFF` aggregator now supports `COUNT` ([#3802](https://github.com/redis/go-redis/pull/3802)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **`FT.HYBRID` vector validation**: Validates hybrid-search vector input types and adds proper typed vector parameters ([#3756](https://github.com/redis/go-redis/pull/3756)) by [@DengY11](https://github.com/DengY11) +- **Cluster pool wait stats**: `ClusterClient.PoolStats()` now accumulates `WaitCount` and `WaitDurationNs` across all node pools (previously always zero) ([#3809](https://github.com/redis/go-redis/pull/3809)) by [@LINKIWI](https://github.com/LINKIWI) + +## 🐛 Bug Fixes + +- **TLS-only Cluster PubSub**: `CLUSTER SLOTS` port-0 entries now fall back to the origin endpoint's port, fixing `dial tcp :0: connection refused` on TLS-only clusters started with `--port 0 --tls-port ` (fixes [#3726](https://github.com/redis/go-redis/issues/3726)) ([#3828](https://github.com/redis/go-redis/pull/3828)) by [@ndyakov](https://github.com/ndyakov) +- **Sharded PubSub reconnect routing**: `PubSub.conn()` now passes both regular (`c.channels`) and sharded (`c.schannels`) channels into the per-PubSub `newConn` closure. Previously, `ClusterClient.SSubscribe`-only PubSubs reconnected to a random node (because the routing closure saw an empty channel list), the `SSUBSCRIBE` was sent to the wrong shard, and the resulting `MOVED` reply was silently dropped ([#3829](https://github.com/redis/go-redis/pull/3829)) by [@ndyakov](https://github.com/ndyakov) +- **ClusterClient `Watch` retry**: User errors returned from a `Watch` callback are no longer subjected to cluster-retry classification; transient cluster errors still retry, but a callback returning e.g. `net.ErrClosed` short-circuits immediately ([#3821](https://github.com/redis/go-redis/pull/3821)) by [@obiyang](https://github.com/obiyang) +- **Sentinel concurrent-probe leak**: `MasterAddr`'s concurrent sentinel probe now closes the non-winning sentinel clients instead of leaking them ([#3827](https://github.com/redis/go-redis/pull/3827)) by [@cxljs](https://github.com/cxljs) +- **Sentinel rediscovery loop on master-only setups**: `replicaAddrs` no longer tears down the cached sentinel client when the replica list is empty, eliminating a continuous rediscovery loop on master-only Sentinel deployments that flooded logs and added per-operation latency ([#3795](https://github.com/redis/go-redis/pull/3795)) by [@shahyash2609](https://github.com/shahyash2609) +- **Pool `CloseConn` hooks**: `Pool.CloseConn` now triggers registered hooks, fixing a memory leak when connections are closed explicitly rather than via the normal removal path ([#3818](https://github.com/redis/go-redis/pull/3818)) by [@ndyakov](https://github.com/ndyakov) +- **Dial TCP error redirection**: Wrapped `dial tcp` errors are now correctly classified as redirectable so cluster routing can recover from a single unreachable node ([#3810](https://github.com/redis/go-redis/pull/3810)) by [@vladisa88](https://github.com/vladisa88) +- **Pool `Close` health checks**: `ConnPool.Close` now only runs health checks against idle connections, avoiding spurious activity on connections still in use ([#3805](https://github.com/redis/go-redis/pull/3805)) by [@ndyakov](https://github.com/ndyakov) +- **VLinks return type**: Fixed the return type of `VLINKS`/`VLINKSWITHSCORES` vector-set replies ([#3820](https://github.com/redis/go-redis/pull/3820)) by [@romanpovol](https://github.com/romanpovol) + +## 🧪 Testing & Infrastructure + +- **Flaky tests**: Stabilized several flaky tests in the sentinel and pool suites ([#3815](https://github.com/redis/go-redis/pull/3815)) by [@ndyakov](https://github.com/ndyakov) +- **Sentinel failover metric race**: Fixed a data race in the sentinel failover metric test ([#3824](https://github.com/redis/go-redis/pull/3824)) by [@cxljs](https://github.com/cxljs) +- **`waitForSentinelClusterStable` post-conditions**: The sentinel test harness now waits for replicas to be fully connected (not just present in the count) and is robust to randomized spec ordering after failover specs, eliminating an intermittent `Expected master to equal slave` flake ([#3830](https://github.com/redis/go-redis/pull/3830)) by [@ndyakov](https://github.com/ndyakov) +- **`govulncheck` workflow**: New scheduled GitHub Actions workflow runs `govulncheck` on every push, PR, and weekly, surfacing newly disclosed Go vulnerabilities even when no code changes ([#3779](https://github.com/redis/go-redis/pull/3779)) by [@solardome](https://github.com/solardome) +- **CI Redis 8.8-rc1**: CI now exercises the 8.8-rc1 Redis image ([#3814](https://github.com/redis/go-redis/pull/3814)) by [@ofekshenawa](https://github.com/ofekshenawa) + +## 🧰 Maintenance + +- **`Cmd.Slot()` lookup refactor**: Caches the per-command `CommandInfo` and short-circuits keyless commands before the switch dispatch, removing redundant `Peek` calls ([#3804](https://github.com/redis/go-redis/pull/3804)) by [@retr0-kernel](https://github.com/retr0-kernel) +- **stdlib `math/rand`**: Replaced `internal/rand` with `math/rand` from the standard library now that the minimum Go version is 1.24 ([#3823](https://github.com/redis/go-redis/pull/3823)) by [@cxljs](https://github.com/cxljs) +- **ConnPool queue channel**: Removed the unused queue channel from `ConnPool`, trimming the pool's footprint ([#3826](https://github.com/redis/go-redis/pull/3826)) by [@cxljs](https://github.com/cxljs) +- **Extra packages LICENSE**: Added a LICENSE file to each `extra/*` package ([#3817](https://github.com/redis/go-redis/pull/3817)) by [@ndyakov](https://github.com/ndyakov) +- **README & CI image**: Documentation refresh and bumped the default CI image tag ([#3822](https://github.com/redis/go-redis/pull/3822)) by [@ndyakov](https://github.com/ndyakov) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@cxljs](https://github.com/cxljs), [@DengY11](https://github.com/DengY11), [@elena-kolevska](https://github.com/elena-kolevska), [@Khukharr](https://github.com/Khukharr), [@LINKIWI](https://github.com/LINKIWI), [@ndyakov](https://github.com/ndyakov), [@obiyang](https://github.com/obiyang), [@ofekshenawa](https://github.com/ofekshenawa), [@retr0-kernel](https://github.com/retr0-kernel), [@romanpovol](https://github.com/romanpovol), [@shahyash2609](https://github.com/shahyash2609), [@solardome](https://github.com/solardome), [@vladisa88](https://github.com/vladisa88) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.19.0...v9.20.0 + +# 9.19.0 (2026-04-27) + +## 🚀 Highlights + +### FIPS-Compatible Script Helper + +`Script` now supports a FIPS-safe execution mode that avoids client-side SHA-1 computation, which is blocked in strict FIPS environments. A new `NewScriptServerSHA` constructor uses `SCRIPT LOAD` to obtain and cache the digest from the server, then runs commands via `EVALSHA`/`EVALSHA_RO`. Falls back to `EVAL`/`EVALRO` if loading fails, and transparently retries once on `NOSCRIPT`. The default behavior is unchanged for existing users. + +([#3700](https://github.com/redis/go-redis/pull/3700)) by [@chaitanyabodlapati](https://github.com/chaitanyabodlapati) + +### FT.AGGREGATE Step-Based Pipeline Builder + +Added a new step-based `FT.AGGREGATE` pipeline API via `FTAggregateOptions.Steps`, allowing `LOAD`, `APPLY`, `GROUPBY`, and `SORTBY` (with per-step `MAX`) to be repeated and interleaved in arbitrary order — matching Redis's native multi-stage aggregation semantics. The legacy `Load`/`Apply`/`GroupBy`/`SortBy`/`SortByMax` fields are now deprecated. + +([#3782](https://github.com/redis/go-redis/pull/3782)) by [@ndyakov](https://github.com/ndyakov) + +### Raw RESP Protocol Access + +Added `DoRaw` and `DoRawWriteTo` methods for executing arbitrary commands and reading the raw RESP response. Useful for proxying, custom protocol inspection, and working with commands not yet wrapped by go-redis. + +([#3713](https://github.com/redis/go-redis/pull/3713)) by [@ofekshenawa](https://github.com/ofekshenawa) + +### Configurable Dial Retry Backoff + +Added `DialerRetryBackoff` option (plumbed through `Options`, `ClusterOptions`, `RingOptions`, `FailoverOptions`) to let callers customize the delay between failed dial attempts. Helpers `DialRetryBackoffConstant` and `DialRetryBackoffExponential` (with jitter and cap) are provided out of the box. Dial timeout is now also applied **per attempt** rather than across all retries. + +([#3706](https://github.com/redis/go-redis/pull/3706), [#3705](https://github.com/redis/go-redis/pull/3705)) by [@mwhooker](https://github.com/mwhooker) + +## ✨ New Features + +- **FT.AGGREGATE Steps**: Step-based pipeline builder for `FT.AGGREGATE` with support for repeated/interleaved `LOAD`, `APPLY`, `GROUPBY`, and `SORTBY` stages ([#3782](https://github.com/redis/go-redis/pull/3782)) by [@ndyakov](https://github.com/ndyakov) +- **VectorSet commands**: Added `VISMEMBER` and `WITHATTRIBS` support ([#3753](https://github.com/redis/go-redis/pull/3753)) by [@romanpovol](https://github.com/romanpovol) +- **FIPS-safe Script**: `NewScriptServerSHA` uses `SCRIPT LOAD` to obtain the digest from the server, avoiding client-side SHA-1 ([#3700](https://github.com/redis/go-redis/pull/3700)) by [@chaitanyabodlapati](https://github.com/chaitanyabodlapati) +- **Raw RESP access**: `DoRaw` and `DoRawWriteTo` for raw RESP protocol access ([#3713](https://github.com/redis/go-redis/pull/3713)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **Dial retry backoff**: `DialerRetryBackoff` function option with constant and exponential helpers ([#3706](https://github.com/redis/go-redis/pull/3706)) by [@mwhooker](https://github.com/mwhooker) +- **Typed NOSCRIPT error**: Redis `NOSCRIPT` replies are now surfaced as a typed error for easier handling ([#3738](https://github.com/redis/go-redis/pull/3738)) by [@LINKIWI](https://github.com/LINKIWI) +- **PubSub ClientSetName**: Added `ClientSetName` method to `PubSub` ([#3727](https://github.com/redis/go-redis/pull/3727)) by [@Flack74](https://github.com/Flack74) +- **ReplicaOf**: New `ReplicaOf` method replaces the deprecated `SlaveOf` ([#3720](https://github.com/redis/go-redis/pull/3720)) by [@Copilot](https://github.com/apps/copilot-swe-agent) +- **HSCAN BinaryUnmarshaler**: `HScan` now supports types implementing `encoding.BinaryUnmarshaler` ([#3768](https://github.com/redis/go-redis/pull/3768)) by [@Aaditya-dubey1](https://github.com/Aaditya-dubey1) + +## 🐛 Bug Fixes + +- **Auto hostname type detection**: Improved endpoint type detection for maintenance notifications using DNS-based classification; handles empty hosts and expanded private-IP ranges ([#3789](https://github.com/redis/go-redis/pull/3789)) by [@ndyakov](https://github.com/ndyakov) +- **HELLO fallback**: Don't send `CLIENT MAINT_NOTIFICATIONS` handshake when `HELLO` fails and connection falls back to RESP2; fail fast when explicitly enabled with RESP3 ([#3788](https://github.com/redis/go-redis/pull/3788)) by [@ndyakov](https://github.com/ndyakov) +- **Dial TCP retry**: `ShouldRetry` now treats `net.OpError` with `Op == "dial"` timeout errors as safe to retry since no command was sent ([#3787](https://github.com/redis/go-redis/pull/3787)) by [@vladisa88](https://github.com/vladisa88) +- **wrappedOnClose leak**: Fixed resource leak caused by repeatedly wrapping `baseClient` close logic; replaced with a bounded, concurrency-safe named-hook registry ([#3785](https://github.com/redis/go-redis/pull/3785)) by [@ndyakov](https://github.com/ndyakov) +- **Pool Close() on stale connections**: Suppress close errors (e.g., TLS `closeNotify` timeouts) for connections already dropped by the server due to idle timeout ([#3778](https://github.com/redis/go-redis/pull/3778)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **FIFO waiter ordering**: Fixed race in `ConnStateMachine.notifyWaiters` that could wake multiple waiters under a single mutex hold and violate FIFO ordering ([#3777](https://github.com/redis/go-redis/pull/3777)) by [@0x48core](https://github.com/0x48core) +- **Lua READONLY detection**: Detect `READONLY` errors embedded in Lua script error messages on read-only replicas so commands are correctly retried ([#3769](https://github.com/redis/go-redis/pull/3769)) by [@zhengjilei](https://github.com/zhengjilei) +- **VectorScoreSliceCmd RESP2**: Fixed `VSimWithScores`, `VSimWithArgsWithScores`, and `VLinksWithScores` which were broken on RESP2 connections returning flat arrays instead of maps ([#3767](https://github.com/redis/go-redis/pull/3767)) by [@Copilot](https://github.com/apps/copilot-swe-agent) +- **Closed connection handling**: Two fixes for closed connection handling in the pool ([#3764](https://github.com/redis/go-redis/pull/3764)) by [@cxljs](https://github.com/cxljs) +- **ZRangeArgs Rev**: Fixed `ZRangeArgs` with `Rev` + `ByScore`/`ByLex` incorrectly swapping `Start`/`Stop`, breaking `ZRANGESTORE` ([#3751](https://github.com/redis/go-redis/pull/3751)) by [@Copilot](https://github.com/apps/copilot-swe-agent) +- **OTel metric instrument types**: Fixed metric instrument types in `redisotel-native` ([#3743](https://github.com/redis/go-redis/pull/3743)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **Options.clone() data race**: Fixed data race when cloning `Options` ([#3739](https://github.com/redis/go-redis/pull/3739)) by [@rubensayshi](https://github.com/rubensayshi) +- **Connection closure metrics**: Fixed connection closure metrics and enabled all metric groups by default in `redisotel-native` ([#3735](https://github.com/redis/go-redis/pull/3735)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **OTel semconv v1.38.0**: Use metric definition from `otel/semconv/v1.38.0` in `redisotel-native` ([#3731](https://github.com/redis/go-redis/pull/3731)) by [@wzy9607](https://github.com/wzy9607) +- **SETNX semantics**: Use `SET ... NX` instead of the deprecated `SETNX` command ([#3723](https://github.com/redis/go-redis/pull/3723)) by [@ndyakov](https://github.com/ndyakov) +- **TIME keyless routing**: Mark `TIME` as a keyless command for correct cluster routing ([#3722](https://github.com/redis/go-redis/pull/3722)) by [@fatal10110](https://github.com/fatal10110) +- **Dial timeout per retry**: Dial timeout now applies per attempt instead of across all retry attempts combined ([#3705](https://github.com/redis/go-redis/pull/3705)) by [@mwhooker](https://github.com/mwhooker) +- **Cluster metrics attributes**: Fixed `pool.name` being appended per node, which corrupted and dropped user-provided custom attributes ([#3699](https://github.com/redis/go-redis/pull/3699)) by [@Jesse-Bonfire](https://github.com/Jesse-Bonfire) +- **initConn nil dereference**: Fixed nil pointer dereference and potential deadlock in `*baseClient.initConn()`; added explicit nil option guards to client constructors ([#3676](https://github.com/redis/go-redis/pull/3676)) by [@olde-ducke](https://github.com/olde-ducke) + +## ⚡ Performance + +- **RESP reader**: Optimized RESP reader by eliminating intermediate string allocations ([#3774](https://github.com/redis/go-redis/pull/3774)) by [@Aaditya-dubey1](https://github.com/Aaditya-dubey1) +- **Inline rendezvous hashing**: Replaced `github.com/dgryski/go-rendezvous` dependency with an in-repo implementation in `internal/hashtag`, reducing the dependency graph while preserving algorithm parity ([#3762](https://github.com/redis/go-redis/pull/3762)) by [@bigsk05](https://github.com/bigsk05) + +## 🧪 Testing & Infrastructure + +- **Release automation**: Added `repository`, `ref`, and `client-libs-test-image-tag` inputs to the `run-tests` composite action; `redis-version` is now optional so unstable builds use `REDIS_VERSION` from the Makefile ([#3749](https://github.com/redis/go-redis/pull/3749)) by [@dariaguy](https://github.com/dariaguy) +- **Go 1.24**: Updated minimum Go version to 1.24 and use `-compat=1.24` in release scripts ([#3714](https://github.com/redis/go-redis/pull/3714), [#3754](https://github.com/redis/go-redis/pull/3754)) by [@ndyakov](https://github.com/ndyakov), [@cxljs](https://github.com/cxljs) + +## 🧰 Maintenance + +- **Pool state machine**: Removed redundant `Conn.closed` atomic field in favor of the state machine's `StateClosed` ([#3783](https://github.com/redis/go-redis/pull/3783)) by [@cxljs](https://github.com/cxljs) +- **OTel SDK**: Updated OpenTelemetry SDK dependencies in `redisotel`/`redisotel-native` ([#3770](https://github.com/redis/go-redis/pull/3770)) by [@ndyakov](https://github.com/ndyakov) +- **Go 1.21+ built-ins**: Use `maps.Keys`, `slices.Collect`, `slices.Contains`, `clear()`, and `slices.SortFunc` instead of custom helpers ([#3758](https://github.com/redis/go-redis/pull/3758), [#3746](https://github.com/redis/go-redis/pull/3746)) by [@cxljs](https://github.com/cxljs) +- **HGetAll docs**: Added Go doc comment to `HGetAll` describing behavior and complexity ([#3776](https://github.com/redis/go-redis/pull/3776)) by [@0x48core](https://github.com/0x48core) +- **Docs links**: Fixed irrelevant docs links ([#3724](https://github.com/redis/go-redis/pull/3724)) by [@olzhas-sabiyev](https://github.com/olzhas-sabiyev) +- **Examples cleanup**: Removed throughput binary from examples ([#3733](https://github.com/redis/go-redis/pull/3733)) by [@ndyakov](https://github.com/ndyakov) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@0x48core](https://github.com/0x48core), [@Aaditya-dubey1](https://github.com/Aaditya-dubey1), [@Copilot](https://github.com/apps/copilot-swe-agent), [@Flack74](https://github.com/Flack74), [@Jesse-Bonfire](https://github.com/Jesse-Bonfire), [@LINKIWI](https://github.com/LINKIWI), [@bigsk05](https://github.com/bigsk05), [@chaitanyabodlapati](https://github.com/chaitanyabodlapati), [@cxljs](https://github.com/cxljs), [@dariaguy](https://github.com/dariaguy), [@fatal10110](https://github.com/fatal10110), [@mwhooker](https://github.com/mwhooker), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@olde-ducke](https://github.com/olde-ducke), [@olzhas-sabiyev](https://github.com/olzhas-sabiyev), [@romanpovol](https://github.com/romanpovol), [@rubensayshi](https://github.com/rubensayshi), [@vladisa88](https://github.com/vladisa88), [@wzy9607](https://github.com/wzy9607), [@zhengjilei](https://github.com/zhengjilei) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.18.0...v9.19.0 + +# 9.18.0 (2026-02-16) + +## 🚀 Highlights + +### Redis 8.6 Support + +Added support for Redis 8.6, including new commands and features for streams idempotent production and HOTKEYS. + +### Smart Client Handoff (Maintenance Notifications) for Cluster + +This release introduces comprehensive support for Redis Cluster maintenance notifications via SMIGRATING/SMIGRATED push notifications. The client now automatically handles slot migrations by: +- **Relaxing timeouts during migration** (SMIGRATING) to prevent false failures +- **Triggering lazy cluster state reloads** upon completion (SMIGRATED) +- Enabling seamless operations during Redis Enterprise maintenance windows + +([#3643](https://github.com/redis/go-redis/pull/3643)) by [@ndyakov](https://github.com/ndyakov) + +### OpenTelemetry Native Metrics Support + +Added comprehensive OpenTelemetry metrics support following the [OpenTelemetry Database Client Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/database/database-metrics/). The implementation uses a Bridge Pattern to keep the core library dependency-free while providing optional metrics instrumentation through the new `extra/redisotel-native` package. + +**Metric groups include:** +- Command metrics: Operation duration with retry tracking +- Connection basic: Connection count and creation time +- Resiliency: Errors, handoffs, timeout relaxation +- Connection advanced: Wait time and use time +- Pubsub metrics: Published and received messages +- Stream metrics: Processing duration and maintenance notifications + +([#3637](https://github.com/redis/go-redis/pull/3637)) by [@ofekshenawa](https://github.com/ofekshenawa) + +## ✨ New Features + +- **HOTKEYS Commands**: Added support for Redis HOTKEYS feature for identifying hot keys based on CPU consumption and network utilization ([#3695](https://github.com/redis/go-redis/pull/3695)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **Streams Idempotent Production**: Added support for Redis 8.6+ Streams Idempotent Production with `ProducerID`, `IdempotentID`, `IdempotentAuto` in `XAddArgs` and new `XCFGSET` command ([#3693](https://github.com/redis/go-redis/pull/3693)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **NaN Values for TimeSeries**: Added support for NaN (Not a Number) values in Redis time series commands ([#3687](https://github.com/redis/go-redis/pull/3687)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **DialerRetries Options**: Added `DialerRetries` and `DialerRetryTimeout` to `ClusterOptions`, `RingOptions`, and `FailoverOptions` ([#3686](https://github.com/redis/go-redis/pull/3686)) by [@naveenchander30](https://github.com/naveenchander30) +- **ConnMaxLifetimeJitter**: Added jitter configuration to distribute connection expiration times and prevent thundering herd ([#3666](https://github.com/redis/go-redis/pull/3666)) by [@cyningsun](https://github.com/cyningsun) +- **Digest Helper Functions**: Added `DigestString` and `DigestBytes` helper functions for client-side xxh3 hashing compatible with Redis DIGEST command ([#3679](https://github.com/redis/go-redis/pull/3679)) by [@ofekshenawa](https://github.com/ofekshenawa) +- **SMIGRATED New Format**: Updated SMIGRATED parser to support new format and remember original host:port ([#3697](https://github.com/redis/go-redis/pull/3697)) by [@ndyakov](https://github.com/ndyakov) +- **Cluster State Reload Interval**: Added cluster state reload interval option for maintenance notifications ([#3663](https://github.com/redis/go-redis/pull/3663)) by [@ndyakov](https://github.com/ndyakov) + +## 🐛 Bug Fixes + +- **PubSub nil pointer dereference**: Fixed nil pointer dereference in PubSub after `WithTimeout()` - `pubSubPool` is now properly cloned ([#3710](https://github.com/redis/go-redis/pull/3710)) by [@Copilot](https://github.com/apps/copilot-swe-agent) +- **MaintNotificationsConfig nil check**: Guard against nil `MaintNotificationsConfig` in `initConn` ([#3707](https://github.com/redis/go-redis/pull/3707)) by [@veeceey](https://github.com/veeceey) +- **wantConnQueue zombie elements**: Fixed zombie `wantConn` elements accumulation in `wantConnQueue` ([#3680](https://github.com/redis/go-redis/pull/3680)) by [@cyningsun](https://github.com/cyningsun) +- **XADD/XTRIM approx flag**: Fixed XADD and XTRIM to use `=` when approx is false ([#3684](https://github.com/redis/go-redis/pull/3684)) by [@ndyakov](https://github.com/ndyakov) +- **Sentinel timeout retry**: When connection to a sentinel times out, attempt to connect to other sentinels ([#3654](https://github.com/redis/go-redis/pull/3654)) by [@cxljs](https://github.com/cxljs) + +## ⚡ Performance + +- **Fuzz test optimization**: Eliminated repeated string conversions, used functional approach for cleaner operation selection ([#3692](https://github.com/redis/go-redis/pull/3692)) by [@feiguoL](https://github.com/feiguoL) +- **Pre-allocate capacity**: Pre-allocate slice capacity to prevent multiple capacity expansions ([#3689](https://github.com/redis/go-redis/pull/3689)) by [@feelshu](https://github.com/feelshu) + +## 🧪 Testing + +- **Comprehensive TLS tests**: Added comprehensive TLS tests and example for standalone, cluster, and certificate authentication ([#3681](https://github.com/redis/go-redis/pull/3681)) by [@ndyakov](https://github.com/ndyakov) +- **Redis 8.6**: Updated CI to use Redis 8.6-pre ([#3685](https://github.com/redis/go-redis/pull/3685)) by [@ndyakov](https://github.com/ndyakov) + +## 🧰 Maintenance + +- **Deprecation warnings**: Added deprecation warnings for commands based on Redis documentation ([#3673](https://github.com/redis/go-redis/pull/3673)) by [@ndyakov](https://github.com/ndyakov) +- **Use errors.Join()**: Replaced custom error join function with standard library `errors.Join()` ([#3653](https://github.com/redis/go-redis/pull/3653)) by [@cxljs](https://github.com/cxljs) +- **Use Go 1.21 min/max**: Use Go 1.21's built-in min/max functions ([#3656](https://github.com/redis/go-redis/pull/3656)) by [@cxljs](https://github.com/cxljs) +- **Proper formatting**: Code formatting improvements ([#3670](https://github.com/redis/go-redis/pull/3670)) by [@12ya](https://github.com/12ya) +- **Set commands documentation**: Added comprehensive documentation to all set command methods ([#3642](https://github.com/redis/go-redis/pull/3642)) by [@iamamirsalehi](https://github.com/iamamirsalehi) +- **MaxActiveConns docs**: Added default value documentation for `MaxActiveConns` ([#3674](https://github.com/redis/go-redis/pull/3674)) by [@codykaup](https://github.com/codykaup) +- **README example update**: Updated README example ([#3657](https://github.com/redis/go-redis/pull/3657)) by [@cxljs](https://github.com/cxljs) +- **Cluster maintnotif example**: Added example application for cluster maintenance notifications ([#3651](https://github.com/redis/go-redis/pull/3651)) by [@ndyakov](https://github.com/ndyakov) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@12ya](https://github.com/12ya), [@Copilot](https://github.com/apps/copilot-swe-agent), [@codykaup](https://github.com/codykaup), [@cxljs](https://github.com/cxljs), [@cyningsun](https://github.com/cyningsun), [@feelshu](https://github.com/feelshu), [@feiguoL](https://github.com/feiguoL), [@iamamirsalehi](https://github.com/iamamirsalehi), [@naveenchander30](https://github.com/naveenchander30), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@veeceey](https://github.com/veeceey) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.17.0...v9.18.0 + +# 9.18.0-beta.2 (2025-12-09) + +## 🚀 Highlights + +### Go Version Update + +This release updates the minimum required Go version to 1.21. This is part of a gradual migration strategy where the minimum supported Go version will be three versions behind the latest release. With each new Go version release, we will bump the minimum version by one, ensuring compatibility while staying current with the Go ecosystem. + +### Stability Improvements + +This release includes several important stability fixes: +- Fixed a critical panic in the handoff worker manager that could occur when handling nil errors +- Improved test reliability for Smart Client Handoff functionality +- Fixed logging format issues that could cause runtime errors + +## ✨ New Features + +- OpenTelemetry metrics improvements for nil response handling ([#3638](https://github.com/redis/go-redis/pull/3638)) by [@fengve](https://github.com/fengve) + +## 🐛 Bug Fixes + +- Fixed panic on nil error in handoffWorkerManager closeConnFromRequest ([#3633](https://github.com/redis/go-redis/pull/3633)) by [@ccoVeille](https://github.com/ccoVeille) +- Fixed bad sprintf syntax in logging ([#3632](https://github.com/redis/go-redis/pull/3632)) by [@ccoVeille](https://github.com/ccoVeille) + +## 🧰 Maintenance + +- Updated minimum Go version to 1.21 ([#3640](https://github.com/redis/go-redis/pull/3640)) by [@ndyakov](https://github.com/ndyakov) +- Use Go 1.20 idiomatic string<->byte conversion ([#3435](https://github.com/redis/go-redis/pull/3435)) by [@justinhwang](https://github.com/justinhwang) +- Reduce flakiness of Smart Client Handoff test ([#3641](https://github.com/redis/go-redis/pull/3641)) by [@kiryazovi-redis](https://github.com/kiryazovi-redis) +- Revert PR #3634 (Observability metrics phase1) ([#3635](https://github.com/redis/go-redis/pull/3635)) by [@ofekshenawa](https://github.com/ofekshenawa) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@justinhwang](https://github.com/justinhwang), [@ndyakov](https://github.com/ndyakov), [@kiryazovi-redis](https://github.com/kiryazovi-redis), [@fengve](https://github.com/fengve), [@ccoVeille](https://github.com/ccoVeille), [@ofekshenawa](https://github.com/ofekshenawa) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.18.0-beta.1...v9.18.0-beta.2 + +# 9.18.0-beta.1 (2025-12-01) + +## 🚀 Highlights + +### Request and Response Policy Based Routing in Cluster Mode + +This beta release introduces comprehensive support for Redis COMMAND-based request and response policy routing for cluster clients. This feature enables intelligent command routing and response aggregation based on Redis command metadata. + +**Key Features:** +- **Command Policy Loader**: Automatically parses and caches COMMAND metadata with routing/aggregation hints +- **Enhanced Routing Engine**: Supports all request policies including: + - `default(keyless)` - Commands without keys + - `default(hashslot)` - Commands with hash slot routing + - `all_shards` - Commands that need to run on all shards + - `all_nodes` - Commands that need to run on all nodes + - `multi_shard` - Commands that span multiple shards + - `special` - Commands with custom routing logic +- **Response Aggregator**: Intelligently combines multi-shard replies based on response policies: + - `all_succeeded` - All shards must succeed + - `one_succeeded` - At least one shard must succeed + - `agg_sum` - Aggregate numeric responses + - `special` - Custom aggregation logic (e.g., FT.CURSOR) +- **Raw Command Support**: Policies are enforced on `Client.Do(ctx, args...)` + +This feature is particularly useful for Redis Stack commands like RediSearch that need to operate across multiple shards in a cluster. + +### Connection Pool Improvements + +Fixed a critical defect in the connection pool's turn management mechanism that could lead to connection leaks under certain conditions. The fix ensures proper 1:1 correspondence between turns and connections. + +## ✨ New Features + +- Request and Response Policy Based Routing in Cluster Mode ([#3422](https://github.com/redis/go-redis/pull/3422)) by [@ofekshenawa](https://github.com/ofekshenawa) + +## 🐛 Bug Fixes + +- Fixed connection pool turn management to prevent connection leaks ([#3626](https://github.com/redis/go-redis/pull/3626)) by [@cyningsun](https://github.com/cyningsun) + +## 🧰 Maintenance + +- chore(deps): bump rojopolis/spellcheck-github-actions from 0.54.0 to 0.55.0 ([#3627](https://github.com/redis/go-redis/pull/3627)) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@cyningsun](https://github.com/cyningsun), [@ofekshenawa](https://github.com/ofekshenawa), [@ndyakov](https://github.com/ndyakov) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.17.1...v9.18.0-beta.1 + +# 9.17.1 (2025-11-25) + +## 🐛 Bug Fixes + +- add wait to keyless commands list ([#3615](https://github.com/redis/go-redis/pull/3615)) by [@marcoferrer](https://github.com/marcoferrer) +- fix(time): remove cached time optimization ([#3611](https://github.com/redis/go-redis/pull/3611)) by [@ndyakov](https://github.com/ndyakov) + +## 🧰 Maintenance + +- chore(deps): bump golangci/golangci-lint-action from 9.0.0 to 9.1.0 ([#3609](https://github.com/redis/go-redis/pull/3609)) +- chore(deps): bump actions/checkout from 5 to 6 ([#3610](https://github.com/redis/go-redis/pull/3610)) +- chore(script): fix help call in tag.sh ([#3606](https://github.com/redis/go-redis/pull/3606)) by [@ndyakov](https://github.com/ndyakov) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@marcoferrer](https://github.com/marcoferrer) and [@ndyakov](https://github.com/ndyakov) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.17.0...v9.17.1 + +# 9.17.0 (2025-11-19) + +## 🚀 Highlights + +### Redis 8.4 Support +Added support for Redis 8.4, including new commands and features ([#3572](https://github.com/redis/go-redis/pull/3572)) + +### Typed Errors +Introduced typed errors for better error handling using `errors.As` instead of string checks. Errors can now be wrapped and set to commands in hooks without breaking library functionality ([#3602](https://github.com/redis/go-redis/pull/3602)) + +### New Commands +- **CAS/CAD Commands**: Added support for Compare-And-Set/Compare-And-Delete operations with conditional matching (`IFEQ`, `IFNE`, `IFDEQ`, `IFDNE`) ([#3583](https://github.com/redis/go-redis/pull/3583), [#3595](https://github.com/redis/go-redis/pull/3595)) +- **MSETEX**: Atomically set multiple key-value pairs with expiration options and conditional modes ([#3580](https://github.com/redis/go-redis/pull/3580)) +- **XReadGroup CLAIM**: Consume both incoming and idle pending entries from streams in a single call ([#3578](https://github.com/redis/go-redis/pull/3578)) +- **ACL Commands**: Added `ACLGenPass`, `ACLUsers`, and `ACLWhoAmI` ([#3576](https://github.com/redis/go-redis/pull/3576)) +- **SLOWLOG Commands**: Added `SLOWLOG LEN` and `SLOWLOG RESET` ([#3585](https://github.com/redis/go-redis/pull/3585)) +- **LATENCY Commands**: Added `LATENCY LATEST` and `LATENCY RESET` ([#3584](https://github.com/redis/go-redis/pull/3584)) + +### Search & Vector Improvements +- **Hybrid Search**: Added **EXPERIMENTAL** support for the new `FT.HYBRID` command ([#3573](https://github.com/redis/go-redis/pull/3573)) +- **Vector Range**: Added `VRANGE` command for vector sets ([#3543](https://github.com/redis/go-redis/pull/3543)) +- **FT.INFO Enhancements**: Added vector-specific attributes in FT.INFO response ([#3596](https://github.com/redis/go-redis/pull/3596)) + +### Connection Pool Improvements +- **Improved Connection Success Rate**: Implemented FIFO queue-based fairness and context pattern for connection creation to prevent premature cancellation under high concurrency ([#3518](https://github.com/redis/go-redis/pull/3518)) +- **Connection State Machine**: Resolved race conditions and improved pool performance with proper state tracking ([#3559](https://github.com/redis/go-redis/pull/3559)) +- **Pool Performance**: Significant performance improvements with faster semaphores, lockless hook manager, and reduced allocations (47-67% faster Get/Put operations) ([#3565](https://github.com/redis/go-redis/pull/3565)) + +### Metrics & Observability +- **Canceled Metric Attribute**: Added 'canceled' metrics attribute to distinguish context cancellation errors from other errors ([#3566](https://github.com/redis/go-redis/pull/3566)) + +## ✨ New Features + +- Typed errors with wrapping support ([#3602](https://github.com/redis/go-redis/pull/3602)) by [@ndyakov](https://github.com/ndyakov) +- CAS/CAD commands (marked as experimental) ([#3583](https://github.com/redis/go-redis/pull/3583), [#3595](https://github.com/redis/go-redis/pull/3595)) by [@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis) +- MSETEX command support ([#3580](https://github.com/redis/go-redis/pull/3580)) by [@ofekshenawa](https://github.com/ofekshenawa) +- XReadGroup CLAIM argument ([#3578](https://github.com/redis/go-redis/pull/3578)) by [@ofekshenawa](https://github.com/ofekshenawa) +- ACL commands: GenPass, Users, WhoAmI ([#3576](https://github.com/redis/go-redis/pull/3576)) by [@destinyoooo](https://github.com/destinyoooo) +- SLOWLOG commands: LEN, RESET ([#3585](https://github.com/redis/go-redis/pull/3585)) by [@destinyoooo](https://github.com/destinyoooo) +- LATENCY commands: LATEST, RESET ([#3584](https://github.com/redis/go-redis/pull/3584)) by [@destinyoooo](https://github.com/destinyoooo) +- Hybrid search command (FT.HYBRID) ([#3573](https://github.com/redis/go-redis/pull/3573)) by [@htemelski-redis](https://github.com/htemelski-redis) +- Vector range command (VRANGE) ([#3543](https://github.com/redis/go-redis/pull/3543)) by [@cxljs](https://github.com/cxljs) +- Vector-specific attributes in FT.INFO ([#3596](https://github.com/redis/go-redis/pull/3596)) by [@ndyakov](https://github.com/ndyakov) +- Improved connection pool success rate with FIFO queue ([#3518](https://github.com/redis/go-redis/pull/3518)) by [@cyningsun](https://github.com/cyningsun) +- Canceled metrics attribute for context errors ([#3566](https://github.com/redis/go-redis/pull/3566)) by [@pvragov](https://github.com/pvragov) + +## 🐛 Bug Fixes + +- Fixed Failover Client MaintNotificationsConfig ([#3600](https://github.com/redis/go-redis/pull/3600)) by [@ajax16384](https://github.com/ajax16384) +- Fixed ACLGenPass function to use the bit parameter ([#3597](https://github.com/redis/go-redis/pull/3597)) by [@destinyoooo](https://github.com/destinyoooo) +- Return error instead of panic from commands ([#3568](https://github.com/redis/go-redis/pull/3568)) by [@dragneelfps](https://github.com/dragneelfps) +- Safety harness in `joinErrors` to prevent panic ([#3577](https://github.com/redis/go-redis/pull/3577)) by [@manisharma](https://github.com/manisharma) + +## ⚡ Performance + +- Connection state machine with race condition fixes ([#3559](https://github.com/redis/go-redis/pull/3559)) by [@ndyakov](https://github.com/ndyakov) +- Pool performance improvements: 47-67% faster Get/Put, 33% less memory, 50% fewer allocations ([#3565](https://github.com/redis/go-redis/pull/3565)) by [@ndyakov](https://github.com/ndyakov) + +## 🧪 Testing & Infrastructure + +- Updated to Redis 8.4.0 image ([#3603](https://github.com/redis/go-redis/pull/3603)) by [@ndyakov](https://github.com/ndyakov) +- Added Redis 8.4-RC1-pre to CI ([#3572](https://github.com/redis/go-redis/pull/3572)) by [@ndyakov](https://github.com/ndyakov) +- Refactored tests for idiomatic Go ([#3561](https://github.com/redis/go-redis/pull/3561), [#3562](https://github.com/redis/go-redis/pull/3562), [#3563](https://github.com/redis/go-redis/pull/3563)) by [@12ya](https://github.com/12ya) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@12ya](https://github.com/12ya), [@ajax16384](https://github.com/ajax16384), [@cxljs](https://github.com/cxljs), [@cyningsun](https://github.com/cyningsun), [@destinyoooo](https://github.com/destinyoooo), [@dragneelfps](https://github.com/dragneelfps), [@htemelski-redis](https://github.com/htemelski-redis), [@manisharma](https://github.com/manisharma), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@pvragov](https://github.com/pvragov) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.16.0...v9.17.0 + +# 9.16.0 (2025-10-23) + +## 🚀 Highlights + +### Maintenance Notifications Support + +This release introduces comprehensive support for Redis maintenance notifications, enabling applications to handle server maintenance events gracefully. The new `maintnotifications` package provides: + +- **RESP3 Push Notifications**: Full support for Redis RESP3 protocol push notifications +- **Connection Handoff**: Automatic connection migration during server maintenance with configurable retry policies and circuit breakers +- **Graceful Degradation**: Configurable timeout relaxation during maintenance windows to prevent false failures +- **Event-Driven Architecture**: Background workers with on-demand scaling for efficient handoff processing +- **Production-Ready**: Comprehensive E2E testing framework and monitoring capabilities + +For detailed usage examples and configuration options, see the [maintenance notifications documentation](maintnotifications/README.md). + +## ✨ New Features + +- **Trace Filtering**: Add support for filtering traces for specific commands, including pipeline operations and dial operations ([#3519](https://github.com/redis/go-redis/pull/3519), [#3550](https://github.com/redis/go-redis/pull/3550)) + - New `TraceCmdFilter` option to selectively trace commands + - Reduces overhead by excluding high-frequency or low-value commands from traces + +## 🐛 Bug Fixes + +- **Pipeline Error Handling**: Fix issue where pipeline repeatedly sets the same error ([#3525](https://github.com/redis/go-redis/pull/3525)) +- **Connection Pool**: Ensure re-authentication does not interfere with connection handoff operations ([#3547](https://github.com/redis/go-redis/pull/3547)) + +## 🔧 Improvements + +- **Hash Commands**: Update hash command implementations ([#3523](https://github.com/redis/go-redis/pull/3523)) +- **OpenTelemetry**: Use `metric.WithAttributeSet` to avoid unnecessary attribute copying in redisotel ([#3552](https://github.com/redis/go-redis/pull/3552)) + +## 📚 Documentation + +- **Cluster Client**: Add explanation for why `MaxRetries` is disabled for `ClusterClient` ([#3551](https://github.com/redis/go-redis/pull/3551)) + +## 🧪 Testing & Infrastructure + +- **E2E Testing**: Upgrade E2E testing framework with improved reliability and coverage ([#3541](https://github.com/redis/go-redis/pull/3541)) +- **Release Process**: Improved resiliency of the release process ([#3530](https://github.com/redis/go-redis/pull/3530)) + +## 📦 Dependencies + +- Bump `rojopolis/spellcheck-github-actions` from 0.51.0 to 0.52.0 ([#3520](https://github.com/redis/go-redis/pull/3520)) +- Bump `github/codeql-action` from 3 to 4 ([#3544](https://github.com/redis/go-redis/pull/3544)) + +## 👥 Contributors + +We'd like to thank all the contributors who worked on this release! + +[@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis), [@Sovietaced](https://github.com/Sovietaced), [@Udhayarajan](https://github.com/Udhayarajan), [@boekkooi-impossiblecloud](https://github.com/boekkooi-impossiblecloud), [@Pika-Gopher](https://github.com/Pika-Gopher), [@cxljs](https://github.com/cxljs), [@huiyifyj](https://github.com/huiyifyj), [@omid-h70](https://github.com/omid-h70) + +--- + +**Full Changelog**: https://github.com/redis/go-redis/compare/v9.14.0...v9.16.0 + + +# 9.15.0 was accidentally released. Please use version 9.16.0 instead. + +# 9.15.0-beta.3 (2025-09-26) + +## Highlights +This beta release includes a pre-production version of processing push notifications and hitless upgrades. + +# Changes + +- chore: Update hash_commands.go ([#3523](https://github.com/redis/go-redis/pull/3523)) + +## 🚀 New Features + +- feat: RESP3 notifications support & Hitless notifications handling ([#3418](https://github.com/redis/go-redis/pull/3418)) + +## 🐛 Bug Fixes + +- fix: pipeline repeatedly sets the error ([#3525](https://github.com/redis/go-redis/pull/3525)) + +## 🧰 Maintenance + +- chore(deps): bump rojopolis/spellcheck-github-actions from 0.51.0 to 0.52.0 ([#3520](https://github.com/redis/go-redis/pull/3520)) +- feat(e2e-testing): maintnotifications e2e and refactor ([#3526](https://github.com/redis/go-redis/pull/3526)) +- feat(tag.sh): Improved resiliency of the release process ([#3530](https://github.com/redis/go-redis/pull/3530)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@cxljs](https://github.com/cxljs), [@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis), and [@omid-h70](https://github.com/omid-h70) + + +# 9.15.0-beta.1 (2025-09-10) + +## Highlights +This beta release includes a pre-production version of processing push notifications and hitless upgrades. + +### Hitless Upgrades +Hitless upgrades is a major new feature that allows for zero-downtime upgrades in Redis clusters. +You can find more information in the [Hitless Upgrades documentation](https://github.com/redis/go-redis/tree/master/hitless). + +# Changes + +## 🚀 New Features +- [CAE-1088] & [CAE-1072] feat: RESP3 notifications support & Hitless notifications handling ([#3418](https://github.com/redis/go-redis/pull/3418)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@ndyakov](https://github.com/ndyakov), [@htemelski-redis](https://github.com/htemelski-redis), [@ofekshenawa](https://github.com/ofekshenawa) + + +# 9.14.0 (2025-09-10) + +## Highlights +- Added batch process method to the pipeline ([#3510](https://github.com/redis/go-redis/pull/3510)) + +# Changes + +## 🚀 New Features + +- Added batch process method to the pipeline ([#3510](https://github.com/redis/go-redis/pull/3510)) + +## 🐛 Bug Fixes + +- fix: SetErr on Cmd if the command cannot be queued correctly in multi/exec ([#3509](https://github.com/redis/go-redis/pull/3509)) + +## 🧰 Maintenance + +- Updates release drafter config to exclude dependabot ([#3511](https://github.com/redis/go-redis/pull/3511)) +- chore(deps): bump actions/setup-go from 5 to 6 ([#3504](https://github.com/redis/go-redis/pull/3504)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@elena-kolevska](https://github.com/elena-kolevksa), [@htemelski-redis](https://github.com/htemelski-redis) and [@ndyakov](https://github.com/ndyakov) + + +# 9.13.0 (2025-09-03) + +## Highlights +- Pipeliner expose queued commands ([#3496](https://github.com/redis/go-redis/pull/3496)) +- Ensure that JSON.GET returns Nil response ([#3470](https://github.com/redis/go-redis/pull/3470)) +- Fixes on Read and Write buffer sizes and UniversalOptions + +## Changes +- Pipeliner expose queued commands ([#3496](https://github.com/redis/go-redis/pull/3496)) +- fix(test): fix a timing issue in pubsub test ([#3498](https://github.com/redis/go-redis/pull/3498)) +- Allow users to enable read-write splitting in failover mode. ([#3482](https://github.com/redis/go-redis/pull/3482)) +- Set the read/write buffer size of the sentinel client to 4KiB ([#3476](https://github.com/redis/go-redis/pull/3476)) + +## 🚀 New Features + +- fix(otel): register wait metrics ([#3499](https://github.com/redis/go-redis/pull/3499)) +- Support subscriptions against cluster slave nodes ([#3480](https://github.com/redis/go-redis/pull/3480)) +- Add wait metrics to otel ([#3493](https://github.com/redis/go-redis/pull/3493)) +- Clean failing timeout implementation ([#3472](https://github.com/redis/go-redis/pull/3472)) + +## 🐛 Bug Fixes + +- Do not assume that all non-IP hosts are loopbacks ([#3085](https://github.com/redis/go-redis/pull/3085)) +- Ensure that JSON.GET returns Nil response ([#3470](https://github.com/redis/go-redis/pull/3470)) + +## 🧰 Maintenance + +- fix(otel): register wait metrics ([#3499](https://github.com/redis/go-redis/pull/3499)) +- fix(make test): Add default env in makefile ([#3491](https://github.com/redis/go-redis/pull/3491)) +- Update the introduction to running tests in README.md ([#3495](https://github.com/redis/go-redis/pull/3495)) +- test: Add comprehensive edge case tests for IncrByFloat command ([#3477](https://github.com/redis/go-redis/pull/3477)) +- Set the default read/write buffer size of Redis connection to 32KiB ([#3483](https://github.com/redis/go-redis/pull/3483)) +- Bumps test image to 8.2.1-pre ([#3478](https://github.com/redis/go-redis/pull/3478)) +- fix UniversalOptions miss ReadBufferSize and WriteBufferSize options ([#3485](https://github.com/redis/go-redis/pull/3485)) +- chore(deps): bump actions/checkout from 4 to 5 ([#3484](https://github.com/redis/go-redis/pull/3484)) +- Removes dry run for stale issues policy ([#3471](https://github.com/redis/go-redis/pull/3471)) +- Update otel metrics URL ([#3474](https://github.com/redis/go-redis/pull/3474)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@LINKIWI](https://github.com/LINKIWI), [@cxljs](https://github.com/cxljs), [@cybersmeashish](https://github.com/cybersmeashish), [@elena-kolevska](https://github.com/elena-kolevska), [@htemelski-redis](https://github.com/htemelski-redis), [@mwhooker](https://github.com/mwhooker), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@suever](https://github.com/suever) + + +# 9.12.1 (2025-08-11) +## 🚀 Highlights +In the last version (9.12.0) the client introduced bigger write and read buffer sized. The default value we set was 512KiB. +However, users reported that this is too big for most use cases and can lead to high memory usage. +In this version the default value is changed to 256KiB. The `README.md` was updated to reflect the +correct default value and include a note that the default value can be changed. + +## 🐛 Bug Fixes + +- fix(options): Add buffer sizes to failover. Update README ([#3468](https://github.com/redis/go-redis/pull/3468)) + +## 🧰 Maintenance + +- fix(options): Add buffer sizes to failover. Update README ([#3468](https://github.com/redis/go-redis/pull/3468)) +- chore: update & fix otel example ([#3466](https://github.com/redis/go-redis/pull/3466)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@ndyakov](https://github.com/ndyakov) and [@vmihailenco](https://github.com/vmihailenco) + +# 9.12.0 (2025-08-05) + +## 🚀 Highlights + +- This release includes support for [Redis 8.2](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/release-notes/redisce/redisos-8.2-release-notes/). +- Introduces an experimental Query Builders for `FTSearch`, `FTAggregate` and other search commands. +- Adds support for `EPSILON` option in `FT.VSIM`. +- Includes bug fixes and improvements contributed by the community related to ring and [redisotel](https://github.com/redis/go-redis/tree/master/extra/redisotel). + +## Changes +- Improve stale issue workflow ([#3458](https://github.com/redis/go-redis/pull/3458)) +- chore(ci): Add 8.2 rc2 pre build for CI ([#3459](https://github.com/redis/go-redis/pull/3459)) +- Added new stream commands ([#3450](https://github.com/redis/go-redis/pull/3450)) +- feat: Add "skip_verify" to Sentinel ([#3428](https://github.com/redis/go-redis/pull/3428)) +- fix: `errors.Join` requires Go 1.20 or later ([#3442](https://github.com/redis/go-redis/pull/3442)) +- DOC-4344 document quickstart examples ([#3426](https://github.com/redis/go-redis/pull/3426)) +- feat(bitop): add support for the new bitop operations ([#3409](https://github.com/redis/go-redis/pull/3409)) + +## 🚀 New Features + +- feat: recover addIdleConn may occur panic ([#2445](https://github.com/redis/go-redis/pull/2445)) +- feat(ring): specify custom health check func via HeartbeatFn option ([#2940](https://github.com/redis/go-redis/pull/2940)) +- Add Query Builder for RediSearch commands ([#3436](https://github.com/redis/go-redis/pull/3436)) +- add configurable buffer sizes for Redis connections ([#3453](https://github.com/redis/go-redis/pull/3453)) +- Add VAMANA vector type to RediSearch ([#3449](https://github.com/redis/go-redis/pull/3449)) +- VSIM add `EPSILON` option ([#3454](https://github.com/redis/go-redis/pull/3454)) +- Add closing support to otel metrics instrumentation ([#3444](https://github.com/redis/go-redis/pull/3444)) + +## 🐛 Bug Fixes + +- fix(redisotel): fix buggy append in reportPoolStats ([#3122](https://github.com/redis/go-redis/pull/3122)) +- fix(search): return results even if doc is empty ([#3457](https://github.com/redis/go-redis/pull/3457)) +- [ISSUE-3402]: Ring.Pipelined return dial timeout error ([#3403](https://github.com/redis/go-redis/pull/3403)) + +## 🧰 Maintenance + +- Merges stale issues jobs into one job with two steps ([#3463](https://github.com/redis/go-redis/pull/3463)) +- improve code readability ([#3446](https://github.com/redis/go-redis/pull/3446)) +- chore(release): 9.12.0-beta.1 ([#3460](https://github.com/redis/go-redis/pull/3460)) +- DOC-5472 time series doc examples ([#3443](https://github.com/redis/go-redis/pull/3443)) +- Add VAMANA compression algorithm tests ([#3461](https://github.com/redis/go-redis/pull/3461)) +- bumped redis 8.2 version used in the CI/CD ([#3451](https://github.com/redis/go-redis/pull/3451)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@andy-stark-redis](https://github.com/andy-stark-redis), [@cxljs](https://github.com/cxljs), [@elena-kolevska](https://github.com/elena-kolevska), [@htemelski-redis](https://github.com/htemelski-redis), [@jouir](https://github.com/jouir), [@monkey92t](https://github.com/monkey92t), [@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@rokn](https://github.com/rokn), [@smnvdev](https://github.com/smnvdev), [@strobil](https://github.com/strobil) and [@wzy9607](https://github.com/wzy9607) + +## New Contributors +* [@htemelski-redis](https://github.com/htemelski-redis) made their first contribution in [#3409](https://github.com/redis/go-redis/pull/3409) +* [@smnvdev](https://github.com/smnvdev) made their first contribution in [#3403](https://github.com/redis/go-redis/pull/3403) +* [@rokn](https://github.com/rokn) made their first contribution in [#3444](https://github.com/redis/go-redis/pull/3444) + +# 9.11.0 (2025-06-24) + +## 🚀 Highlights + +Fixes TxPipeline to work correctly in cluster scenarios, allowing execution of commands +only in the same slot. + +# Changes + +## 🚀 New Features + +- Set cluster slot for `scan` commands, rather than random ([#2623](https://github.com/redis/go-redis/pull/2623)) +- Add CredentialsProvider field to UniversalOptions ([#2927](https://github.com/redis/go-redis/pull/2927)) +- feat(redisotel): add WithCallerEnabled option ([#3415](https://github.com/redis/go-redis/pull/3415)) + +## 🐛 Bug Fixes + +- fix(txpipeline): keyless commands should take the slot of the keyed ([#3411](https://github.com/redis/go-redis/pull/3411)) +- fix(loading): cache the loaded flag for slave nodes ([#3410](https://github.com/redis/go-redis/pull/3410)) +- fix(txpipeline): should return error on multi/exec on multiple slots ([#3408](https://github.com/redis/go-redis/pull/3408)) +- fix: check if the shard exists to avoid returning nil ([#3396](https://github.com/redis/go-redis/pull/3396)) + +## 🧰 Maintenance + +- feat: optimize connection pool waitTurn ([#3412](https://github.com/redis/go-redis/pull/3412)) +- chore(ci): update CI redis builds ([#3407](https://github.com/redis/go-redis/pull/3407)) +- chore: remove a redundant method from `Ring`, `Client` and `ClusterClient` ([#3401](https://github.com/redis/go-redis/pull/3401)) +- test: refactor TestBasicCredentials using table-driven tests ([#3406](https://github.com/redis/go-redis/pull/3406)) +- perf: reduce unnecessary memory allocation operations ([#3399](https://github.com/redis/go-redis/pull/3399)) +- fix: insert entry during iterating over a map ([#3398](https://github.com/redis/go-redis/pull/3398)) +- DOC-5229 probabilistic data type examples ([#3413](https://github.com/redis/go-redis/pull/3413)) +- chore(deps): bump rojopolis/spellcheck-github-actions from 0.49.0 to 0.51.0 ([#3414](https://github.com/redis/go-redis/pull/3414)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@andy-stark-redis](https://github.com/andy-stark-redis), [@boekkooi-impossiblecloud](https://github.com/boekkooi-impossiblecloud), [@cxljs](https://github.com/cxljs), [@dcherubini](https://github.com/dcherubini), [@dependabot[bot]](https://github.com/apps/dependabot), [@iamamirsalehi](https://github.com/iamamirsalehi), [@ndyakov](https://github.com/ndyakov), [@pete-woods](https://github.com/pete-woods), [@twz915](https://github.com/twz915) and [dependabot[bot]](https://github.com/apps/dependabot) + +# 9.10.0 (2025-06-06) + +## 🚀 Highlights + +`go-redis` now supports [vector sets](https://redis.io/docs/latest/develop/data-types/vector-sets/). This data type is marked +as "in preview" in Redis and its support in `go-redis` is marked as experimental. You can find examples in the documentation and +in the `doctests` folder. + +# Changes + +## 🚀 New Features + +- feat: support vectorset ([#3375](https://github.com/redis/go-redis/pull/3375)) + +## 🧰 Maintenance + +- Add the missing NewFloatSliceResult for testing ([#3393](https://github.com/redis/go-redis/pull/3393)) +- DOC-5078 vector set examples ([#3394](https://github.com/redis/go-redis/pull/3394)) + +## Contributors +We'd like to thank all the contributors who worked on this release! + +[@AndBobsYourUncle](https://github.com/AndBobsYourUncle), [@andy-stark-redis](https://github.com/andy-stark-redis), [@fukua95](https://github.com/fukua95) and [@ndyakov](https://github.com/ndyakov) + + + +# 9.9.0 (2025-05-27) + +## 🚀 Highlights +- **Token-based Authentication**: Added `StreamingCredentialsProvider` for dynamic credential updates (experimental) + - Can be used with [go-redis-entraid](https://github.com/redis/go-redis-entraid) for Azure AD authentication +- **Connection Statistics**: Added connection waiting statistics for better monitoring +- **Failover Improvements**: Added `ParseFailoverURL` for easier failover configuration +- **Ring Client Enhancements**: Added shard access methods for better Pub/Sub management + +## ✨ New Features +- Added `StreamingCredentialsProvider` for token-based authentication ([#3320](https://github.com/redis/go-redis/pull/3320)) + - Supports dynamic credential updates + - Includes connection close hooks + - Note: Currently marked as experimental +- Added `ParseFailoverURL` for parsing failover URLs ([#3362](https://github.com/redis/go-redis/pull/3362)) +- Added connection waiting statistics ([#2804](https://github.com/redis/go-redis/pull/2804)) +- Added new utility functions: + - `ParseFloat` and `MustParseFloat` in public utils package ([#3371](https://github.com/redis/go-redis/pull/3371)) + - Unit tests for `Atoi`, `ParseInt`, `ParseUint`, and `ParseFloat` ([#3377](https://github.com/redis/go-redis/pull/3377)) +- Added Ring client shard access methods: + - `GetShardClients()` to retrieve all active shard clients + - `GetShardClientForKey(key string)` to get the shard client for a specific key ([#3388](https://github.com/redis/go-redis/pull/3388)) + +## 🐛 Bug Fixes +- Fixed routing reads to loading slave nodes ([#3370](https://github.com/redis/go-redis/pull/3370)) +- Added support for nil lag in XINFO GROUPS ([#3369](https://github.com/redis/go-redis/pull/3369)) +- Fixed pool acquisition timeout issues ([#3381](https://github.com/redis/go-redis/pull/3381)) +- Optimized unnecessary copy operations ([#3376](https://github.com/redis/go-redis/pull/3376)) + +## 📚 Documentation +- Updated documentation for XINFO GROUPS with nil lag support ([#3369](https://github.com/redis/go-redis/pull/3369)) +- Added package-level comments for new features + +## ⚡ Performance and Reliability +- Optimized `ReplaceSpaces` function ([#3383](https://github.com/redis/go-redis/pull/3383)) +- Set default value for `Options.Protocol` in `init()` ([#3387](https://github.com/redis/go-redis/pull/3387)) +- Exported pool errors for public consumption ([#3380](https://github.com/redis/go-redis/pull/3380)) + +## 🔧 Dependencies and Infrastructure +- Updated Redis CI to version 8.0.1 ([#3372](https://github.com/redis/go-redis/pull/3372)) +- Updated spellcheck GitHub Actions ([#3389](https://github.com/redis/go-redis/pull/3389)) +- Removed unused parameters ([#3382](https://github.com/redis/go-redis/pull/3382), [#3384](https://github.com/redis/go-redis/pull/3384)) + +## 🧪 Testing +- Added unit tests for pool acquisition timeout ([#3381](https://github.com/redis/go-redis/pull/3381)) +- Added unit tests for utility functions ([#3377](https://github.com/redis/go-redis/pull/3377)) + +## 👥 Contributors + +We would like to thank all the contributors who made this release possible: + +[@ndyakov](https://github.com/ndyakov), [@ofekshenawa](https://github.com/ofekshenawa), [@LINKIWI](https://github.com/LINKIWI), [@iamamirsalehi](https://github.com/iamamirsalehi), [@fukua95](https://github.com/fukua95), [@lzakharov](https://github.com/lzakharov), [@DengY11](https://github.com/DengY11) + +## 📝 Changelog + +For a complete list of changes, see the [full changelog](https://github.com/redis/go-redis/compare/v9.8.0...v9.9.0). + +# 9.8.0 (2025-04-30) + +## 🚀 Highlights +- **Redis 8 Support**: Full compatibility with Redis 8.0, including testing and CI integration +- **Enhanced Hash Operations**: Added support for new hash commands (`HGETDEL`, `HGETEX`, `HSETEX`) and `HSTRLEN` command +- **Search Improvements**: Enabled Search DIALECT 2 by default and added `CountOnly` argument for `FT.Search` + +## ✨ New Features +- Added support for new hash commands: `HGETDEL`, `HGETEX`, `HSETEX` ([#3305](https://github.com/redis/go-redis/pull/3305)) +- Added `HSTRLEN` command for hash operations ([#2843](https://github.com/redis/go-redis/pull/2843)) +- Added `Do` method for raw query by single connection from `pool.Conn()` ([#3182](https://github.com/redis/go-redis/pull/3182)) +- Prevent false-positive marshaling by treating zero time.Time as empty in isEmptyValue ([#3273](https://github.com/redis/go-redis/pull/3273)) +- Added FailoverClusterClient support for Universal client ([#2794](https://github.com/redis/go-redis/pull/2794)) +- Added support for cluster mode with `IsClusterMode` config parameter ([#3255](https://github.com/redis/go-redis/pull/3255)) +- Added client name support in `HELLO` RESP handshake ([#3294](https://github.com/redis/go-redis/pull/3294)) +- **Enabled Search DIALECT 2 by default** ([#3213](https://github.com/redis/go-redis/pull/3213)) +- Added read-only option for failover configurations ([#3281](https://github.com/redis/go-redis/pull/3281)) +- Added `CountOnly` argument for `FT.Search` to use `LIMIT 0 0` ([#3338](https://github.com/redis/go-redis/pull/3338)) +- Added `DB` option support in `NewFailoverClusterClient` ([#3342](https://github.com/redis/go-redis/pull/3342)) +- Added `nil` check for the options when creating a client ([#3363](https://github.com/redis/go-redis/pull/3363)) + +## 🐛 Bug Fixes +- Fixed `PubSub` concurrency safety issues ([#3360](https://github.com/redis/go-redis/pull/3360)) +- Fixed panic caused when argument is `nil` ([#3353](https://github.com/redis/go-redis/pull/3353)) +- Improved error handling when fetching master node from sentinels ([#3349](https://github.com/redis/go-redis/pull/3349)) +- Fixed connection pool timeout issues and increased retries ([#3298](https://github.com/redis/go-redis/pull/3298)) +- Fixed context cancellation error leading to connection spikes on Primary instances ([#3190](https://github.com/redis/go-redis/pull/3190)) +- Fixed RedisCluster client to consider `MASTERDOWN` a retriable error ([#3164](https://github.com/redis/go-redis/pull/3164)) +- Fixed tracing to show complete commands instead of truncated versions ([#3290](https://github.com/redis/go-redis/pull/3290)) +- Fixed OpenTelemetry instrumentation to prevent multiple span reporting ([#3168](https://github.com/redis/go-redis/pull/3168)) +- Fixed `FT.Search` Limit argument and added `CountOnly` argument for limit 0 0 ([#3338](https://github.com/redis/go-redis/pull/3338)) +- Fixed missing command in interface ([#3344](https://github.com/redis/go-redis/pull/3344)) +- Fixed slot calculation for `COUNTKEYSINSLOT` command ([#3327](https://github.com/redis/go-redis/pull/3327)) +- Updated PubSub implementation with correct context ([#3329](https://github.com/redis/go-redis/pull/3329)) + +## 📚 Documentation +- Added hash search examples ([#3357](https://github.com/redis/go-redis/pull/3357)) +- Fixed documentation comments ([#3351](https://github.com/redis/go-redis/pull/3351)) +- Added `CountOnly` search example ([#3345](https://github.com/redis/go-redis/pull/3345)) +- Added examples for list commands: `LLEN`, `LPOP`, `LPUSH`, `LRANGE`, `RPOP`, `RPUSH` ([#3234](https://github.com/redis/go-redis/pull/3234)) +- Added `SADD` and `SMEMBERS` command examples ([#3242](https://github.com/redis/go-redis/pull/3242)) +- Updated `README.md` to use Redis Discord guild ([#3331](https://github.com/redis/go-redis/pull/3331)) +- Updated `HExpire` command documentation ([#3355](https://github.com/redis/go-redis/pull/3355)) +- Featured OpenTelemetry instrumentation more prominently ([#3316](https://github.com/redis/go-redis/pull/3316)) +- Updated `README.md` with additional information ([#310ce55](https://github.com/redis/go-redis/commit/310ce55)) + +## ⚡ Performance and Reliability +- Bound connection pool background dials to configured dial timeout ([#3089](https://github.com/redis/go-redis/pull/3089)) +- Ensured context isn't exhausted via concurrent query ([#3334](https://github.com/redis/go-redis/pull/3334)) + +## 🔧 Dependencies and Infrastructure +- Updated testing image to Redis 8.0-RC2 ([#3361](https://github.com/redis/go-redis/pull/3361)) +- Enabled CI for Redis CE 8.0 ([#3274](https://github.com/redis/go-redis/pull/3274)) +- Updated various dependencies: + - Bumped golangci/golangci-lint-action from 6.5.0 to 7.0.0 ([#3354](https://github.com/redis/go-redis/pull/3354)) + - Bumped rojopolis/spellcheck-github-actions ([#3336](https://github.com/redis/go-redis/pull/3336)) + - Bumped golang.org/x/net in example/otel ([#3308](https://github.com/redis/go-redis/pull/3308)) +- Migrated golangci-lint configuration to v2 format ([#3354](https://github.com/redis/go-redis/pull/3354)) + +## ⚠️ Breaking Changes +- **Enabled Search DIALECT 2 by default** ([#3213](https://github.com/redis/go-redis/pull/3213)) +- Dropped RedisGears (Triggers and Functions) support ([#3321](https://github.com/redis/go-redis/pull/3321)) +- Dropped FT.PROFILE command that was never enabled ([#3323](https://github.com/redis/go-redis/pull/3323)) + +## 🔒 Security +- Fixed network error handling on SETINFO (CVE-2025-29923) ([#3295](https://github.com/redis/go-redis/pull/3295)) + +## 🧪 Testing +- Added integration tests for Redis 8 behavior changes in Redis Search ([#3337](https://github.com/redis/go-redis/pull/3337)) +- Added vector types INT8 and UINT8 tests ([#3299](https://github.com/redis/go-redis/pull/3299)) +- Added test codes for search_commands.go ([#3285](https://github.com/redis/go-redis/pull/3285)) +- Fixed example test sorting ([#3292](https://github.com/redis/go-redis/pull/3292)) + +## 👥 Contributors + +We would like to thank all the contributors who made this release possible: + +[@alexander-menshchikov](https://github.com/alexander-menshchikov), [@EXPEbdodla](https://github.com/EXPEbdodla), [@afti](https://github.com/afti), [@dmaier-redislabs](https://github.com/dmaier-redislabs), [@four_leaf_clover](https://github.com/four_leaf_clover), [@alohaglenn](https://github.com/alohaglenn), [@gh73962](https://github.com/gh73962), [@justinmir](https://github.com/justinmir), [@LINKIWI](https://github.com/LINKIWI), [@liushuangbill](https://github.com/liushuangbill), [@golang88](https://github.com/golang88), [@gnpaone](https://github.com/gnpaone), [@ndyakov](https://github.com/ndyakov), [@nikolaydubina](https://github.com/nikolaydubina), [@oleglacto](https://github.com/oleglacto), [@andy-stark-redis](https://github.com/andy-stark-redis), [@rodneyosodo](https://github.com/rodneyosodo), [@dependabot](https://github.com/dependabot), [@rfyiamcool](https://github.com/rfyiamcool), [@frankxjkuang](https://github.com/frankxjkuang), [@fukua95](https://github.com/fukua95), [@soleymani-milad](https://github.com/soleymani-milad), [@ofekshenawa](https://github.com/ofekshenawa), [@khasanovbi](https://github.com/khasanovbi) + + +# Old Changelog +## Unreleased + +### Changed + +* `go-redis` won't skip span creation if the parent spans is not recording. ([#2980](https://github.com/redis/go-redis/issues/2980)) + Users can use the OpenTelemetry sampler to control the sampling behavior. + For instance, you can use the `ParentBased(NeverSample())` sampler from `go.opentelemetry.io/otel/sdk/trace` to keep + a similar behavior (drop orphan spans) of `go-redis` as before. + +## [9.0.5](https://github.com/redis/go-redis/compare/v9.0.4...v9.0.5) (2023-05-29) + + +### Features + +* Add ACL LOG ([#2536](https://github.com/redis/go-redis/issues/2536)) ([31ba855](https://github.com/redis/go-redis/commit/31ba855ddebc38fbcc69a75d9d4fb769417cf602)) +* add field protocol to setupClusterQueryParams ([#2600](https://github.com/redis/go-redis/issues/2600)) ([840c25c](https://github.com/redis/go-redis/commit/840c25cb6f320501886a82a5e75f47b491e46fbe)) +* add protocol option ([#2598](https://github.com/redis/go-redis/issues/2598)) ([3917988](https://github.com/redis/go-redis/commit/391798880cfb915c4660f6c3ba63e0c1a459e2af)) + + + +## [9.0.4](https://github.com/redis/go-redis/compare/v9.0.3...v9.0.4) (2023-05-01) + + +### Bug Fixes + +* reader float parser ([#2513](https://github.com/redis/go-redis/issues/2513)) ([46f2450](https://github.com/redis/go-redis/commit/46f245075e6e3a8bd8471f9ca67ea95fd675e241)) + + +### Features + +* add client info command ([#2483](https://github.com/redis/go-redis/issues/2483)) ([b8c7317](https://github.com/redis/go-redis/commit/b8c7317cc6af444603731f7017c602347c0ba61e)) +* no longer verify HELLO error messages ([#2515](https://github.com/redis/go-redis/issues/2515)) ([7b4f217](https://github.com/redis/go-redis/commit/7b4f2179cb5dba3d3c6b0c6f10db52b837c912c8)) +* read the structure to increase the judgment of the omitempty op… ([#2529](https://github.com/redis/go-redis/issues/2529)) ([37c057b](https://github.com/redis/go-redis/commit/37c057b8e597c5e8a0e372337f6a8ad27f6030af)) + + + +## [9.0.3](https://github.com/redis/go-redis/compare/v9.0.2...v9.0.3) (2023-04-02) + +### New Features + +- feat(scan): scan time.Time sets the default decoding (#2413) +- Add support for CLUSTER LINKS command (#2504) +- Add support for acl dryrun command (#2502) +- Add support for COMMAND GETKEYS & COMMAND GETKEYSANDFLAGS (#2500) +- Add support for LCS Command (#2480) +- Add support for BZMPOP (#2456) +- Adding support for ZMPOP command (#2408) +- Add support for LMPOP (#2440) +- feat: remove pool unused fields (#2438) +- Expiretime and PExpireTime (#2426) +- Implement `FUNCTION` group of commands (#2475) +- feat(zadd): add ZAddLT and ZAddGT (#2429) +- Add: Support for COMMAND LIST command (#2491) +- Add support for BLMPOP (#2442) +- feat: check pipeline.Do to prevent confusion with Exec (#2517) +- Function stats, function kill, fcall and fcall_ro (#2486) +- feat: Add support for CLUSTER SHARDS command (#2507) +- feat(cmd): support for adding byte,bit parameters to the bitpos command (#2498) + +### Fixed + +- fix: eval api cmd.SetFirstKeyPos (#2501) +- fix: limit the number of connections created (#2441) +- fixed #2462 v9 continue support dragonfly, it's Hello command return "NOAUTH Authentication required" error (#2479) +- Fix for internal/hscan/structmap.go:89:23: undefined: reflect.Pointer (#2458) +- fix: group lag can be null (#2448) + +### Maintenance + +- Updating to the latest version of redis (#2508) +- Allowing for running tests on a port other than the fixed 6380 (#2466) +- redis 7.0.8 in tests (#2450) +- docs: Update redisotel example for v9 (#2425) +- chore: update go mod, Upgrade golang.org/x/net version to 0.7.0 (#2476) +- chore: add Chinese translation (#2436) +- chore(deps): bump github.com/bsm/gomega from 1.20.0 to 1.26.0 (#2421) +- chore(deps): bump github.com/bsm/ginkgo/v2 from 2.5.0 to 2.7.0 (#2420) +- chore(deps): bump actions/setup-go from 3 to 4 (#2495) +- docs: add instructions for the HSet api (#2503) +- docs: add reading lag field comment (#2451) +- test: update go mod before testing(go mod tidy) (#2423) +- docs: fix comment typo (#2505) +- test: remove testify (#2463) +- refactor: change ListElementCmd to KeyValuesCmd. (#2443) +- fix(appendArg): appendArg case special type (#2489) + +## [9.0.2](https://github.com/redis/go-redis/compare/v9.0.1...v9.0.2) (2023-02-01) + +### Features + +* upgrade OpenTelemetry, use the new metrics API. ([#2410](https://github.com/redis/go-redis/issues/2410)) ([e29e42c](https://github.com/redis/go-redis/commit/e29e42cde2755ab910d04185025dc43ce6f59c65)) + +## v9 2023-01-30 + +### Breaking + +- Changed Pipelines to not be thread-safe any more. + +### Added + +- Added support for [RESP3](https://github.com/antirez/RESP3/blob/master/spec.md) protocol. It was + contributed by @monkey92t who has done the majority of work in this release. +- Added `ContextTimeoutEnabled` option that controls whether the client respects context timeouts + and deadlines. See + [Redis Timeouts](https://redis.uptrace.dev/guide/go-redis-debugging.html#timeouts) for details. +- Added `ParseClusterURL` to parse URLs into `ClusterOptions`, for example, + `redis://user:password@localhost:6789?dial_timeout=3&read_timeout=6s&addr=localhost:6790&addr=localhost:6791`. +- Added metrics instrumentation using `redisotel.IstrumentMetrics`. See + [documentation](https://redis.uptrace.dev/guide/go-redis-monitoring.html) +- Added `redis.HasErrorPrefix` to help working with errors. + +### Changed + +- Removed asynchronous cancellation based on the context timeout. It was racy in v8 and is + completely gone in v9. +- Reworked hook interface and added `DialHook`. +- Replaced `redisotel.NewTracingHook` with `redisotel.InstrumentTracing`. See + [example](example/otel) and + [documentation](https://redis.uptrace.dev/guide/go-redis-monitoring.html). +- Replaced `*redis.Z` with `redis.Z` since it is small enough to be passed as value without making + an allocation. +- Renamed the option `MaxConnAge` to `ConnMaxLifetime`. +- Renamed the option `IdleTimeout` to `ConnMaxIdleTime`. +- Removed connection reaper in favor of `MaxIdleConns`. +- Removed `WithContext` since `context.Context` can be passed directly as an arg. +- Removed `Pipeline.Close` since there is no real need to explicitly manage pipeline resources and + it can be safely reused via `sync.Pool` etc. `Pipeline.Discard` is still available if you want to + reset commands for some reason. + +### Fixed + +- Improved and fixed pipeline retries. +- As usually, added support for more commands and fixed some bugs. diff --git a/vendor/github.com/redis/go-redis/v9/RELEASING.md b/vendor/github.com/redis/go-redis/v9/RELEASING.md new file mode 100644 index 0000000000..033ec100be --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/RELEASING.md @@ -0,0 +1,146 @@ +# Releasing + +This document is the runbook for cutting a go-redis release. It is intended +for maintainers with write/tag access to the repository. + +For the format and style of the release notes themselves, see +[.github/RELEASE_NOTES_TEMPLATE.md](./.github/RELEASE_NOTES_TEMPLATE.md). + +## Versioning + +go-redis follows [Semantic Versioning](https://semver.org/): + +- **Patch** (`vX.Y.Z+1`) — bug fixes, no API changes. +- **Minor** (`vX.Y+1.0`) — backwards-compatible new features, deprecations. +- **Major** (`vX+1.0.0`) — breaking changes. Coordinate with the team first. + +Pre-releases use `vX.Y.Z-beta.N` / `vX.Y.Z-rc.N`. + +## Pre-release checklist + +- [ ] Target branch is `master` and CI is green on the latest commit. +- [ ] All PRs intended for this release are merged. +- [ ] There are no open issues in the release milestone (if used). +- [ ] `CHANGELOG` / release notes have been considered; dependabot-only + and doc-only changes are excluded per the template. +- [ ] Confirm the next version number and decide if it's a patch / minor / major. + +## 1. Draft the release notes + +1. Open the draft release auto-generated by + [release-drafter](.github/release-drafter-config.yml) on GitHub. +2. Prepend a new section to [`RELEASE-NOTES.md`](./RELEASE-NOTES.md) using + [`.github/RELEASE_NOTES_TEMPLATE.md`](./.github/RELEASE_NOTES_TEMPLATE.md) + as the format. Keep the file in chronological order (newest first). +3. Pick 3–5 **Highlights** — the most user-facing, impactful changes. +4. Remove dependabot bumps and doc-only typo fixes from the lists. +5. Verify every PR has a contributor attribution and link. +6. Open a PR with just the release-notes change if you want review before + bumping versions, otherwise include it in the release PR below. + +## 2. Bump versions and open the release PR + +Create a release branch from `master`: + +```shell +git checkout master && git pull --ff-only +git checkout -b release/vX.Y.Z +``` + +Run the release script on that branch: + +```shell +TAG=vX.Y.Z ./scripts/release.sh +``` + +What the script does (and explicitly does **not** do): + +- ✅ Validates `TAG` matches the semver regex and isn't already a git tag. +- ✅ Rewrites every `redis/go-redis*` line in every sub-module `go.mod` to + point at the new `TAG`. Trailing `// indirect` markers are preserved. +- ✅ Runs `go mod tidy -compat=1.24` in each sub-module. +- ✅ Updates the return value in [`version.go`](./version.go). +- ❌ Does **not** switch branches (runs in your current branch). +- ❌ Does **not** require a clean working tree (so you can mix it with + release-notes edits in the same branch). +- ❌ Does **not** commit, tag, or push anything. + +Review and commit the changes yourself: + +```shell +git diff # sanity-check the bumps +git add -u +git commit -m "chore: release vX.Y.Z" +git push origin release/vX.Y.Z +``` + +Then on GitHub: + +- [ ] Open a PR from `release/vX.Y.Z` into `master`. +- [ ] Wait for all required CI checks (build, golangci-lint, spellcheck, + doctests, e2e where applicable) to pass. +- [ ] Get at least one maintainer approval. +- [ ] Merge the PR (use a merge commit — the tag will point at the merge SHA). + +## 3. Tag the release + +After the release PR is merged, pull the latest `master` and dry-run the +tagger: + +```shell +git checkout master && git pull --ff-only +TAG=vX.Y.Z ./scripts/tag.sh vX.Y.Z +``` + +The script defaults to **dry-run** and prints the commands it would run. +Verify the output, then apply for real with `-t`: + +```shell +./scripts/tag.sh vX.Y.Z -t +``` + +This creates and pushes: +- The top-level tag `vX.Y.Z`. +- A per-module tag `/vX.Y.Z` for each public sub-module + (skipping `example/*` and `internal/*`). + +## 4. Publish the GitHub release + +1. On GitHub, open the draft release created by release-drafter. +2. Set the tag to `vX.Y.Z` and the target to `master`. +3. Replace the auto-generated body with the curated notes from + `RELEASE-NOTES.md` for this version. +4. For pre-releases, check **"Set as a pre-release"**. +5. Publish. + +## 5. Post-release + +- [ ] Verify the release appears on + [pkg.go.dev](https://pkg.go.dev/github.com/redis/go-redis/v9) within + a few minutes (trigger a fetch by visiting the version URL if needed). +- [ ] Announce on Discord (see the link in `CONTRIBUTING.md`). +- [ ] Close the release milestone if one was used. +- [ ] Open follow-up issues for anything deferred from this release. + +## Hotfix / patch release + +For an urgent fix on top of the latest release: + +1. Branch from the latest release tag: `git checkout -b hotfix/vX.Y.Z+1 vX.Y.Z`. +2. Cherry-pick (or re-apply) only the required fix commits. +3. Follow the normal release flow above with `TAG=vX.Y.Z+1`. +4. Make sure the fix is also present on `master` (forward-port if necessary). + +## Troubleshooting + +- **`release.sh` fails with "tag already exists"** — the tag has already + been created. Pick the next version, or delete the local tag first if + it was created by mistake. +- **`tag.sh` reports version mismatch in a `go.mod`** — a sub-module was + not updated by `release.sh`. Fix the `go.mod` manually (or re-run + `release.sh`), amend the release PR, and re-run the tagger. +- **`version.go` does not contain the tag** — `release.sh` did not run or + the bump was reverted. Re-run `release.sh` on the release branch. +- **pkg.go.dev does not show the new version** — visit + `https://pkg.go.dev/github.com/redis/go-redis/v9@vX.Y.Z` once to trigger + a fetch from the module proxy. diff --git a/vendor/github.com/redis/go-redis/v9/acl_commands.go b/vendor/github.com/redis/go-redis/v9/acl_commands.go new file mode 100644 index 0000000000..0a8a195ceb --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/acl_commands.go @@ -0,0 +1,116 @@ +package redis + +import "context" + +type ACLCmdable interface { + ACLDryRun(ctx context.Context, username string, command ...interface{}) *StringCmd + + ACLLog(ctx context.Context, count int64) *ACLLogCmd + ACLLogReset(ctx context.Context) *StatusCmd + + ACLGenPass(ctx context.Context, bit int) *StringCmd + + ACLSetUser(ctx context.Context, username string, rules ...string) *StatusCmd + ACLDelUser(ctx context.Context, username string) *IntCmd + ACLUsers(ctx context.Context) *StringSliceCmd + ACLWhoAmI(ctx context.Context) *StringCmd + ACLList(ctx context.Context) *StringSliceCmd + + ACLCat(ctx context.Context) *StringSliceCmd + ACLCatArgs(ctx context.Context, options *ACLCatArgs) *StringSliceCmd +} + +type ACLCatArgs struct { + Category string +} + +func (c cmdable) ACLDryRun(ctx context.Context, username string, command ...interface{}) *StringCmd { + args := make([]interface{}, 0, 3+len(command)) + args = append(args, "acl", "dryrun", username) + args = append(args, command...) + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLLog(ctx context.Context, count int64) *ACLLogCmd { + args := make([]interface{}, 0, 3) + args = append(args, "acl", "log") + if count > 0 { + args = append(args, count) + } + cmd := NewACLLogCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLLogReset(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "acl", "log", "reset") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLDelUser(ctx context.Context, username string) *IntCmd { + cmd := NewIntCmd(ctx, "acl", "deluser", username) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLSetUser(ctx context.Context, username string, rules ...string) *StatusCmd { + args := make([]interface{}, 3+len(rules)) + args[0] = "acl" + args[1] = "setuser" + args[2] = username + for i, rule := range rules { + args[i+3] = rule + } + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLGenPass(ctx context.Context, bit int) *StringCmd { + args := make([]interface{}, 0, 3) + args = append(args, "acl", "genpass") + if bit > 0 { + args = append(args, bit) + } + cmd := NewStringCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLUsers(ctx context.Context) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "acl", "users") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLWhoAmI(ctx context.Context) *StringCmd { + cmd := NewStringCmd(ctx, "acl", "whoami") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLList(ctx context.Context) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "acl", "list") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLCat(ctx context.Context) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "acl", "cat") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ACLCatArgs(ctx context.Context, options *ACLCatArgs) *StringSliceCmd { + // if there is a category passed, build new cmd, if there isn't - use the ACLCat method + if options != nil && options.Category != "" { + cmd := NewStringSliceCmd(ctx, "acl", "cat", options.Category) + _ = c(ctx, cmd) + return cmd + } + + return c.ACLCat(ctx) +} diff --git a/vendor/github.com/redis/go-redis/v9/adapters.go b/vendor/github.com/redis/go-redis/v9/adapters.go new file mode 100644 index 0000000000..952a4c2660 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/adapters.go @@ -0,0 +1,118 @@ +package redis + +import ( + "context" + "errors" + "net" + "time" + + "github.com/redis/go-redis/v9/internal/interfaces" + "github.com/redis/go-redis/v9/push" +) + +// ErrInvalidCommand is returned when an invalid command is passed to ExecuteCommand. +var ErrInvalidCommand = errors.New("invalid command type") + +// ErrInvalidPool is returned when the pool type is not supported. +var ErrInvalidPool = errors.New("invalid pool type") + +// newClientAdapter creates a new client adapter for regular Redis clients. +func newClientAdapter(client *baseClient) interfaces.ClientInterface { + return &clientAdapter{client: client} +} + +// clientAdapter adapts a Redis client to implement interfaces.ClientInterface. +type clientAdapter struct { + client *baseClient +} + +// GetOptions returns the client options. +func (ca *clientAdapter) GetOptions() interfaces.OptionsInterface { + return &optionsAdapter{options: ca.client.opt} +} + +// GetPushProcessor returns the client's push notification processor. +func (ca *clientAdapter) GetPushProcessor() interfaces.NotificationProcessor { + return &pushProcessorAdapter{processor: ca.client.pushProcessor} +} + +// optionsAdapter adapts Redis options to implement interfaces.OptionsInterface. +type optionsAdapter struct { + options *Options +} + +// GetReadTimeout returns the read timeout. +func (oa *optionsAdapter) GetReadTimeout() time.Duration { + return oa.options.ReadTimeout +} + +// GetWriteTimeout returns the write timeout. +func (oa *optionsAdapter) GetWriteTimeout() time.Duration { + return oa.options.WriteTimeout +} + +// GetNetwork returns the network type. +func (oa *optionsAdapter) GetNetwork() string { + return oa.options.Network +} + +// GetAddr returns the connection address. +func (oa *optionsAdapter) GetAddr() string { + return oa.options.Addr +} + +// GetNodeAddress returns the address of the Redis node as reported by the server. +// For cluster clients, this is the endpoint from CLUSTER SLOTS before any transformation. +// For standalone clients, this defaults to Addr. +func (oa *optionsAdapter) GetNodeAddress() string { + return oa.options.NodeAddress +} + +// IsTLSEnabled returns true if TLS is enabled. +func (oa *optionsAdapter) IsTLSEnabled() bool { + return oa.options.TLSConfig != nil +} + +// GetProtocol returns the protocol version. +func (oa *optionsAdapter) GetProtocol() int { + return oa.options.Protocol +} + +// GetPoolSize returns the connection pool size. +func (oa *optionsAdapter) GetPoolSize() int { + return oa.options.PoolSize +} + +// NewDialer returns a new dialer function for the connection. +func (oa *optionsAdapter) NewDialer() func(context.Context) (net.Conn, error) { + baseDialer := oa.options.NewDialer() + return func(ctx context.Context) (net.Conn, error) { + // Extract network and address from the options + network := oa.options.Network + addr := oa.options.Addr + return baseDialer(ctx, network, addr) + } +} + +// pushProcessorAdapter adapts a push.NotificationProcessor to implement interfaces.NotificationProcessor. +type pushProcessorAdapter struct { + processor push.NotificationProcessor +} + +// RegisterHandler registers a handler for a specific push notification name. +func (ppa *pushProcessorAdapter) RegisterHandler(pushNotificationName string, handler interface{}, protected bool) error { + if pushHandler, ok := handler.(push.NotificationHandler); ok { + return ppa.processor.RegisterHandler(pushNotificationName, pushHandler, protected) + } + return errors.New("handler must implement push.NotificationHandler") +} + +// UnregisterHandler removes a handler for a specific push notification name. +func (ppa *pushProcessorAdapter) UnregisterHandler(pushNotificationName string) error { + return ppa.processor.UnregisterHandler(pushNotificationName) +} + +// GetHandler returns the handler for a specific push notification name. +func (ppa *pushProcessorAdapter) GetHandler(pushNotificationName string) interface{} { + return ppa.processor.GetHandler(pushNotificationName) +} diff --git a/vendor/github.com/redis/go-redis/v9/array_commands.go b/vendor/github.com/redis/go-redis/v9/array_commands.go new file mode 100644 index 0000000000..71037dfd63 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/array_commands.go @@ -0,0 +1,387 @@ +package redis + +import ( + "context" +) + +// note: the APIs is experimental and may be subject to change. +// +// ArrayCmdable defines the interface for Redis Array data structure commands +// available in Redis 8.8.0+. +// +// Redis array supports index range [0, math.MaxUint64-1), so index parameters use uint64. +type ArrayCmdable interface { + ARSet(ctx context.Context, key string, index uint64, values ...string) *IntCmd + ARGet(ctx context.Context, key string, index uint64) *StringCmd + ARGetRange(ctx context.Context, key string, start, end uint64) *SliceCmd + ARMGet(ctx context.Context, key string, indexes ...uint64) *SliceCmd + ARMSet(ctx context.Context, key string, members ...AREntry) *IntCmd + ARInsert(ctx context.Context, key string, values ...string) *UintCmd + ARDel(ctx context.Context, key string, indexes ...uint64) *IntCmd + ARDelRange(ctx context.Context, key string, ranges ...ARRange) *UintCmd + ARLen(ctx context.Context, key string) *UintCmd + ARCount(ctx context.Context, key string) *UintCmd + ARNext(ctx context.Context, key string) *UintCmd + ARSeek(ctx context.Context, key string, index uint64) *IntCmd + ARInfo(ctx context.Context, key string) *MapStringInterfaceCmd + ARInfoFull(ctx context.Context, key string) *MapStringInterfaceCmd + ARScan(ctx context.Context, key string, start, end uint64, args *ARScanArgs) *AREntrySliceCmd + AROpSum(ctx context.Context, key string, start, end uint64) *StringCmd + AROpMin(ctx context.Context, key string, start, end uint64) *StringCmd + AROpMax(ctx context.Context, key string, start, end uint64) *StringCmd + AROpAnd(ctx context.Context, key string, start, end uint64) *IntCmd + AROpOr(ctx context.Context, key string, start, end uint64) *IntCmd + AROpXor(ctx context.Context, key string, start, end uint64) *IntCmd + AROpMatch(ctx context.Context, key string, start, end uint64, value string) *IntCmd + AROpUsed(ctx context.Context, key string, start, end uint64) *IntCmd + ARGrep(ctx context.Context, key string, start, end string, args *ARGrepArgs) *UintSliceCmd + ARGrepWithValues(ctx context.Context, key string, start, end string, args *ARGrepArgs) *AREntrySliceCmd + ARRing(ctx context.Context, key string, size uint64, values ...string) *UintCmd + ARLastItems(ctx context.Context, key string, count uint64, rev bool) *SliceCmd +} + +// AREntry represents an index-value pair for ARMSET. +type AREntry struct { + Index uint64 + Value string +} + +// ARRange represents a start-end range for ARDELRANGE. +type ARRange struct { + Start uint64 + End uint64 +} + +// ARScanArgs contains optional arguments for ARSCAN. +type ARScanArgs struct { + Limit uint64 +} + +// ARGrepPredicateType defines the type of predicate for ARGREP. +type ARGrepPredicateType string + +const ( + ARGrepExact ARGrepPredicateType = "EXACT" + ARGrepMatch ARGrepPredicateType = "MATCH" + ARGrepGlob ARGrepPredicateType = "GLOB" + ARGrepRegex ARGrepPredicateType = "RE" +) + +// ARGrepPredicate represents a search predicate for ARGREP. +type ARGrepPredicate struct { + Type ARGrepPredicateType + Value string +} + +// ARGrepArgs contains optional arguments for ARGREP. +// Redis ARGREP defaults to OR when multiple predicates are given. +// Set CombineAnd to true to combine predicates with AND instead. +type ARGrepArgs struct { + Predicates []ARGrepPredicate + CombineAnd bool + Limit uint64 + NoCase bool +} + +// ARSet sets one or more contiguous values starting at an index in an array. +// Returns the number of new slots that were set (previously empty). +func (c cmdable) ARSet(ctx context.Context, key string, index uint64, values ...string) *IntCmd { + args := make([]any, 3, 3+len(values)) + args[0] = "arset" + args[1] = key + args[2] = index + for _, v := range values { + args = append(args, v) + } + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARGet gets the value at an index in an array. +// Returns redis.Nil if the key or index does not exist. +func (c cmdable) ARGet(ctx context.Context, key string, index uint64) *StringCmd { + cmd := NewStringCmd(ctx, "arget", key, index) + _ = c(ctx, cmd) + return cmd +} + +// ARGetRange gets values in a range of indexes. +// Returns values in the range, with nil for unset indexes. +func (c cmdable) ARGetRange(ctx context.Context, key string, start, end uint64) *SliceCmd { + cmd := NewSliceCmd(ctx, "argetrange", key, start, end) + _ = c(ctx, cmd) + return cmd +} + +// ARMGet gets values at multiple indexes in an array. +// Returns values at the specified indexes, with nil for unset indexes. +func (c cmdable) ARMGet(ctx context.Context, key string, indexes ...uint64) *SliceCmd { + args := make([]any, 2+len(indexes)) + args[0] = "armget" + args[1] = key + for i, idx := range indexes { + args[2+i] = idx + } + cmd := NewSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARMSet sets multiple index-value pairs in an array. +// Returns the number of new slots that were set (previously empty). +func (c cmdable) ARMSet(ctx context.Context, key string, members ...AREntry) *IntCmd { + args := make([]any, 2, 2+2*len(members)) + args[0] = "armset" + args[1] = key + for _, m := range members { + args = append(args, m.Index, m.Value) + } + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARInsert inserts one or more values at consecutive indexes. +// Returns the last index where a value was inserted. +func (c cmdable) ARInsert(ctx context.Context, key string, values ...string) *UintCmd { + args := make([]any, 2, 2+len(values)) + args[0] = "arinsert" + args[1] = key + for _, v := range values { + args = append(args, v) + } + cmd := NewUintCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARDel deletes elements at the specified indexes in an array. +// Returns the number of elements deleted. +func (c cmdable) ARDel(ctx context.Context, key string, indexes ...uint64) *IntCmd { + args := make([]any, 2+len(indexes)) + args[0] = "ardel" + args[1] = key + for i, idx := range indexes { + args[2+i] = idx + } + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARDelRange deletes elements in one or more ranges. +// Returns the number of elements deleted. +func (c cmdable) ARDelRange(ctx context.Context, key string, ranges ...ARRange) *UintCmd { + args := make([]any, 2, 2+2*len(ranges)) + args[0] = "ardelrange" + args[1] = key + for _, r := range ranges { + args = append(args, r.Start, r.End) + } + cmd := NewUintCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARLen returns the length of an array (max index + 1). +// Returns 0 if the key does not exist. +func (c cmdable) ARLen(ctx context.Context, key string) *UintCmd { + cmd := NewUintCmd(ctx, "arlen", key) + _ = c(ctx, cmd) + return cmd +} + +// ARCount returns the number of non-empty elements in an array. +// Returns 0 if the key does not exist. +func (c cmdable) ARCount(ctx context.Context, key string) *UintCmd { + cmd := NewUintCmd(ctx, "arcount", key) + _ = c(ctx, cmd) + return cmd +} + +// ARNext returns the next index ARINSERT would use. +// Returns 0 for missing keys or when no insert happened yet. +// Returns nil when the insertion cursor is exhausted / would overflow. +func (c cmdable) ARNext(ctx context.Context, key string) *UintCmd { + cmd := NewUintCmd(ctx, "arnext", key) + _ = c(ctx, cmd) + return cmd +} + +// ARSeek sets the ARINSERT / ARRING cursor to a specific index. +// Returns 1 if the cursor was set, 0 if the key does not exist. +func (c cmdable) ARSeek(ctx context.Context, key string, index uint64) *IntCmd { + cmd := NewIntCmd(ctx, "arseek", key, index) + _ = c(ctx, cmd) + return cmd +} + +// ARInfo returns metadata about an array. +func (c cmdable) ARInfo(ctx context.Context, key string) *MapStringInterfaceCmd { + cmd := NewMapStringInterfaceCmd(ctx, "arinfo", key) + _ = c(ctx, cmd) + return cmd +} + +// ARInfoFull returns detailed metadata about an array including slice statistics. +func (c cmdable) ARInfoFull(ctx context.Context, key string) *MapStringInterfaceCmd { + cmd := NewMapStringInterfaceCmd(ctx, "arinfo", key, "full") + _ = c(ctx, cmd) + return cmd +} + +// ARScan iterates existing elements in a range, returning index-value pairs. +func (c cmdable) ARScan(ctx context.Context, key string, start, end uint64, scanArgs *ARScanArgs) *AREntrySliceCmd { + args := make([]any, 4, 6) + args[0], args[1], args[2], args[3] = "arscan", key, start, end + if scanArgs != nil && scanArgs.Limit > 0 { + args = append(args, "limit", scanArgs.Limit) + } + cmd := NewAREntrySliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// AROpSum returns the sum of numeric elements in a range. +func (c cmdable) AROpSum(ctx context.Context, key string, start, end uint64) *StringCmd { + cmd := NewStringCmd(ctx, "arop", key, start, end, "SUM") + _ = c(ctx, cmd) + return cmd +} + +// AROpMin returns the minimum numeric element in a range. +func (c cmdable) AROpMin(ctx context.Context, key string, start, end uint64) *StringCmd { + cmd := NewStringCmd(ctx, "arop", key, start, end, "MIN") + _ = c(ctx, cmd) + return cmd +} + +// AROpMax returns the maximum numeric element in a range. +func (c cmdable) AROpMax(ctx context.Context, key string, start, end uint64) *StringCmd { + cmd := NewStringCmd(ctx, "arop", key, start, end, "MAX") + _ = c(ctx, cmd) + return cmd +} + +// AROpAnd returns the bitwise AND of integer elements in a range. +func (c cmdable) AROpAnd(ctx context.Context, key string, start, end uint64) *IntCmd { + cmd := NewIntCmd(ctx, "arop", key, start, end, "AND") + _ = c(ctx, cmd) + return cmd +} + +// AROpOr returns the bitwise OR of integer elements in a range. +func (c cmdable) AROpOr(ctx context.Context, key string, start, end uint64) *IntCmd { + cmd := NewIntCmd(ctx, "arop", key, start, end, "OR") + _ = c(ctx, cmd) + return cmd +} + +// AROpXor returns the bitwise XOR of integer elements in a range. +func (c cmdable) AROpXor(ctx context.Context, key string, start, end uint64) *IntCmd { + cmd := NewIntCmd(ctx, "arop", key, start, end, "XOR") + _ = c(ctx, cmd) + return cmd +} + +// AROpMatch returns the count of elements matching a target string in a range. +func (c cmdable) AROpMatch(ctx context.Context, key string, start, end uint64, value string) *IntCmd { + cmd := NewIntCmd(ctx, "arop", key, start, end, "MATCH", value) + _ = c(ctx, cmd) + return cmd +} + +// AROpUsed returns the count of non-empty slots in a range. +func (c cmdable) AROpUsed(ctx context.Context, key string, start, end uint64) *IntCmd { + cmd := NewIntCmd(ctx, "arop", key, start, end, "USED") + _ = c(ctx, cmd) + return cmd +} + +// ARGrep searches array elements in a range using textual predicates. +// Returns matching indexes only. Use ARGrepWithValues to also get the values. +func (c cmdable) ARGrep(ctx context.Context, key string, start, end string, grepArgs *ARGrepArgs) *UintSliceCmd { + args := make([]any, 4, 4+grepArgs.Len()) + args[0], args[1], args[2], args[3] = "argrep", key, start, end + args = grepArgs.Append(args) + cmd := NewUintSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARGrepWithValues searches array elements in a range using textual predicates. +// Returns matching indexes and their values as index-value pairs. +func (c cmdable) ARGrepWithValues(ctx context.Context, key string, start, end string, grepArgs *ARGrepArgs) *AREntrySliceCmd { + args := make([]any, 4, 5+grepArgs.Len()) + args[0], args[1], args[2], args[3] = "argrep", key, start, end + args = grepArgs.Append(args) + args = append(args, "withvalues") + cmd := NewAREntrySliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (args *ARGrepArgs) Len() int { + if args == nil { + return 0 + } + n := 2 * len(args.Predicates) + if args.CombineAnd { + n++ + } + if args.Limit > 0 { + n += 2 + } + if args.NoCase { + n++ + } + return n +} + +func (args *ARGrepArgs) Append(a []any) []any { + if args == nil { + return a + } + for _, p := range args.Predicates { + a = append(a, string(p.Type), p.Value) + } + if args.CombineAnd { + a = append(a, "and") + } + if args.Limit > 0 { + a = append(a, "limit", args.Limit) + } + if args.NoCase { + a = append(a, "nocase") + } + return a +} + +// ARRing inserts values into a ring buffer of specified size, wrapping and truncating as needed. +// Returns the last index where a value was inserted. +func (c cmdable) ARRing(ctx context.Context, key string, size uint64, values ...string) *UintCmd { + args := make([]any, 3, 3+len(values)) + args[0] = "arring" + args[1] = key + args[2] = size + for _, v := range values { + args = append(args, v) + } + cmd := NewUintCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ARLastItems returns the most recently inserted elements. +// When rev is true, returns items in reverse order. +func (c cmdable) ARLastItems(ctx context.Context, key string, count uint64, rev bool) *SliceCmd { + args := make([]any, 3, 4) + args[0], args[1], args[2] = "arlastitems", key, count + if rev { + args = append(args, "rev") + } + cmd := NewSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} diff --git a/vendor/github.com/redis/go-redis/v9/auth/auth.go b/vendor/github.com/redis/go-redis/v9/auth/auth.go new file mode 100644 index 0000000000..21667a1284 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/auth/auth.go @@ -0,0 +1,79 @@ +// Package auth package provides authentication-related interfaces and types. +// It also includes a basic implementation of credentials using username and password. +package auth + +// StreamingCredentialsProvider is an interface that defines the methods for a streaming credentials provider. +// It is used to provide credentials for authentication. +// The CredentialsListener is used to receive updates when the credentials change. +type StreamingCredentialsProvider interface { + // Subscribe subscribes to the credentials provider for updates. + // It returns the current credentials, a cancel function to unsubscribe from the provider, + // and an error if any. + // + // Implementations MUST be idempotent with respect to listener identity: + // subscribing the same listener value more than once must not produce + // duplicate notifications and must not create multiple independent + // subscriptions that each need to be cancelled separately. Every + // UnsubscribeFunc returned for a given listener must cancel that + // listener's subscription; calling any one of them must be sufficient to + // stop updates to that listener, and calling subsequent ones must be a + // safe no-op. Callers (including go-redis internals) may retain only + // the most recently returned UnsubscribeFunc and rely on it to fully + // unsubscribe the listener. + // + // TODO(ndyakov): Should we add context to the Subscribe method? + Subscribe(listener CredentialsListener) (Credentials, UnsubscribeFunc, error) +} + +// UnsubscribeFunc is a function that is used to cancel the subscription to the credentials provider. +// It is used to unsubscribe from the provider when the credentials are no longer needed. +// +// Per the StreamingCredentialsProvider.Subscribe contract, if the same +// listener is subscribed multiple times, every UnsubscribeFunc returned for +// that listener must fully unsubscribe it on first invocation, and +// subsequent invocations (from any of the equivalent UnsubscribeFuncs) must +// be a safe no-op. +type UnsubscribeFunc func() error + +// CredentialsListener is an interface that defines the methods for a credentials listener. +// It is used to receive updates when the credentials change. +// The OnNext method is called when the credentials change. +// The OnError method is called when an error occurs while requesting the credentials. +type CredentialsListener interface { + OnNext(credentials Credentials) + OnError(err error) +} + +// Credentials is an interface that defines the methods for credentials. +// It is used to provide the credentials for authentication. +type Credentials interface { + // BasicAuth returns the username and password for basic authentication. + BasicAuth() (username string, password string) + // RawCredentials returns the raw credentials as a string. + // This can be used to extract the username and password from the raw credentials or + // additional information if present in the token. + RawCredentials() string +} + +type basicAuth struct { + username string + password string +} + +// RawCredentials returns the raw credentials as a string. +func (b *basicAuth) RawCredentials() string { + return b.username + ":" + b.password +} + +// BasicAuth returns the username and password for basic authentication. +func (b *basicAuth) BasicAuth() (username string, password string) { + return b.username, b.password +} + +// NewBasicCredentials creates a new Credentials object from the given username and password. +func NewBasicCredentials(username, password string) Credentials { + return &basicAuth{ + username: username, + password: password, + } +} diff --git a/vendor/github.com/redis/go-redis/v9/auth/reauth_credentials_listener.go b/vendor/github.com/redis/go-redis/v9/auth/reauth_credentials_listener.go new file mode 100644 index 0000000000..40076a0b13 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/auth/reauth_credentials_listener.go @@ -0,0 +1,47 @@ +package auth + +// ReAuthCredentialsListener is a struct that implements the CredentialsListener interface. +// It is used to re-authenticate the credentials when they are updated. +// It contains: +// - reAuth: a function that takes the new credentials and returns an error if any. +// - onErr: a function that takes an error and handles it. +type ReAuthCredentialsListener struct { + reAuth func(credentials Credentials) error + onErr func(err error) +} + +// OnNext is called when the credentials are updated. +// It calls the reAuth function with the new credentials. +// If the reAuth function returns an error, it calls the onErr function with the error. +func (c *ReAuthCredentialsListener) OnNext(credentials Credentials) { + if c.reAuth == nil { + return + } + + err := c.reAuth(credentials) + if err != nil { + c.OnError(err) + } +} + +// OnError is called when an error occurs. +// It can be called from both the credentials provider and the reAuth function. +func (c *ReAuthCredentialsListener) OnError(err error) { + if c.onErr == nil { + return + } + + c.onErr(err) +} + +// NewReAuthCredentialsListener creates a new ReAuthCredentialsListener. +// Implements the auth.CredentialsListener interface. +func NewReAuthCredentialsListener(reAuth func(credentials Credentials) error, onErr func(err error)) *ReAuthCredentialsListener { + return &ReAuthCredentialsListener{ + reAuth: reAuth, + onErr: onErr, + } +} + +// Ensure ReAuthCredentialsListener implements the CredentialsListener interface. +var _ CredentialsListener = (*ReAuthCredentialsListener)(nil) diff --git a/vendor/github.com/redis/go-redis/v9/autopipeline.go b/vendor/github.com/redis/go-redis/v9/autopipeline.go new file mode 100644 index 0000000000..42ac8d5059 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/autopipeline.go @@ -0,0 +1,2609 @@ +package redis + +import ( + "context" + "errors" + "fmt" + "io" + "runtime" + "runtime/debug" + "strings" + "sync" + "sync/atomic" + "time" + + "golang.org/x/sys/cpu" + + "github.com/redis/go-redis/v9/internal" +) + +// AutoPipelineOptions configures the autopipelining behavior. +// +// EXPERIMENTAL: this API is subject to change, use with caution. +type AutoPipelineOptions struct { + // MaxBatchSize is the target batch size: the accumulator stops waiting for + // more commands once the shard queue reaches it, so a batch flushes promptly + // instead of lingering. It is a soft threshold, not a hard cap — under heavy + // concurrent enqueue (or while a flush waits on the concurrency semaphore) the + // queue can grow past it and execute as a single larger pipeline, which is + // safe and simply yields a deeper pipeline. + // Default: 200 (the blocking face's no-options preset, + // DefaultBlockingAutoPipelineOptions, uses 300). + MaxBatchSize int + + // MaxBatchBytes, when > 0, caps a batch by APPROXIMATE payload volume: the + // accumulator stops waiting once the queued commands' argument bytes reach + // it, so many large values flush as several bounded writes instead of one + // huge burst (300 x 64KiB is ~19MB written down one connection before any + // reply is read — enough to stall a constrained link past its write + // deadline). Like MaxBatchSize it is a soft threshold, not a hard cap. + // The estimate counts string/[]byte argument lengths plus a small + // per-argument overhead. Default: 0 (no byte cap). + MaxBatchBytes int + + // MaxConcurrentBatches is the maximum number of pipeline batches that may + // execute concurrently. + // + // Default: 1, which gives a single ordered command stream — batches execute + // serially in submit order, so even a windowed caller (submit many, read + // later) sees strict ordering, while still reaching high throughput via deep + // pipelines (~3M ops/sec locally). + // + // Setting this above 1 runs batches in parallel for maximum throughput, but + // commands then have NO guaranteed execution order. Because that trades away + // ordering, it is only allowed together with Unordered: true — otherwise the + // configuration is rejected (see Validate). This makes the trade-off + // explicit: you cannot accidentally lose ordering by raising concurrency. + MaxConcurrentBatches int + + // Unordered must be set to true to allow MaxConcurrentBatches > 1. It is the + // caller's explicit acknowledgement that parallel batch execution gives up + // command ordering in exchange for throughput. With the default (false), + // MaxConcurrentBatches is forced to 1 (an ordered stream) and any value > 1 + // is a configuration error. + Unordered bool + + // contentSharded is set internally by cluster wiring when commands are + // routed to shards by content (slot), so same-key commands always share a + // shard and per-key order holds even with several shards. It exempts that + // wiring from the NumShards ordering check in newAutoPipeliner. Never set + // by users (unexported). + contentSharded bool + + // NumShards is the number of independent queue+flusher shards the + // autopipeliner runs. 0 (the default) means auto: a single shard, which + // funnels every caller into one queue so batches stay deep — measured + // throughput and latency are best with one shard even under heavy + // goroutine concurrency. Cluster clients default to several slot-routed + // shards instead, so commands for different nodes queue independently + // (per-key order still holds: a key's slot always maps to the same + // shard). Raising NumShards splits the queue: it reduces enqueue-mutex + // contention but fragments batches, which usually costs far more than the + // contention saves. Every shard always has at least one concurrency + // permit, so the effective global batch concurrency is + // max(NumShards, MaxConcurrentBatches) — and because shards flush + // concurrently, NumShards > 1 on the deferred (async) face requires + // Unordered: true (construction fails otherwise). + NumShards int + + // MaxFlushDelay is the maximum delay after flushing before checking for more commands. + // A small delay (e.g., 100μs) can significantly reduce CPU usage by allowing + // more commands to batch together, at the cost of slightly higher latency. + // + // Trade-off: + // - 0 (default): Lowest latency, higher CPU usage + // - 100μs: Balanced (recommended for most workloads) + // - 500μs: Lower CPU usage, higher latency + // + // Based on benchmarks, 100μs can reduce CPU usage by 50% + // while adding only ~100μs average latency per command. + // Default: 0, meaning the flusher applies no coalescing wait — it flushes + // each batch as soon as the queue is ready and lets in-flight backpressure + // coalesce concurrent callers (see accumulateBatch). Set a value here to add + // an explicit accumulation window, trading latency for larger batches / less + // CPU as described above. + MaxFlushDelay time.Duration + + // AdaptiveDelay enables smart delay calculation based on queue fill level. + // When enabled, the delay is automatically adjusted: + // - Queue ≥75% full: No delay (flush immediately to prevent overflow) + // - Queue ≥50% full: 25% of MaxFlushDelay (queue filling up) + // - Queue ≥25% full: 50% of MaxFlushDelay (moderate load) + // - Queue <25% full: 100% of MaxFlushDelay (low load, maximize batching) + // + // This provides automatic adaptation to varying load patterns without + // manual tuning. Uses integer-only arithmetic for optimal performance. + // Default: false (use fixed MaxFlushDelay) + AdaptiveDelay bool +} + +// autoPipelinePermitBackstop bounds how long a flush waits for a concurrency +// permit when all are busy. It is only a safety net against a wedged semaphore: +// every permit holder releases it (via defer) and each batch Exec is itself +// bounded by the connection's read/write timeout, so in normal operation a +// permit frees long before this. It is set well above the default ReadTimeout +// and a maintnotifications relaxed window so a legitimately slow in-flight batch +// never makes waiters fail spuriously. The wait deliberately does NOT end on +// Close: commands taken from the queue were already accepted, and Close's +// contract is to flush them (it waits via wg/batchWg), so permit waits run on +// a background context bounded only by this backstop. +const autoPipelinePermitBackstop = 30 * time.Second + +// autoPipelineCloseBackstop bounds Close's wait for in-flight dispatches. It +// deliberately carries the same value as the permit backstop but its OWN name: +// the two answer different questions, and this one may want tuning on its own. +// +// Why it is generous rather than snappy: the bound is only ever REACHED when a +// dispatch cannot end by itself — a blocking command with no timeout, or a +// stalled read with ReadTimeout disabled. In every other configuration the +// read timeout ends the dispatch and Close returns the moment it does, well +// under this value. A tighter bound would not speed up healthy shutdowns; it +// would instead make Close report failure while legitimate work is still +// finishing (a large final batch, or a maintnotifications relaxed window +// during a failover), turning a correct slow drain into a spurious error. +const autoPipelineCloseBackstop = 30 * time.Second + +// numAutoPipelineShards is the shard-count default used by CLUSTER wiring, +// where commands are routed to shards by slot so different nodes' batches +// queue independently (every shard keeps at least one concurrency permit, so +// several shards can flush to their nodes in parallel regardless of +// MaxConcurrentBatches). It is NOT used for standalone clients: those default +// to one shard (see newAutoPipeliner), because a single deep queue pipelines +// far better than a fragmented one. Deliberately NOT derived from +// MaxConcurrentBatches — coupling shard count to the permit budget silently +// collapsed cluster slot routing to a single shard at the default budget. +func numAutoPipelineShards() int { + n := runtime.GOMAXPROCS(0) + if n < 1 { + n = 1 + } + const maxShards = 16 + if n > maxShards { + n = maxShards + } + return n +} + +// DefaultAutoPipelineOptions returns the default autopipelining configuration. +// +// The default is ordered: MaxConcurrentBatches is 1, so batches execute +// serially in submit order (a single ordered command stream) while still +// reaching high throughput via deep pipelines when callers submit in windows. +// To trade ordering for parallel-batch throughput, set MaxConcurrentBatches > 1 +// together with Unordered: true. +// +// EXPERIMENTAL: this API is subject to change, use with caution. +func DefaultAutoPipelineOptions() *AutoPipelineOptions { + return &AutoPipelineOptions{ + MaxBatchSize: 200, + MaxConcurrentBatches: 1, // ordered by default + MaxFlushDelay: 0, // lowest latency; no coalescing wait (batch via in-flight backpressure) + } +} + +// DefaultBlockingAutoPipelineOptions returns the default config for the +// blocking face (Client.AutoPipeline). It uses a single ordered batch stream +// (MaxConcurrentBatches: 1). Counterintuitively this maximizes throughput AND +// minimizes latency for the blocking face: with one batch in flight, callers whose +// commands return while it executes re-enqueue and flush together as the next +// batch, so batches stay deep (a near-continuous, double-buffered pipeline), +// while a lone caller flushes promptly in a single round-trip (no coalescing +// wait — see accumulateBatch). More parallel permits (MaxConcurrentBatches>1) do the +// opposite: each command finds a free permit and flushes on its own before +// others accumulate, collapsing batch size — and throughput — toward one command +// per round-trip while latency rises. For maximum throughput use the async face +// (AsyncAutoPipeline) with a window of in-flight commands (inflight>1); it keeps +// MaxConcurrentBatches: 1 as well. +// +// EXPERIMENTAL: this API is subject to change, use with caution. +func DefaultBlockingAutoPipelineOptions() *AutoPipelineOptions { + return &AutoPipelineOptions{ + MaxBatchSize: 300, + MaxConcurrentBatches: 1, + } +} + +// Validate reports whether the configuration is self-consistent. It returns an +// error if MaxConcurrentBatches > 1 without Unordered: true — raising +// concurrency gives up command ordering, so the caller must opt in explicitly. +// +// Validate()==nil does not guarantee construction succeeds: rules that need +// the face (e.g. NumShards>1 requires Unordered on the deferred face) are +// enforced by the AutoPipeline/AsyncAutoPipeline getters. Note also that +// Options.AutoPipelineOptions is validated lazily — on the first getter +// call, not in NewClient. +func (cfg *AutoPipelineOptions) Validate() error { + if cfg.MaxConcurrentBatches > 1 && !cfg.Unordered { + return fmt.Errorf("redis: AutoPipelineOptions.MaxConcurrentBatches=%d requires Unordered:true "+ + "(parallel batches do not preserve command ordering); set Unordered:true to allow it, "+ + "or keep MaxConcurrentBatches=1 for an ordered stream", cfg.MaxConcurrentBatches) + } + // Reject obviously-wrong negatives so a typo surfaces at construction rather + // than being silently coerced to a default. Zero is allowed and means "use + // the default" (MaxBatchSize) or "no delay" (MaxFlushDelay). + if cfg.MaxBatchSize < 0 { + return fmt.Errorf("redis: AutoPipelineOptions.MaxBatchSize=%d must be >= 0", cfg.MaxBatchSize) + } + if cfg.MaxBatchBytes < 0 { + return fmt.Errorf("redis: AutoPipelineOptions.MaxBatchBytes=%d must be >= 0", cfg.MaxBatchBytes) + } + if cfg.MaxConcurrentBatches < 0 { + return fmt.Errorf("redis: AutoPipelineOptions.MaxConcurrentBatches=%d must be >= 0", cfg.MaxConcurrentBatches) + } + if cfg.MaxFlushDelay < 0 { + return fmt.Errorf("redis: AutoPipelineOptions.MaxFlushDelay=%s must be >= 0", cfg.MaxFlushDelay) + } + if cfg.NumShards < 0 { + return fmt.Errorf("redis: AutoPipelineOptions.NumShards=%d must be >= 0", cfg.NumShards) + } + if cfg.AdaptiveDelay && cfg.MaxFlushDelay <= 0 { + return fmt.Errorf("redis: AutoPipelineOptions.AdaptiveDelay requires MaxFlushDelay > 0 " + + "(adaptive delay scales MaxFlushDelay by queue fill; with no MaxFlushDelay it would " + + "silently disable batch accumulation entirely)") + } + return nil +} + +// cmdableClient is an interface for clients that support pipelining. +// Both Client and ClusterClient implement this interface. It embeds +// UniversalClient (Cmdable + Process + Do + AddHook + Watch + Subscribe... + +// Close + PoolStats) so the AutoPipeliner can delegate the non-batched surface +// back to the underlying client and itself satisfy UniversalClient. +type cmdableClient interface { + UniversalClient + // processPipelineHook is the hook-wrapped []Cmder pipeline entry — the same + // method Pipeline.Exec is wired to (see Client.Pipeline). The flusher + // dispatches drained batches through it directly, skipping the per-batch + // Pipeline construction; hooks/OTel see the identical call. + processPipelineHook(ctx context.Context, cmds []Cmder) error + // The async faces additionally dispatch through withProcessPipelineHook / + // withProcessHook with the base processors as the innermost, so the batch + // can be completed UNDER the user hooks (results ready the moment exec + // returns, before hooks unwind). Both *Client and *ClusterClient satisfy + // these via hooksMixin and their base processors. + withProcessPipelineHook(ctx context.Context, cmds []Cmder, hook ProcessPipelineHook) error + hookCount() int + withProcessHook(ctx context.Context, cmd Cmder, hook ProcessHook) error + processPipeline(ctx context.Context, cmds []Cmder) error + process(ctx context.Context, cmd Cmder) error +} + +// apBatch is the completion signal shared by every command flushed together. +// Its done channel is closed exactly once, when the batch's pipeline has +// executed. Closing one channel wakes all waiters in a single operation, +// instead of doing one buffered-channel send per command — under high +// concurrency the per-command sends dominated CPU (channel-lock contention and +// one goroutine wake-up apiece). +type apBatch struct { + done chan struct{} + // closed makes close() idempotent: on the async faces the dispatch closes + // the batch at the innermost exec seam (under the user hooks, so a hook + // reading a result after next() does not block on a channel its own + // goroutine closes — the #3867 deadlock), while the flusher keeps its + // deferred close as a panic backstop. Whichever runs first wins. + closed atomic.Bool + // dispGid is the goroutine id of the dispatcher while the batch is inside + // the hook chain (0 otherwise). await() consults it before blocking so a + // hook on the dispatch goroutine reading a result BEFORE next() gets the + // not-yet-executed view — what a plain pipeline hook sees — instead of a + // self-deadlock. + dispGid atomic.Int64 + // nodeGids registers cluster per-node executor goroutines: the cluster + // pipeline fans a batch out to one goroutine per node, and each runs the + // NODE client's own hook chain (OnNewNode hooks — redisotel's tracing + // lives there), which the single dispGid slot cannot vouch for. A node + // hook reading a result there would block on a batch that completes only + // after its own return — reproduced as a permanent wedge with a + // rediscmd-shaped Err() peek. Guarded by nodeMu; entered/left once per + // node call, consulted only on the guards' slow path (done still open). + nodeMu sync.Mutex + nodeGids []int64 + // nodeCount mirrors len(nodeGids) so isExecutorGoroutine's fast path can + // skip the goroutine-id parse and the mutex entirely when nobody is + // registered — which is every standalone batch, always, and a cluster + // batch outside its node fan-out window. + nodeCount atomic.Int32 +} + +// enterNodeDispatch registers the calling goroutine as an executor of this +// batch for the duration of a cluster node call; the returned func +// unregisters it. Registered goroutines get the same treatment as the +// dispatcher in the accessor guards: result reads return the current view +// instead of self-deadlocking on the batch's own completion signal. +func (b *apBatch) enterNodeDispatch() func() { + gid := curGoroutineID() + b.nodeMu.Lock() + b.nodeGids = append(b.nodeGids, gid) + b.nodeCount.Store(int32(len(b.nodeGids))) + b.nodeMu.Unlock() + return func() { + b.nodeMu.Lock() + for i, g := range b.nodeGids { + if g == gid { + b.nodeGids[i] = b.nodeGids[len(b.nodeGids)-1] + b.nodeGids = b.nodeGids[:len(b.nodeGids)-1] + break + } + } + b.nodeCount.Store(int32(len(b.nodeGids))) + b.nodeMu.Unlock() + } +} + +// isExecutorGoroutine reports whether the CALLING goroutine is currently +// executing this batch: the flusher/dispatch goroutine or a registered +// cluster node executor. The no-executor fast path (dispGid unset and no +// node executors) is two atomic loads — no goroutine-id parse, no lock. That +// laziness is load-bearing: every blocking-face command and every pre-done +// future passes here once per wait, and an earlier revision that parsed the +// goroutine id and took the mutex unconditionally cost the blocking face 6x +// of its throughput (measured 830k -> 138k ops/sec on a loopback bench). +func (b *apBatch) isExecutorGoroutine() bool { + disp := b.dispGid.Load() + if disp == 0 && b.nodeCount.Load() == 0 { + return false + } + gid := curGoroutineID() + if disp != 0 && disp == gid { + return true + } + if b.nodeCount.Load() == 0 { + return false + } + b.nodeMu.Lock() + defer b.nodeMu.Unlock() + for _, g := range b.nodeGids { + if g == gid { + return true + } + } + return false +} + +// noopUnregister is registerBatchExecutors' zero-batch result, shared so the +// plain-pipeline path stays allocation-free. +var noopUnregister = func() {} + +// registerBatchExecutors marks the calling goroutine as an executor of every +// deferred-face batch among cmds (plain pipeline commands carry none) and +// returns the combined unregister. The cluster pipeline calls it around each +// node's hook chain. +func registerBatchExecutors(cmds []Cmder) func() { + var undo []func() + var seenFirst *apBatch + var seenMore map[*apBatch]struct{} + for _, cmd := range cmds { + bc, ok := cmd.(interface{ readyBatch() *apBatch }) + if !ok { + continue + } + b := bc.readyBatch() + if b == nil || b == seenFirst { + continue + } + if seenFirst == nil { + seenFirst = b + } else { + if seenMore == nil { + seenMore = make(map[*apBatch]struct{}, 2) + } + if _, dup := seenMore[b]; dup { + continue + } + seenMore[b] = struct{}{} + } + undo = append(undo, b.enterNodeDispatch()) + } + if len(undo) == 0 { + return noopUnregister + } + return func() { + for _, u := range undo { + u() + } + } +} + +func newAPBatch() *apBatch { return &apBatch{done: make(chan struct{})} } + +// close completes the batch exactly once, waking every waiter. +func (b *apBatch) close() { + if b.closed.CompareAndSwap(false, true) { + close(b.done) + } +} + +// curGoroutineID parses the goroutine id from runtime.Stack's header +// ("goroutine 123 ["). Called only on paths already paying a dispatch or an +// about-to-block round-trip wait — never on await()'s fast path — so the +// microsecond-scale stack read is noise against the batch RTT. +// armSelfDeadlockGuard reports whether async dispatch should stamp the +// dispatcher's goroutine id on the batches (see apBatch.dispGid) — the +// mechanism that lets a hook on the dispatch goroutine read a command +// without deadlocking on a batch only that goroutine completes: before +// next() it sees the not-yet-executed view, after next() the populated +// results (batches complete only when the whole chain has returned). Armed +// when user hooks exist — without hooks nothing can read a command inside +// the chain — and always on cluster clients, whose node clients may carry +// their own hooks (OnNewNode + AddHook, the redisotel pattern) that +// hookCount() cannot see. NOTE: node-level hooks run on node-worker +// goroutines the gid guard cannot identify, so they must not read command +// results on the async face; the same applies to a goroutine a hook spawns +// and joins before returning. A hook added concurrently with an in-flight +// dispatch misses the guard for that one batch. The guard covers result +// READS only: a hook that ISSUES a command on the same AutoPipeliner and +// synchronously waits for it cannot be saved — the nested command needs the +// dispatch slot the hook chain is holding, and the engine recovers only by +// failing the flush after the permit backstops (see +// autoPipelinePermitBackstop) expire. +func (ap *AutoPipeliner) armSelfDeadlockGuard() bool { + return ap.pipeliner.hookCount() > 0 || ap.config.contentSharded +} + +func curGoroutineID() int64 { + var buf [64]byte + n := runtime.Stack(buf[:], false) + const skip = len("goroutine ") + var id int64 + for _, c := range buf[skip:n] { + if c < '0' || c > '9' { + break + } + id = id*10 + int64(c-'0') + } + return id +} + +// The shard queue stores bare Cmders. The batch a command waits on is the +// shard's curBatch at enqueue time — read once to wire the command's ready +// channel and never needed per-command afterward (the flusher closes the one +// shared batch). Storing []Cmder removes a per-command wrapper allocation. + +var queueSlicePool = sync.Pool{ + New: func() interface{} { s := make([]Cmder, 0, 100); return &s }, +} + +func getQueueSlice(capacity int) []Cmder { + slice := (*queueSlicePool.Get().(*[]Cmder))[:0] + if cap(slice) < capacity { + queueSlicePool.Put(&slice) + return make([]Cmder, 0, capacity) + } + return slice +} + +func putQueueSlice(slice []Cmder) { + if cap(slice) <= 1000 { + // Zero only the used prefix: elements beyond len are already nil — + // slices enter the pool fully zeroed (here) and are only appended to + // afterwards, so the tail invariant holds. Zeroing the whole capacity + // memclr'd up to 8 KB per flush for small batches on large recycled + // arrays. + for i := range slice { + slice[i] = nil + } + queueSlicePool.Put(&slice) + } +} + +// AutoPipeliner automatically batches commands and executes them in pipelines. +// It's safe for concurrent use by multiple goroutines. +// +// AutoPipeliner works by collecting commands from multiple goroutines into a +// shared queue and flushing them as one Redis pipeline when the batch reaches +// MaxBatchSize or a configured coalescing window (MaxFlushDelay) elapses. By +// default there is no window: each batch flushes as soon as the queue is ready +// and concurrent callers coalesce via in-flight backpressure, so a lone command +// flushes in a single round-trip while batches stay deep under load. +// +// This provides significant performance improvements for workloads with many +// concurrent small operations, as it reduces the number of network round-trips. +// +// AutoPipeliner implements the Cmdable interface, so you can use it like a +// regular client. Prefer the typed methods (Set, Get, ...); Do runs OUTSIDE +// the pipeline on a normal connection (see Do). +// AutoPipeline / AsyncAutoPipeline return an error for an invalid config, so check it once: +// +// ap, err := client.AutoPipeline() +// if err != nil { +// return err +// } +// ap.Set(ctx, "key", "value", 0) +// ap.Get(ctx, "key") +// ap.Close() +// +// Per-command contexts: a command is batched and executed on the AutoPipeliner's +// own long-lived context, NOT the context passed to the command. A per-command +// deadline or cancellation is therefore not honored once the command is queued +// (this is deliberate — a per-batch timer per command would cost a goroutine +// each). Use a plain client for commands that need their own deadline. +// The one exception is a blocking command (readTimeout() != nil, e.g. BLPOP): +// it is never batched and runs directly on the caller's context, which is +// honored as usual. +// +// Retries: like any pipeline, a batch that fails on a network error is retried +// as a whole (up to Options.MaxRetries). If the connection drops after the +// server executed part of the batch, non-idempotent commands (INCR, LPUSH, ...) +// may execute twice. Run commands that must not be retransmitted on a plain +// client, or set MaxRetries: -1. +// +// Lifetime: AutoPipeline() returns a single, client-owned instance shared by all +// callers. Close()ing it stops the shared pipeliner for everyone; a later +// AutoPipeline() call on the client builds a fresh one. Closing the CLIENT also +// stops it, but permanently: the getters then return ErrClosed. +// +// Formatting: String()/%v on a command issued by the deferred face WAITS for +// execution, exactly like Err()/Val()/Result() — formatting reads the result +// fields, and reading them unsynchronized would race the dispatcher populating +// them. The one exception is a hook formatting a command from the batch's own +// dispatch goroutine: that returns the not-yet-executed view instead of +// self-deadlocking. Use Name()/Args() if you need to log a submission without +// waiting for it. +// +// EXPERIMENTAL: this API is subject to change, use with caution. +type AutoPipeliner struct { + cmdable // Embed cmdable to get all Redis command methods + + pipeliner cmdableClient + config *AutoPipelineOptions + // blocking selects how the typed command surface (Set, Get, ...) behaves: + // when true the command call itself blocks until the command has executed + // (drop-in, synchronous shape); when false the call returns immediately and + // the result accessors (Val/Result/Err) block. See AutoPipeline (blocking) + // vs AsyncAutoPipeline (deferred). + blocking bool + + // Sharded command queues. Each shard has its own queue, mutex and flusher + // goroutine, so enqueues from many goroutines spread across shards instead + // of all contending on a single mutex and being drained by a single + // flusher. Commands are assigned to shards round-robin; per-goroutine + // ordering is still guaranteed because Do blocks for each command's result + // before issuing the next one. + shards []*apShard + next atomic.Uint32 // round-robin shard selector + // shardFn, when set, picks a command's shard from its content (cluster mode + // sets it to route by slot so all commands for one node land in the same + // shard's batch — keeping per-node pipelines deep instead of splitting every + // batch across nodes). When nil, commands are assigned round-robin. + shardFn func(Cmder) int + + // preflight, when set, can reject a command at submit time, before it is + // enqueued or dispatched (cluster mode refuses fan-out-policy commands + // that cannot ride a pipeline, so one caller's command cannot poison a + // merged batch). The returned error is set on the command. + preflight func(ctx context.Context, cmd Cmder) error + + // mustDivert, when set, forces a command off the batching path even though + // it is otherwise batchable — cluster mode uses it for commands whose + // routing is NOT slot-derived (ReqSpecial, e.g. FT.CURSOR READ, which is + // sticky to the node that owns the cursor). Batched, mapCmdsByNode would + // route them by slot and reach the wrong shard; diverted, they go through + // Client/ClusterClient.Process and keep their special routing. + mustDivert func(ctx context.Context, cmd Cmder) bool + + // sharedClosed, when non-nil, is the owning client's pool-set closed flag + // (shared across WithTimeout clones). The getters refuse to build a fresh + // pipeliner once it is set; this reference makes an ALREADY-built + // pipeliner refuse new work too — without it, a clone's Close would leave + // a cached pipeliner accepting enqueues against closed pools, failing + // them one dispatch at a time instead of with ErrClosed at submit. + sharedClosed *atomic.Bool + + // expectedArrivals counts how many commands the engine expects to arrive + // at any moment: a completed batch of N≥2 commands wakes its N waiters + // together, and in a closed loop each immediately submits its next command + // — so completion announces N expected arrivals, and every enqueue accounts + // for one. The default coalescing wait (awaitExpectedArrivals) holds the + // flusher while arrivals are still expected, so the whole wakeup wave + // flushes as one deep pipeline — an exact count, not a smoothed estimate, + // which cannot ratchet into fragmentation. Single-command batches announce + // nothing, so a lone caller and open-loop traffic never wait. May + // transiently go negative (arrivals nobody announced); readers clamp to + // zero. Pipeliner-global, not per-shard: cluster routing may land a + // follow-up on a different shard than the batch that woke its caller. + expectedArrivals atomic.Int64 + + // execEWMA is an exponentially-weighted moving average (alpha 1/8) of + // batch execution time in nanoseconds — the engine's own view of the + // server round-trip. It scales awaitExpectedArrivals's silence fallback so a + // wave staggered by scheduling on a slow link is not split mid-landing. Updates + // are racy read-modify-writes by design: losing an occasional sample is + // harmless for a smoothing heuristic. 0 means "no sample yet". + execEWMA atomic.Int64 + + // Lifecycle + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup // Tracks flusher goroutines + batchWg sync.WaitGroup // Tracks batch execution goroutines + // divertWg tracks the goroutines that execute DIVERTED commands (blocking + // and connection-hostile ones, which never enter a batch). Close waits on + // it exactly like batchWg so a diverted command's pooled connection is not + // left in flight after Close returns — bounded, see Close. + // + // divertMu serializes "observe not-closed, then register" against Close's + // "mark closed, then wait": without it a diverted command could pass the + // closed check, Close could see a zero counter and return, and only then + // would the goroutine register — leaving an accepted command holding a + // pooled connection past Close (and racing WaitGroup Add against Wait). + divertMu sync.Mutex + divertWg sync.WaitGroup + closed atomic.Bool +} + +// apShard is one queue + flusher. Its fields are touched only by enqueuing +// goroutines (under mu) and by its own single flusher goroutine. +// apEnqueueStripes is how many enqueue stripes a shard runs when striping is +// safe (unordered configs, and every blocking-face shard — a blocking caller +// waits for each command, so stripes cannot reorder its stream). The +// enqueue mutex is the hottest lock in the engine (128 concurrent callers on +// one shard spend ~half their CPU in lock slow paths); striping the queue +// spreads that contention while the flusher still drains every stripe into ONE +// merged pipeline, so batches stay deep. Ordered shards always use a single +// stripe: with several stripes a caller's consecutive commands can land in +// stripes on opposite sides of an in-progress drain and execute out of order. +const apEnqueueStripes = 8 + +// apStripe is one striped slice of a shard's enqueue queue. Each stripe has +// its own batch-completion signal so a drain can take stripes one lock at a +// time; every batch taken in one drain completes together after the merged +// pipeline executes. Padded so neighbouring stripes' mutexes do not share a +// cache line. +type apStripe struct { + mu sync.Mutex + queue []Cmder + queueLen atomic.Int32 + // queueBytes approximates the queued commands' payload volume; maintained + // only when MaxBatchBytes is configured (see cmdApproxBytes). + queueBytes atomic.Int64 + curBatch *apBatch // completion signal for currently-queued cmds + // Pad each stripe onto its own cache line(s). Without it, one stripe's hot + // fields (queueLen/curBatch) share a cache line with the NEXT stripe's + // contended mutex, so a lock-free counter bump on stripe i invalidates the + // line a different core is trying to lock stripe i+1 on — false sharing + // that measured ~16x on a contended microbenchmark. cpu.CacheLinePad is + // sized per GOARCH (64 B on x86-64/arm64, 128 B on ppc64, 256 B on s390x), + // so this is correct on every target rather than a hand-tuned constant. + _ cpu.CacheLinePad +} + +type apShard struct { + ap *AutoPipeliner + + next atomic.Uint32 // round-robin stripe pick (unordered mode) + stripes []apStripe // 1 stripe when ordered, apEnqueueStripes when Unordered + notify chan struct{} // buffered (cap 1) enqueue wake-up + sem *internal.FIFOSemaphore // per-shard concurrent-batch budget + + // inFlight counts this shard's dispatched-but-unfinished batches. When it + // is zero and no arrivals are expected, the shard is idle and a + // new command flushes immediately; when batches are in flight, arrivals + // are mid-stream and the flusher holds them briefly to coalesce (see + // awaitExpectedArrivals). + inFlight atomic.Int32 +} + +// stripe picks the enqueue stripe for the next command: the single stripe in +// ordered mode (preserving strict FIFO), round-robin in unordered mode. +func (s *apShard) stripe() *apStripe { + if len(s.stripes) == 1 { + return &s.stripes[0] + } + return &s.stripes[s.next.Add(1)%uint32(len(s.stripes))] +} + +// getOrCreateAutoPipeliner is the shared caching protocol behind the four +// AutoPipeline/AsyncAutoPipeline getters (Client and ClusterClient, each +// face): return the cached live instance, refuse on a closed client, or build +// and cache a new one. The caller supplies its cached-slot pointer, its +// closed flag (both guarded by the mutex), the explicit-config override, the +// fallback config, and a build closure (the cluster one wraps +// clusterAutoPipelineOptions and installs slot sharding). +func getOrCreateAutoPipeliner( + mu *sync.Mutex, + slot **AutoPipeliner, + closed *bool, + sharedClosed *atomic.Bool, + override *AutoPipelineOptions, + fallback func() *AutoPipelineOptions, + build func(*AutoPipelineOptions) (*AutoPipeliner, error), +) (*AutoPipeliner, error) { + mu.Lock() + defer mu.Unlock() + // closed covers THIS wrapper's Close; sharedClosed covers the shared + // pools closing through ANY sharer (e.g. a WithTimeout clone falling + // through to baseClient.Close) — a fresh pipeliner against closed pools + // would leak flushers that error forever. + if *closed || (sharedClosed != nil && sharedClosed.Load()) { + return nil, ErrClosed + } + if *slot != nil && !(*slot).closed.Load() { + return *slot, nil + } + cfg := override + if cfg == nil { + cfg = fallback() + } + ap, err := build(cfg) + if err != nil { + return nil, err + } + // Thread the shared pool-set closed flag into the pipeliner so an + // ALREADY-cached instance also refuses enqueues once any sharer closes + // the pools (the check above only protects fresh builds). + ap.sharedClosed = sharedClosed + *slot = ap + return ap, nil +} + +// newAutoPipeliner builds an autopipeliner in either blocking or deferred mode. +// It is unexported on purpose: the public entry points are +// Client/ClusterClient.AutoPipeline and AsyncAutoPipeline, which also install +// cluster slot-sharding. Constructing one directly would skip that wiring and +// give a *ClusterClient degraded (cross-node) batching. +func newAutoPipeliner(pipeliner cmdableClient, config *AutoPipelineOptions, blocking bool) (*AutoPipeliner, error) { + if config == nil { + config = DefaultAutoPipelineOptions() + } else { + // Copy so default-filling below doesn't mutate the caller's struct — the + // same *AutoPipelineOptions may be shared across clients (e.g. a reused + // Options.AutoPipelineOptions), and callers may inspect it afterward. + cfgCopy := *config + config = &cfgCopy + } + + // Validate BEFORE default-filling: Validate treats zero as "use the + // default" but rejects negatives, and coercing first would silently + // swallow a negative typo the documented contract promises to error on. + if err := config.Validate(); err != nil { + return nil, err + } + + // Apply defaults for zero values + if config.MaxBatchSize <= 0 { + config.MaxBatchSize = 200 + } + + if config.MaxConcurrentBatches <= 0 { + // Default to an ordered single stream. Callers raise this (with + // Unordered:true) to opt into parallel-batch throughput. + config.MaxConcurrentBatches = 1 + } + + // NumShards > 1 on the deferred (async) face distributes commands + // round-robin across shards that flush concurrently, so submit order is + // not preserved — require the explicit Unordered opt-in, exactly like + // MaxConcurrentBatches > 1. The blocking face is exempt (each caller waits + // per command, and Submit is rejected there), as is cluster slot sharding + // (contentSharded: same-key commands always land in the same shard, so + // per-key order holds). + if config.NumShards > 1 && !config.Unordered && !blocking && !config.contentSharded { + return nil, fmt.Errorf( + "redis: AutoPipelineOptions.NumShards=%d requires Unordered:true on the deferred (async) face "+ + "(commands are distributed round-robin across shards, which flush concurrently and do not preserve submit order)", + config.NumShards) + } + + ctx, cancel := context.WithCancel(context.Background()) + + ap := &AutoPipeliner{ + pipeliner: pipeliner, + config: config, + blocking: blocking, + ctx: ctx, + cancel: cancel, + } + + // Route the typed command surface. Blocking: the command call blocks until + // executed (synchronous drop-in shape). Deferred: the call returns at once + // and the result accessors block until the batch executes. + if blocking { + ap.cmdable = ap.processBlocking + } else { + ap.cmdable = ap.processAsync + } + + // Pick the shard count. NumShards=0 (auto) means ONE shard: a single deep + // queue outperforms a sharded one because batches stay large — sharding by + // core count coupled batch fragmentation to MaxConcurrentBatches and + // collapsed pipelining (measured: 16 shards cut async throughput ~4x and + // tripled latency versus one shard at the same permit count). Cluster + // wiring passes an explicit NumShards so slot-routed shards keep each + // batch on one node. + nShards := config.NumShards + if nShards <= 0 { + nShards = 1 + } + // Split the concurrent-batch budget across shards so each shard has its own + // semaphore. A single shared semaphore became a contention point once the + // per-shard queue mutexes were no longer the bottleneck. Integer division + // drops a remainder, so hand the leftover permits to the first shards: the + // per-shard permits then sum to exactly MaxConcurrentBatches. + perShard := config.MaxConcurrentBatches / nShards + remainder := config.MaxConcurrentBatches % nShards + if perShard < 1 { + // Budget smaller than the shard count: give every shard one permit so + // each flusher can still make progress. The sum then exceeds the + // configured budget, which is unavoidable with per-shard semaphores. + perShard = 1 + remainder = 0 + } + ap.shards = make([]*apShard, nShards) + for i := range ap.shards { + permits := perShard + if i < remainder { + permits++ + } + // Stripe when reordering is impossible or waived: a BLOCKING caller + // waits for each command before issuing its next, so its per-goroutine + // order holds no matter which stripe each command lands in; the async + // face may only stripe when the user set Unordered. The remaining case + // (async, ordered) keeps one stripe to preserve strict submit order. + nStripes := 1 + if config.Unordered || blocking { + nStripes = apEnqueueStripes + } + s := &apShard{ + ap: ap, + notify: make(chan struct{}, 1), + stripes: make([]apStripe, nStripes), + sem: internal.NewFIFOSemaphore(int32(permits)), + } + for j := range s.stripes { + s.stripes[j].queue = getQueueSlice(config.MaxBatchSize) + s.stripes[j].curBatch = newAPBatch() + } + ap.shards[i] = s + ap.wg.Add(1) + go s.flusher() + } + + return ap, nil +} + +// Do executes a raw command on a NORMAL connection, outside the pipeline. +// Arbitrary command names can carry connection state (SELECT, MULTI, SUBSCRIBE, +// CLIENT ...) or block the connection (BLPOP ...); batching those onto a shared +// pipeline connection would silently poison it for every later batch, or stall +// unrelated commands. (Submit enforces the same rule for raw Cmders: names in +// the connection-hostile set are diverted off the pipeline automatically.) +// The typed surface (ap.Set, ap.Get, ...) is safe by +// construction and IS batched — prefer it. Do carries the same caveats as +// Client.Do: a stateful command still affects the (normal, non-pipeline) +// pooled connection it runs on. Do keeps each face's call shape: on +// a blocking autopipeliner the call blocks until the command has executed; on a +// deferred (async) one it returns immediately and the command's result +// accessors (Err/Val/Result) block until it completes. +func (ap *AutoPipeliner) Do(ctx context.Context, args ...interface{}) *Cmd { + cmd := NewCmd(ctx, args...) + if len(args) == 0 { + cmd.SetErr(errDoNoArgs) + return cmd + } + if ap.isClosed() { + cmd.SetErr(ErrClosed) + return cmd + } + + // Both faces go through runOutsidePipeline: it applies the divert + // registration gate, so Close cannot conclude "nothing in flight" while an + // accepted raw command — a blocking one on the blocking face runs inline on + // the caller's goroutine — is still holding a pooled connection. + _ = ap.runOutsidePipeline(ctx, cmd) + return cmd +} + +// runOutsidePipeline executes an escape-hatch command (Do, DoRaw, +// DoRawWriteTo) on a normal pooled connection, outside the batching engine, +// following the face's call shape. Blocking face: synchronous Process. +// Deferred face: returns-immediately — the command runs on a background +// goroutine and a ready batch makes its result accessors block until it +// completes. The batch completes at the innermost seam (under the user +// hooks) so a ProcessHook reading the result cannot self-deadlock; the +// deferred close is the panic backstop. Tracked by divertWg under divertMu, +// so Close waits for accepted diverted work (bounded — see Close) instead of +// returning while it still holds a pooled connection. +func (ap *AutoPipeliner) runOutsidePipeline(ctx context.Context, cmd Cmder) *apBatch { + if ap.blocking { + // The blocking face runs it inline, so the caller's own goroutine holds + // the connection; still take the gate so Close cannot decide "nothing + // in flight" while this command is executing. + ap.divertMu.Lock() + if ap.isClosed() { + ap.divertMu.Unlock() + cmd.SetErr(ErrClosed) + return completedBatch + } + ap.divertWg.Add(1) + ap.divertMu.Unlock() + defer ap.divertWg.Done() + _ = ap.pipeliner.Process(ctx, cmd) + return completedBatch + } + // Register under divertMu with a closed re-check, so registration and the + // close transition cannot interleave (see the divertMu comment). A command + // that loses the race is rejected here rather than running after Close. + // The gate comes BEFORE setReady: publishing the fresh batch first and then + // rejecting would leave the command gated on a batch nobody ever closes, + // hanging every accessor. + ap.divertMu.Lock() + if ap.isClosed() { + ap.divertMu.Unlock() + cmd.SetErr(ErrClosed) + cmd.setReady(completedBatch) + return completedBatch + } + b := newAPBatch() + cmd.setReady(b) + ap.divertWg.Add(1) + ap.divertMu.Unlock() + go func() { + defer ap.divertWg.Done() + defer b.close() + defer recoverDispatchPanic([]Cmder{cmd}) + if ap.armSelfDeadlockGuard() { + b.dispGid.Store(curGoroutineID()) + } + // A hook that returns nil WITHOUT calling next has short-circuited + // SUCCESSFULLY (it served the command itself); plain Client hooks may do + // that, so nothing here synthesizes an error for it — see dispatchCmds. + err := ap.pipeliner.withProcessHook(ctx, cmd, func(ctx context.Context, cmd Cmder) error { + return ap.pipeliner.process(ctx, cmd) + }) + // The chain's final verdict, exactly like Client.Process — recorded + // before the deferred close wakes the reader, so short-circuits, + // post-next rewrites and suppressions are all honored. + cmd.SetErr(err) + }() + return b +} + +// DoRaw mirrors Do for raw RESP access: AutoPipeliner embeds cmdable, so +// without this override DoRaw would ride the batching engine — but raw +// commands carry Do's caveats and DoRawWriteTo-style streaming must not run +// inside a shared batch's reply loop. Runs outside the pipeline, following +// the face's call shape (see Do). +func (ap *AutoPipeliner) DoRaw(ctx context.Context, args ...interface{}) *RawCmd { + cmd := NewRawCmd(ctx, args...) + if len(args) == 0 { + cmd.SetErr(errDoNoArgs) + return cmd + } + if ap.isClosed() { + cmd.SetErr(ErrClosed) + return cmd + } + _ = ap.runOutsidePipeline(ctx, cmd) + return cmd +} + +// DoRawWriteTo mirrors Do for streamed raw RESP access (see DoRaw). On the +// deferred face the write to w happens when the command executes; use the +// result accessors (Err/Written) to wait before reading w. +func (ap *AutoPipeliner) DoRawWriteTo(ctx context.Context, w io.Writer, args ...interface{}) *RawWriteToCmd { + cmd := NewRawWriteToCmd(ctx, w, args...) + if len(args) == 0 { + cmd.SetErr(errDoNoArgs) + return cmd + } + if ap.isClosed() { + cmd.SetErr(ErrClosed) + return cmd + } + _ = ap.runOutsidePipeline(ctx, cmd) + return cmd +} + +// Process queues a command for autopipelined execution, following the +// autopipeliner's mode like the typed methods and Do: on a blocking +// autopipeliner the call blocks until the command has executed; on a deferred +// (async) one it returns immediately and reading the command's result +// (Val/Result/Err) blocks until its batch is flushed. +func (ap *AutoPipeliner) Process(ctx context.Context, cmd Cmder) error { + return ap.cmdable(ctx, cmd) +} + +// The methods below complete the UniversalClient surface by delegating to the +// underlying client. They are NOT autopipelined — pub/sub, transactions (Watch), +// hooks, Do and pool stats cannot be batched — so an AutoPipeliner used as a +// UniversalClient batches only the typed data commands; everything here runs on +// the underlying client exactly as it would there. +// +// Note on lifecycle: Close() (defined elsewhere) closes the AUTOPIPELINER — +// drains in-flight batches and stops flushers — but does NOT close the +// underlying client, whose lifecycle is owned by whoever created it. + +// AddHook adds a hook to the underlying client. Autopipelined batches are hooked +// too, since dispatch goes through the hook-wrapped pipeline entry. +func (ap *AutoPipeliner) AddHook(hook Hook) { ap.pipeliner.AddHook(hook) } + +// The four commands below have CLUSTER-WIDE overrides on ClusterClient +// (DBSize sums every master, the Script commands fan out to every shard). +// The embedded generic cmdable would route them as ordinary keyless commands +// to one picked shard — partial results, scripts missing on other shards — +// so they delegate to the underlying client instead of batching. On a +// standalone client the delegation is semantically identical to the generic +// path; these are rare admin/script-management commands, not data-path. + +// DBSize delegates to the underlying client (cluster-wide sum on ClusterClient). +func (ap *AutoPipeliner) DBSize(ctx context.Context) *IntCmd { + return ap.pipeliner.DBSize(ctx) +} + +// ScriptLoad delegates to the underlying client (loads every shard on ClusterClient). +func (ap *AutoPipeliner) ScriptLoad(ctx context.Context, script string) *StringCmd { + return ap.pipeliner.ScriptLoad(ctx, script) +} + +// ScriptFlush delegates to the underlying client (flushes every shard on ClusterClient). +func (ap *AutoPipeliner) ScriptFlush(ctx context.Context) *StatusCmd { + return ap.pipeliner.ScriptFlush(ctx) +} + +// ScriptExists delegates to the underlying client (ANDs results across shards +// on ClusterClient). +func (ap *AutoPipeliner) ScriptExists(ctx context.Context, hashes ...string) *BoolSliceCmd { + return ap.pipeliner.ScriptExists(ctx, hashes...) +} + +// HImportPrepare, HImportDiscard and HImportDiscardAll are the remaining +// cluster-wide overrides (see the delegation note above): ClusterClient fans +// them out to every master and updates the shared fieldset registry, so +// running them on a single routed node would let a later HImportSet for a key +// on another master fail with "no such fieldset". TestAPDelegatesClusterWideOverrides +// fails if a future ClusterClient override is added without a delegate here. +func (ap *AutoPipeliner) HImportPrepare(ctx context.Context, fieldsetName string, fields ...string) *StatusCmd { + return ap.pipeliner.HImportPrepare(ctx, fieldsetName, fields...) +} + +func (ap *AutoPipeliner) HImportDiscard(ctx context.Context, fieldsetName string) *IntCmd { + return ap.pipeliner.HImportDiscard(ctx, fieldsetName) +} + +func (ap *AutoPipeliner) HImportDiscardAll(ctx context.Context) *IntCmd { + return ap.pipeliner.HImportDiscardAll(ctx) +} + +// Watch runs a transactional function on the underlying client (not batched). +func (ap *AutoPipeliner) Watch(ctx context.Context, fn func(*Tx) error, keys ...string) error { + return ap.pipeliner.Watch(ctx, fn, keys...) +} + +// Subscribe opens a pub/sub on the underlying client (not batched — pub/sub +// needs a dedicated connection). +func (ap *AutoPipeliner) Subscribe(ctx context.Context, channels ...string) *PubSub { + return ap.pipeliner.Subscribe(ctx, channels...) +} + +// PSubscribe opens a pattern pub/sub on the underlying client (not batched). +func (ap *AutoPipeliner) PSubscribe(ctx context.Context, channels ...string) *PubSub { + return ap.pipeliner.PSubscribe(ctx, channels...) +} + +// SSubscribe opens a sharded pub/sub on the underlying client (not batched). +func (ap *AutoPipeliner) SSubscribe(ctx context.Context, channels ...string) *PubSub { + return ap.pipeliner.SSubscribe(ctx, channels...) +} + +// PoolStats returns the underlying client's connection pool statistics. +func (ap *AutoPipeliner) PoolStats() *PoolStats { return ap.pipeliner.PoolStats() } + +// AutoPipeline delegates to the underlying client, which returns its cached +// autopipeliner (typically this same instance). Present to satisfy the +// UniversalClient surface. +func (ap *AutoPipeliner) AutoPipeline() (*AutoPipeliner, error) { + return ap.pipeliner.AutoPipeline() +} + +// AutoPipelineWithOptions delegates to the underlying client. +func (ap *AutoPipeliner) AutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error) { + return ap.pipeliner.AutoPipelineWithOptions(config) +} + +// AsyncAutoPipeline delegates to the underlying client. Present to satisfy the +// UniversalClient surface. +func (ap *AutoPipeliner) AsyncAutoPipeline() (*AutoPipeliner, error) { + return ap.pipeliner.AsyncAutoPipeline() +} + +// AsyncAutoPipelineWithOptions delegates to the underlying client. +func (ap *AutoPipeliner) AsyncAutoPipelineWithOptions(config *AutoPipelineOptions) (*AutoPipeliner, error) { + return ap.pipeliner.AsyncAutoPipelineWithOptions(config) +} + +// AutoFuture is the handle returned by Submit. Call Wait (or Result on the +// command after Wait) once the result is needed; it blocks only until the +// command's batch has executed. +type AutoFuture struct { + cmd Cmder + batch *apBatch +} + +// Wait blocks until the submitted command has executed, then returns its error. +// The zero AutoFuture (no submitted command) returns an error rather than +// panicking. +func (f AutoFuture) Wait() error { + if f.batch == nil { + if f.cmd != nil { + return f.cmd.Err() + } + return errZeroAutoFuture + } + select { + case <-f.batch.done: + default: + // Same self-deadlock guard as baseCmd.await(): a pipeline hook on + // the batch's own dispatch goroutine waiting a future pre-next() + // would block a channel only its goroutine can close. Give it the + // not-yet-executed view instead. + if f.batch.isExecutorGoroutine() { + return f.cmd.rawErr() + } + <-f.batch.done + } + return f.cmd.Err() +} + +// WaitContext is like Wait but stops waiting when ctx is done. The command +// still executes and its result remains readable once its batch completes — +// ctx abandons only this wait, it does not cancel the command (per-command +// contexts are not honored after enqueue; see the AutoPipeliner doc). +// +// After a ctx error the result may simply not be there YET: the batch is +// still in flight and may populate the command at any moment, so do not read +// Cmd()'s value or error directly — that races the executing batch. Call Wait +// (or WaitContext with a fresh context) again; once it returns a non-context +// error, the command's result is complete and safe to read. +func (f AutoFuture) WaitContext(ctx context.Context) error { + if f.batch == nil { + if f.cmd != nil { + return f.cmd.Err() + } + return errZeroAutoFuture + } + select { + case <-f.batch.done: + return f.cmd.Err() + default: + if f.batch.isExecutorGoroutine() { + return f.cmd.rawErr() // see Wait: dispatch-goroutine self-deadlock guard + } + } + select { + case <-f.batch.done: + return f.cmd.Err() + case <-ctx.Done(): + return ctx.Err() + } +} + +// Cmd returns the underlying command (call Wait first before reading results). +func (f AutoFuture) Cmd() Cmder { return f.cmd } + +// outsidePipelineCommands lists commands that must never ride a SHARED +// pipeline connection. SHUTDOWN terminates the server before replying (its +// batchmates would all fail with EOF and the batch would retry against a +// dead server); MONITOR rebinds the connection into a monitor stream, +// desyncing every reply behind it; the rest change per-connection state +// (database, auth, protocol, transaction, subscription mode) that would +// leak to every unrelated caller sharing the pipeline conn afterwards. The +// typed surface cannot produce most of the stateful ones (they live on +// statefulCmdable) — but ReadOnly/ReadWrite ARE on cmdable, and raw +// Submit/Do accept any Cmder. Diverted commands execute directly on their +// own pooled connection — the same semantics (including the same footguns) +// as plain Client.Do. +var outsidePipelineCommands = map[string]struct{}{ + "shutdown": {}, "monitor": {}, + "select": {}, "auth": {}, "hello": {}, "reset": {}, "quit": {}, + "multi": {}, "exec": {}, "discard": {}, "watch": {}, "unwatch": {}, + "subscribe": {}, "unsubscribe": {}, "psubscribe": {}, "punsubscribe": {}, + "ssubscribe": {}, "sunsubscribe": {}, + "client": {}, + // Connection-scoped cluster state: queued onto a shared pipeline conn + // they would leak replica-reads (or a pending redirect) to every later + // batch on that conn. + "readonly": {}, "readwrite": {}, "asking": {}, +} + +func runsOutsidePipeline(name string) bool { + _, ok := outsidePipelineCommands[name] + return ok +} + +// blockingCommands are commands that park on the server until data arrives or +// their own timeout expires. The TYPED helpers set a per-command read timeout +// (see cmdable.BLPop), which submit already diverts on; a RAW Cmder built by +// hand — NewCmd(ctx, "blpop", key, 0) via Submit/Process/Do — carries no such +// marker, so without this set it would be queued onto a shared pipeline +// connection and hold the whole batch for the block duration. +// Derived from the typed helpers rather than guessed: every cmdable method that +// calls cmd.setReadTimeout parks the connection, so +// +// grep -rn 'setReadTimeout' --include='*.go' . | grep -v _test +// +// enumerates exactly the wire names that belong here (the arg-driven ones are +// handled in isBlockingCmd instead). Re-run that grep when adding a blocking +// command. +var blockingCommands = map[string]struct{}{ + "blpop": {}, "brpop": {}, "brpoplpush": {}, + "blmove": {}, "blmovem": {}, "blmpop": {}, + "bzpopmin": {}, "bzpopmax": {}, "bzmpop": {}, + "wait": {}, "waitaof": {}, + // MIGRATE blocks the source instance for up to its timeout. + "migrate": {}, +} + +// isBlockingCmd reports whether cmd parks the connection. XREAD/XREADGROUP are +// decided by ARGUMENTS, not by name: only the BLOCK form blocks, and +// blanket-diverting the (far more common) non-blocking form would drop it out +// of batching for nothing. +func isBlockingCmd(cmd Cmder) bool { + name := cmd.Name() + if _, ok := blockingCommands[name]; ok { + return true + } + // Arg-driven: these block only in their BLOCK form, and blanket-diverting + // the far more common non-blocking form would drop it out of batching for + // nothing. TS.READ takes BLOCK the same way (see TSReadWithArgs). + if name != "xread" && name != "xreadgroup" && name != "ts.read" { + return false + } + // Match the token the way the encoder does: a raw Cmder may carry RESP + // tokens as []byte or *string (see baseCmd.stringArg), and a type switch on + // string alone would let NewCmd(ctx, "xread", []byte("BLOCK"), 0, ...) be + // batched onto a shared connection. + for _, arg := range cmd.Args() { + if internal.ToLower(blockingArgString(arg)) == "block" { + return true + } + } + return false +} + +// blockingArgString renders a command argument as the string the encoder will +// write for the token comparisons above. Only the forms that can carry a RESP +// keyword are handled; anything else cannot be the BLOCK token. +func blockingArgString(arg interface{}) string { + switch v := arg.(type) { + case string: + return v + case []byte: + return string(v) + case *string: + if v == nil { + return "" + } + return *v + default: + return "" + } +} + +// submit queues a command without blocking and returns its completion future. +func (ap *AutoPipeliner) submit(ctx context.Context, cmd Cmder) AutoFuture { + // finish marks the command ready on the deferred face so its result + // accessors (Val/Result/Err) self-gate through await() — whether the + // caller goes through the typed surface or raw Submit. Reading a + // Submit()-ed command before Wait() was previously a silent data race + // with the dispatch goroutine. The blocking face deliberately never + // carries a batch: its callers only regain control after execution, and + // the dispatcher-gid deadlock guard relies on that. + finish := func(f AutoFuture) AutoFuture { + if !ap.blocking { + cmd.setReady(f.batch) + } + return f + } + // Decide DIVERSION first. The cluster preflight rejects commands whose + // request policy cannot ride a pipeline (ReqAllNodes/ReqAllShards), but a + // diverted command never rides one: it goes through the underlying + // Client/ClusterClient.Process, which performs the normal cluster-wide + // fan-out and aggregation. Running the preflight first therefore rejected + // commands that would have worked — typed WAIT/WAITAOF on a cluster with + // command policies enabled (review finding by codex on #3942). + diverted := cmd.readTimeout() != nil || runsOutsidePipeline(cmd.Name()) || isBlockingCmd(cmd) || + (ap.mustDivert != nil && ap.mustDivert(ctx, cmd)) + if !diverted && ap.preflight != nil { + if err := ap.preflight(ctx, cmd); err != nil { + cmd.SetErr(err) + return finish(AutoFuture{cmd: cmd, batch: completedBatch}) + } + } + if diverted { + // Blocking commands (and the conn-hostile ones above) are executed + // directly, outside the pipeline — via runOutsidePipeline, which + // keeps each face's call shape: the blocking face runs the command + // synchronously, the deferred face runs it on its own goroutine so + // this call returns immediately and the result accessors block (a + // BLPOP submitted on the async face must not stall the submitter, + // exactly like Do). They still must respect a closed AutoPipeliner: + // enqueue() rejects on the batched path, so mirror that here instead + // of running after Close(). + if ap.isClosed() { + cmd.SetErr(ErrClosed) + return finish(AutoFuture{cmd: cmd, batch: completedBatch}) + } + // runOutsidePipeline sets the command ready itself on the deferred + // face; the returned batch completes when the command has executed. + return AutoFuture{cmd: cmd, batch: ap.runOutsidePipeline(ctx, cmd)} + } + // No finish here: enqueue stamps ready under the stripe lock, before the + // command is visible to any drain (the error paths above still go through + // finish for uniform accessor behavior). + return AutoFuture{cmd: cmd, batch: ap.enqueue(cmd)} +} + +// ErrSubmitBlockingFace rejects Submit on the blocking face: Submit does not +// wait, so a windowed caller could have several commands in flight at once — +// but the blocking face stripes its enqueue queue on the strength of every +// caller waiting per command, and a non-waiting window there can be reordered. +// The deferred face (AsyncAutoPipeline) is built for exactly that usage. +// +// EXPERIMENTAL: this API is subject to change, use with caution. +var ErrSubmitBlockingFace = errors.New( + "redis: Submit requires the deferred autopipeliner (AsyncAutoPipeline); on the blocking face use the typed methods or Do") + +// errZeroAutoFuture is returned by Wait/WaitContext on a zero AutoFuture. +var errZeroAutoFuture = errors.New("redis: Wait on a zero AutoFuture") + +// errDoNoArgs is returned by Do when called without a command. +var errDoNoArgs = errors.New("redis: AutoPipeliner.Do requires at least one argument") + +// ErrAutoPipelineTimeout is set on drained commands when a flush could not +// obtain a batch permit within the engine's internal backstop — the engine is +// overloaded or an in-flight batch is wedged (e.g. read timeouts disabled on +// a dead peer). It is deliberately NOT context.DeadlineExceeded: the caller's +// own context did not expire, and errors.Is(err, context.DeadlineExceeded) +// must not fire for an internal engine timeout. +// +// EXPERIMENTAL: this API is subject to change, use with caution. +var ErrAutoPipelineTimeout = errors.New( + "redis: autopipeline: no batch permit within the internal backstop (engine overloaded or a batch is wedged)") + +// Submit queues a command without blocking and returns an AutoFuture; Wait on +// it when the result is needed. This is the explicit form for working with raw +// Cmders on the deferred (async) face, where the typed methods (Set, Get, ...) +// provide the same deferred behaviour returning the usual *XxxCmd. The +// command's own result accessors (Err/Val/Result) are safe to use instead of +// Wait — they block until the command has executed. Connection-hostile +// command names (SHUTDOWN, MONITOR, SELECT, AUTH, MULTI, SUBSCRIBE, CLIENT, +// ...) never ride a shared pipeline connection: they are diverted to a +// normal pooled connection with plain Client.Do semantics. On a BLOCKING +// autopipeliner Submit is rejected (the future's Wait returns an error): the +// blocking face's ordering relies on every caller waiting for each command +// before issuing the next, which Submit by design does not do. +func (ap *AutoPipeliner) Submit(ctx context.Context, cmd Cmder) AutoFuture { + if ap.blocking { + cmd.SetErr(ErrSubmitBlockingFace) + return AutoFuture{cmd: cmd, batch: completedBatch} + } + return ap.submit(ctx, cmd) +} + +// processAsync is the cmdable backing the typed command surface: it queues a +// command without blocking the caller and marks it ready so the command's +// result accessors (Val/Result/Err) block until the batch executes. This gives +// the autopipeliner the full typed surface (ap.Set, ap.Get, ...) with the exact +// same call shape as a normal client — only the wait is deferred to the point a +// result is read. +func (ap *AutoPipeliner) processAsync(ctx context.Context, cmd Cmder) error { + // submit marks the command ready (see the finish closure there): a hook + // that reads the command before that store lands sees a nil ready — the + // non-blocking not-yet-executed view — while the caller always sees its + // own store before any await. + f := ap.submit(ctx, cmd) + // Report SUBMIT-time rejections (a closed pipeliner, a cluster preflight + // refusal): those paths set the error on the command and hand back the + // shared completed batch without queueing anything, so returning nil made + // Process claim success for a command that will never run — and callers + // reaching the engine through UniversalClient.Process see only this return + // value (review finding by codex on #3942). Execution errors are NOT + // reported here: the deferred face's contract is that this call does not + // wait, so those stay on the command for its accessors. rawErr keeps the + // check non-blocking. + if f.batch == completedBatch { + return cmd.rawErr() + } + return nil +} + +// processBlocking is the cmdable backing the blocking face: it queues the +// command and blocks until its batch has executed, so the command call has the +// same synchronous shape as a normal client (the returned *XxxCmd already holds +// its result). The flusher still batches this command with other concurrent +// callers' commands into a pipeline, so throughput is far above a plain client +// even though each caller waits. Per-goroutine ordering holds regardless of +// MaxConcurrentBatches: a caller cannot issue its next command until this one +// returns, so its commands execute in submit order. +func (ap *AutoPipeliner) processBlocking(ctx context.Context, cmd Cmder) error { + return ap.submit(ctx, cmd).Wait() +} + +// completedBatch is a reusable already-completed batch: returned both for +// commands that already executed directly (blocking commands, Submit-time +// rejections) and for error cases like enqueue-after-Close, so Wait returns +// immediately and the command's own error tells the story. +var completedBatch = func() *apBatch { + b := newAPBatch() + b.close() + return b +}() + +// enqueue queues a command and returns the batch whose done channel completes +// when it has executed. On a closed autopipeliner it errors the command and +// returns the already-closed batch. +// isClosed reports whether this pipeliner (or the shared pool set it rides +// on) has been closed. Two atomic loads; no locks. +// +// EVERY closed check that gates accepting new work must go through this, not +// ap.closed directly: a WithTimeout clone's Close sets only the shared flag, +// so a guard reading ap.closed alone would accept commands against pools that +// are already gone and surface pool-closed errors instead of ErrClosed. +// (Close's own CompareAndSwap on ap.closed is the one deliberate direct use: +// it claims the shutdown for this instance.) +func (ap *AutoPipeliner) isClosed() bool { + return ap.closed.Load() || (ap.sharedClosed != nil && ap.sharedClosed.Load()) +} + +func (ap *AutoPipeliner) enqueue(cmd Cmder) *apBatch { + if ap.isClosed() { + cmd.SetErr(ErrClosed) + return completedBatch + } + + // Pick a shard. With shardFn (cluster mode) route by command content so all + // commands for one node collect in the same shard's batch; otherwise spread + // round-robin to keep each shard's mutex lightly contended. + var s *apShard + if ap.shardFn != nil { + // uint conversion instead of negation: -math.MinInt overflows back to + // itself and a negative modulo would panic the index. The unsigned + // modulo is deterministic for every int, including MinInt. + idx := ap.shardFn(cmd) + s = ap.shards[uint(idx)%uint(len(ap.shards))] + } else if len(ap.shards) == 1 { + // Single shard (the standalone default): skip the round-robin counter — + // it is a shared cache line bumped by every enqueue for a pick that is + // constant. Same guard the stripe pick already has. + s = ap.shards[0] + } else { + // Unsigned modulo: converting to int first goes negative after the + // uint32 counter passes 2^31 on 32-bit platforms and panics. + s = ap.shards[int((ap.next.Add(1)-1)%uint32(len(ap.shards)))] + } + + st := s.stripe() + st.mu.Lock() + // Re-check closed under the stripe lock (see Close): either we win the lock + // first and the shutdown drain flushes us, or the drain ran first and we + // reject here — so a late enqueue never hangs on an unclosed done. + if ap.isClosed() { + st.mu.Unlock() + cmd.SetErr(ErrClosed) + return completedBatch + } + batch := st.curBatch + if !ap.blocking { + // Publish the gating batch BEFORE the command becomes visible to a + // drain (the drain takes this same stripe lock): a flush racing the + // submitter's return path must observe ready already set, or the + // cluster node-executor registration would skip this command's batch + // and a node hook reading the command mid-dispatch could block on a + // batch its own call chain completes. The blocking face deliberately + // never carries a batch (see submit). + cmd.setReady(batch) + } + st.queue = append(st.queue, cmd) + st.queueLen.Store(int32(len(st.queue))) + if ap.config.MaxBatchBytes > 0 { + st.queueBytes.Add(cmdApproxBytes(cmd)) + } + st.mu.Unlock() + + // One expected arrival has landed (see expectedArrivals). + ap.expectedArrivals.Add(-1) + + s.wake() + return batch +} + +// wake signals the shard's flusher that work is available without blocking. +func (s *apShard) wake() { + select { + case s.notify <- struct{}{}: + default: + } +} + +// IsBlocking reports which face this autopipeliner is: true for the blocking +// face (Client.AutoPipeline — calls wait for execution), false for the +// deferred face (AsyncAutoPipeline — calls return immediately and result +// accessors block). The two faces reject different usage (Submit is +// blocking-face-rejected), so code handed an *AutoPipeliner can branch on +// this instead of probing with errors. +func (ap *AutoPipeliner) IsBlocking() bool { return ap.blocking } + +// Config returns a copy of the effective configuration (defaults filled in). +func (ap *AutoPipeliner) Config() AutoPipelineOptions { + cfg := *ap.config + // Strip internal-only fields. contentSharded is set by cluster wiring and + // tells Validate that shards are slot-routed, so same-key commands cannot + // be reordered — which exempts the config from the NumShards>1 ordering + // requirement. Handing that bit back to a caller who copies this config + // into a STANDALONE async autopipeliner would silence that check for + // round-robin shards, which really do flush concurrently and really do + // break submit order (review finding by codex on #3942). + cfg.contentSharded = false + return cfg +} + +// IsClosed reports whether the AutoPipeliner has been closed, either by an +// explicit Close or by closing the owning client. A closed AutoPipeliner +// rejects new commands with ErrClosed. +func (ap *AutoPipeliner) IsClosed() bool { + return ap.isClosed() +} + +// numShards reports how many shards this autopipeliner runs. +func (ap *AutoPipeliner) numShards() int { return len(ap.shards) } + +// setShardFn installs a content-based shard selector. In cluster mode it maps +// a command's SLOT to a shard, which is a batch-depth heuristic, not an +// invariant: slot ranges are assigned to shards proportionally, so when a +// node's slots are non-contiguous one shard's batch can still span nodes and +// mapCmdsByNode splits it (correctness is unaffected — that router resolves +// every command's own slot — but those per-node pipelines are shallower). +// What the mapping DOES guarantee is that a given key always lands on the same +// shard, so a caller's relative order for that key is preserved regardless of +// how the shard's batch is split. Must be called before the autopipeliner is +// used. Not safe to change concurrently with enqueues. +func (ap *AutoPipeliner) setShardFn(fn func(Cmder) int) { ap.shardFn = fn } + +// setPreflight installs a submit-time command filter (cluster wiring rejects +// commands whose request policy cannot ride a pipeline). Called once during +// construction, before the AutoPipeliner is published. +func (ap *AutoPipeliner) setPreflight(fn func(ctx context.Context, cmd Cmder) error) { + ap.preflight = fn +} + +// setMustDivert installs a predicate that forces a command off the batching +// path (see the mustDivert field). Called once during construction, before the +// AutoPipeliner is published. +func (ap *AutoPipeliner) setMustDivert(fn func(ctx context.Context, cmd Cmder) bool) { + ap.mustDivert = fn +} + +// Close stops the autopipeliner and flushes any pending commands. Worst +// case it blocks up to the internal permit backstop (~30s) PER SHARD if +// in-flight batches are wedged (e.g. read timeouts disabled against a dead +// peer) — healthy shutdowns take one round trip per shard with commands +// queued, near-zero otherwise. +func (ap *AutoPipeliner) Close() error { + if !ap.closed.CompareAndSwap(false, true) { + return nil // Already closed + } + + // Cancel context to stop flushers + ap.cancel() + + // Wake every shard's flusher so each observes the cancelled context promptly. + for _, s := range ap.shards { + s.wake() + } + + // Pass through the divert gate once: after the CompareAndSwap above, any + // registration either completed before this (so the counter already sees + // it) or will observe closed==true and reject. Without this handshake the + // wait below could read a zero counter while a diverted command was + // between its closed check and its Add. + ap.divertMu.Lock() + ap.divertMu.Unlock() //nolint:staticcheck // handshake, not a critical section + + // Drain everything that remains, BOUNDED AS ONE UNIT: the flusher exit, the + // final shard sweep, and the batch/diverted dispatch waits. + // + // None of it can be cancelled: commands taken from a queue (or accepted for + // diverted execution) were already ACCEPTED, and Close's contract is to + // flush them, so ap.cancel() deliberately does not reach an in-flight + // dispatch. With ReadTimeout disabled — a supported configuration — a + // stalled read against a dead peer, or a diverted BLPOP with a zero + // timeout, has nothing to end it. Bounding only the LAST wait would not + // help: the wedged dispatch can just as easily sit in a flusher that + // ap.wg.Wait() is waiting for, or in the shutdown sweep's own dispatch, so + // Close would hang before ever reaching the bound it documents (review + // finding by codex on #3942). On expiry, report what is still outstanding + // instead of blocking the caller: the engine is already closed to new work, + // and the leaked goroutines end when the server or the OS breaks the + // connection. See autoPipelineCloseBackstop for why the bound is generous. + return ap.drainAll(autoPipelineCloseBackstop) +} + +// drainAll runs Close's whole drain tail under a single bound and returns an +// error naming every stage that was still outstanding when it expired. Split +// out of Close so the bound is testable without a real stalled connection. +// +// The stages are ordered as Close needs them — the shard sweep must not start +// before the flushers are provably gone — but they are waited on +// CONCURRENTLY with the timer, which is the whole point: any stage can be the +// one that never finishes. +func (ap *AutoPipeliner) drainAll(timeout time.Duration) error { + flushers := make(chan struct{}) + go func() { defer close(flushers); ap.wg.Wait() }() + + // swept: after the flushers are gone, drain each shard once more under its + // lock. A command can pass enqueue's under-lock closed-recheck just before + // Close's CompareAndSwap and append to a shard AFTER that shard's flusher + // has already drained and exited — leaving its batch.done unclosed and the + // caller's accessor blocked forever. s.mu serializes the two, so either the + // late enqueue appends first and this sweep flushes it, or the sweep runs + // first and the enqueue then observes closed==true and rejects. + swept := make(chan struct{}) + go func() { + defer close(swept) + <-flushers + for _, s := range ap.shards { + s.flushBatchSliceShutdown() + } + }() + + batches := make(chan struct{}) + go func() { + defer close(batches) + <-swept + ap.batchWg.Wait() + }() + + diverted := make(chan struct{}) + go func() { defer close(diverted); ap.divertWg.Wait() }() + + timer := time.NewTimer(timeout) + defer timer.Stop() + batchesDone, divertedDone := false, false + for !batchesDone || !divertedDone { + select { + case <-batches: + batchesDone = true + batches = nil // a closed channel is always ready; stop selecting it + case <-diverted: + divertedDone = true + diverted = nil + case <-timer.C: + var outstanding []string + if !batchesDone { + // Name the precise stage: a wedged flusher and a wedged batch + // dispatch need different operator responses. + select { + case <-flushers: + select { + case <-swept: + outstanding = append(outstanding, "batch dispatches") + default: + outstanding = append(outstanding, "the shutdown flush") + } + default: + outstanding = append(outstanding, "the flusher drain") + } + } + if !divertedDone { + outstanding = append(outstanding, "diverted (blocking) commands") + } + return fmt.Errorf( + "redis: autopipeline: Close timed out after %s with %s still in flight; "+ + "they hold pooled connections until the server or the OS ends them "+ + "(most often a blocking command with no timeout, or ReadTimeout disabled)", + timeout, strings.Join(outstanding, " and ")) + } + } + return nil +} + +// flusher is the per-shard background goroutine that flushes batches. +func (s *apShard) flusher() { + defer s.ap.wg.Done() + ap := s.ap + + for { + // Wait for a command to arrive (or shutdown). The notify channel is a + // cheap buffered wake-up; no lock is taken on the hot enqueue path. + if s.Len() == 0 { + select { + case <-s.notify: + case <-ap.ctx.Done(): + } + } + + // Check if context is cancelled + if ap.ctx.Err() != nil { + // Final flush before shutdown - use background context to avoid immediate cancellation + s.flushBatchSliceShutdown() + return + } + + // Apply the coalescing window if one is configured (MaxFlushDelay / + // AdaptiveDelay). With the default config this returns at once: batching + // under concurrent load comes from in-flight backpressure, not a wait — + // see accumulateBatch. + s.accumulateBatch() + + // Flush all pending commands + for s.Len() > 0 { + select { + case <-ap.ctx.Done(): + // Final flush before shutdown + s.flushBatchSliceShutdown() + return + default: + } + + s.flushBatchSlice() + + // Between batches, apply the configured window again so the next + // pipeline is also full. A no-op with the default config (see + // accumulateBatch); the next drain picks up whatever has queued. + if s.Len() > 0 && s.Len() < ap.config.MaxBatchSize && !s.bytesFull() { + s.accumulateBatch() + } + } + } +} + +// accumulateBatch lets commands pile up before the flusher drains the queue, +// so pipelines carry many commands instead of one. It returns as soon as any +// of these holds: +// +// - the queue reaches MaxBatchSize (batch is full); +// - a configured MaxFlushDelay / AdaptiveDelay window elapses; or +// - with no configured window (the default), the expected resubmission +// wave of arrivals has landed — see awaitExpectedArrivals. +// +// A configured MaxFlushDelay / AdaptiveDelay is an intentional accumulation +// window and is waited in full (AdaptiveDelay scales it down as the queue fills +// and returns 0 — flush now — once the queue is ≥75% full). +func (s *apShard) accumulateBatch() { + ap := s.ap + batchSize := ap.config.MaxBatchSize + if batchSize <= 0 { + batchSize = 1 + } + if s.Len() >= batchSize || s.bytesFull() { + return + } + + // Pick the accumulation window. calculateDelay returns 0 both when no + // MaxFlushDelay is configured (the default) and when AdaptiveDelay resolves + // the current fill level to "flush immediately". The fill level is this + // shard's own length — each shard flushes independently, so a global count + // would mis-tune a quiet shard while another is busy. + window := ap.calculateDelay(s.Len()) + if window <= 0 { + if ap.config.MaxFlushDelay == 0 && !ap.config.AdaptiveDelay { + // Default: coalesce by expected-arrival count, not by wall-clock. + s.awaitExpectedArrivals(batchSize) + } + return + } + + // Explicit window: wait the whole delay (or until the batch fills). Each + // enqueue sends on notify, so we re-check the queue length on every wake-up + // and return once the batch is full. + deadline := time.NewTimer(window) + defer deadline.Stop() + for { + select { + case <-ap.ctx.Done(): + return + case <-deadline.C: + return + case <-s.notify: + if s.Len() >= batchSize || s.bytesFull() { + return + } + } + } +} + +// silenceGapFloor / silenceGapCeil bound awaitExpectedArrivals's silence fallback. +// The floor covers fast links; the RTT-scaled value (execEWMA/8) takes over on +// slow ones, where a wakeup wave staggered by goroutine scheduling can pause +// longer than the floor mid-landing and a premature flush is expensive (each +// batch fragment occupies a pipeline connection for a full round trip). The +// ceiling bounds how long a stale expectation (callers that left) can delay a +// flush. +const ( + silenceGapFloor = 200 * time.Microsecond + silenceGapCeil = 2 * time.Millisecond +) + +// coalesceMinFlush is the smallest pipeline worth dispatching while other +// batches are still executing. Below it, a gap-fire holds the queued +// stragglers for the next wave instead of burning a connection on a +// near-empty flush; once nothing is in flight, any size flushes immediately. +const coalesceMinFlush = 8 + +// observeBatchExec folds one batch execution duration into execEWMA. +func (ap *AutoPipeliner) observeBatchExec(d time.Duration) { + sample := int64(d) + if sample <= 0 { + return + } + old := ap.execEWMA.Load() + if old == 0 { + ap.execEWMA.Store(sample) + return + } + ap.execEWMA.Store(old + (sample-old)/8) +} + +// silenceGap returns the silence fallback for awaitExpectedArrivals, scaled to the +// observed batch round-trip: clamp(execEWMA/8, floor, ceil). +func (ap *AutoPipeliner) silenceGap() time.Duration { + g := time.Duration(ap.execEWMA.Load() / 8) + if g < silenceGapFloor { + return silenceGapFloor + } + if g > silenceGapCeil { + return silenceGapCeil + } + return g +} + +// awaitExpectedArrivals holds the flusher while related work is in motion, so +// commands flush as deep pipelines instead of fragmenting into small batches +// (each fragment costs a pipeline connection for a full round trip). Two +// signals — both facts the engine already has, not wall-clock guesses — decide +// whether anything is imminent: +// +// - expectedArrivals: a completed batch of N commands wakes its N waiters +// together, and in a closed loop each immediately submits its next +// command. Completion announces the exact count; every enqueue accounts +// for one; the wait ends the moment the count drains — the wave of +// arrivals has fully landed. An exact per-wave count has no failure mode +// where an averaged estimate undershoots the true wave and locks the +// engine into fragmented flushes. +// - inFlight: batches still executing mean their waiters will wake shortly +// and stragglers are mid-stream — worth holding a moment to coalesce with, +// bounded by the silence gap. This also recovers a fragmented state (many +// singles in flight, which announce nothing): their staggered returns land +// within one gap, merge into a real batch, and arrival tracking resumes. +// +// When neither holds, the shard is idle and the flush happens immediately: a +// lone caller pays a single round trip with no timer armed. That is the point +// of the design — the previous fixed ~20µs debounce timer armed on every flush +// fires ~1ms late on an idle or low-core host (wakeup latency dominates the +// requested delay), taxing every low-concurrency command ~5x its round trip. +// Here the gap timer never fires in steady state, closed loop or open; it only +// ends waits for callers that left. +func (s *apShard) awaitExpectedArrivals(batchSize int) { + ap := s.ap + expected := ap.expectedArrivals.Load() + if expected < 0 { + // Arrivals outran what was announced (open-loop traffic); re-zero so + // the deficit does not mask the next wave. CAS: only clear the value + // we saw, never a concurrent announcement. + ap.expectedArrivals.CompareAndSwap(expected, 0) + expected = 0 + } + expectingWave := expected > 0 + if !expectingWave && s.inFlight.Load() == 0 { + // Idle shard: nothing imminent, flush in one round trip. + return + } + + gap := ap.silenceGap() + // Reset is drain-safe on Go 1.23+ (see go.mod: go 1.24). + fallback := time.NewTimer(gap) + defer fallback.Stop() + lastSeenExpected := expected // count as of the most recent timer (re)arm + var holdStart time.Time // set on the first straggler-hold gap fire + for { + select { + case <-ap.ctx.Done(): + return + case <-fallback.C: + if !expectingWave && s.Len() < coalesceMinFlush && s.inFlight.Load() > 0 { + // Only stragglers queued while batches are still executing: + // flushing a near-empty pipeline burns a connection for a full + // round trip (measured at high WAN concurrency: straggler + // flushes of 1-3 commands starved the connection pool and + // doubled p50). Hold them — the next completed batch's wave + // sweeps them along, and the wave path below flushes promptly. + // The hold is bounded like the permit wait: with read timeouts + // disabled a wedged batch could pin inFlight forever, and the + // held stragglers must not hang with it. + if holdStart.IsZero() { + holdStart = time.Now() + } + if time.Since(holdStart) < autoPipelinePermitBackstop { + lastSeenExpected = ap.expectedArrivals.Load() + fallback.Reset(gap) + continue + } + } + if expectingWave { + // A whole gap passed with no arrivals on this shard: the + // expected callers left (workload shrank), so clear the stale + // expectation or future flushes will wait for ghosts. But only + // if it did not GROW during the silent gap — growth means a + // batch elsewhere (another shard, or racing this fire) + // announced a fresh wave, and erasing that would fragment a + // wave that is really coming. CAS, never a blind store, so an + // announcement racing the reset itself also survives. + if d := ap.expectedArrivals.Load(); d > 0 && d <= lastSeenExpected { + ap.expectedArrivals.CompareAndSwap(d, 0) + } + } + return + case <-s.notify: + if s.Len() >= batchSize || s.bytesFull() { + return + } + if d := ap.expectedArrivals.Load(); d > 0 { + // An in-flight batch completed mid-wait: its wave is now the + // thing to wait out, with the exact-count exit below. + expectingWave = true + lastSeenExpected = d + } else if expectingWave { + // The wave has fully landed; flush it as one batch. + return + } else if s.inFlight.Load() == 0 { + // Nothing executing, no wave expected: no completion will + // wake more callers, so flush what we have now. + return + } + fallback.Reset(gap) + } + } +} + +// dispatchCmds executes the drained stripe queues as one pipeline without +// constructing a Pipeline object: the queue slices go straight to the client's +// hook-wrapped pipeline processor (the exact entry Pipeline.Exec is wired to), +// so hooks and OTel behave identically while the per-batch Pipeline allocation, +// its append-growth reallocations and the per-command Process calls disappear. +// A single-stripe drain (every ordered shard, and any drain that found one +// non-empty stripe) passes its queue zero-copy; multi-stripe drains merge into +// one pooled slice. +// The batches stay OPEN throughout: completion happens at the caller's +// deferred closes, after the whole hook chain has returned. Hooks on the +// dispatch goroutine can still read results without deadlocking via the +// dispGid guard in await() (pre-next: the not-yet-executed view; post-next: +// the populated results), and — exactly like a plain pipeline — they may +// even adjust results before any waiter wakes. +// +// The innermost records whether execution actually happened. Two hook +// behaviours the chain's return value can carry are surfaced, both while the +// batches are still open (the callers' deferred closes run after this +// returns, so no waiter is reading yet): +// - short-circuit (hook returned without calling next): nothing set the +// commands' results — the chain's error, if any, is set +// on every command; +// - post-next verdict (exec ran, a hook still returned an error): applied +// to the commands ONLY when every one of them is error-free — the case +// where the hook's verdict would otherwise vanish entirely. A plain +// pipeline hands that verdict to the Exec caller without rewriting +// per-command results; with no Exec caller here, per-command errors +// recorded by the exec always win and are never overwritten. +func (ap *AutoPipeliner) dispatchCmds(ctx context.Context, queues [][]Cmder, total int) { + cmds := queues[0] + if len(queues) > 1 { + cmds = getQueueSlice(total) + for i := range queues { + cmds = append(cmds, queues[i]...) + } + } + // A command that forbids retries (today: the zero-copy reads, whose reply + // decodes into a caller buffer that a retry could not un-write) disables + // retries for the WHOLE slice it is dispatched in — see cmdsContainNoRetry. + // In a shared batch that would silently strip retries from unrelated + // callers' ordinary commands, so a mixed batch is dispatched as several + // pipelines instead of one. + // + // Split into CONTIGUOUS RUNS, in order, never into two policy groups: + // grouping would reorder the stream — a zero-copy read submitted before a + // SET to the same key would execute after it, so the read observes the new + // value on a face that promises submit order. Runs preserve every relative + // position while still keeping each dispatched slice policy-uniform (both + // findings by codex on #3942; the grouping bug was introduced by the first + // fix for the retry leak). + if runs := splitRetryRuns(cmds); runs != nil { + ap.dispatchSequential(ctx, runs) + if len(queues) > 1 { + putQueueSlice(cmds) + } + return + } + executed := false + chainErr := ap.pipeliner.withProcessPipelineHook(ctx, cmds, func(ctx context.Context, cmds []Cmder) error { + executed = true + return ap.pipeliner.processPipeline(ctx, cmds) + }) + // NOTE: a hook that returns nil WITHOUT calling next has short-circuited + // SUCCESSFULLY — it served the batch itself (a cache, a mock) and set the + // command values. Plain Pipeline/Client hooks are allowed to do exactly + // that, so no error is synthesized for it: doing so made a hook that works + // on a pipeline fail on an autopipelined batch (review finding by codex on + // #3942). Only the hook's own error propagates, below. + if chainErr != nil { + if !executed { + setCmdsErr(cmds, chainErr) + } else if cmdsFirstErr(cmds) == nil { + // Post-next error on an all-clean batch: the exec fully succeeded, + // so the error can only be the hook's own verdict — apply it. + // On a mixed batch it is applied to nothing: hooks conventionally + // return next's error (`err := next(...); return err`), so after a + // partial failure the chain error is presumed to be that echo, and + // stamping it on the commands that DID succeed would overwrite + // valid replies with their batchmates' failure. Exec-recorded + // per-command outcomes always win over a post-next rewrap. + setCmdsErr(cmds, chainErr) + } + } + if len(queues) > 1 { + putQueueSlice(cmds) + } +} + +// dispatchCmdsMaybeChunked dispatches a drained batch, splitting it into +// byte-bounded chunks when MaxBatchBytes is configured: each chunk is its own +// pipeline write+read cycle, so a batch of many large values becomes several +// bounded bursts instead of one huge write that can stall a constrained link +// past its deadline. The commands' batches still complete only after ALL +// chunks executed (the caller's deferred closes), exactly like an unchunked +// dispatch — chunking bounds the wire bursts, it does not change completion +// semantics. Each chunk runs the full hook chain, like consecutive pipelines. +func (ap *AutoPipeliner) dispatchCmdsMaybeChunked(ctx context.Context, queues [][]Cmder, total int) { + limit := int64(ap.config.MaxBatchBytes) + if limit <= 0 { + ap.dispatchCmds(ctx, queues, total) + return + } + + // Merge (borrowed from dispatchCmds's multi-queue path) so chunk + // boundaries can cross stripe queues. + cmds := queues[0] + merged := false + if len(queues) > 1 { + cmds = getQueueSlice(total) + for i := range queues { + cmds = append(cmds, queues[i]...) + } + merged = true + } + + // Cut the byte-bounded chunks, then hand the ordered sequence to the shared + // dispatcher — which stops after a chunk dies on a transport-class failure, + // so later commands cannot overtake a failed prefix (see + // dispatchSequential; the retry-policy runs go through the same helper). + chunks := make([][]Cmder, 0, 4) + start := 0 + var chunkBytes int64 + for i, cmd := range cmds { + chunkBytes += cmdApproxBytes(cmd) + if chunkBytes >= limit && i+1 > start { + chunks = append(chunks, cmds[start:i+1]) + start = i + 1 + chunkBytes = 0 + } + } + if start < len(cmds) { + chunks = append(chunks, cmds[start:]) + } + ap.dispatchSequential(ctx, chunks) + if merged { + putQueueSlice(cmds) + } +} + +// dispatchSequential dispatches an ORDERED sequence of sub-batches, stopping +// once one of them dies on a transport-class failure and failing the rest with +// that error. +// +// The stop is the same contract the unchunked path has: it fails or retries the +// batch as a UNIT, so in an ordered stream later commands must never overtake a +// prefix that died (retries exhausted, hook abort). Per-command redis errors +// (WRONGTYPE, nil) are normal outcomes and do not stop the sequence. +// +// Both callers that break a batch into ordered pieces — the MaxBatchBytes +// chunker and the retry-policy runs — go through here, because the first +// version of each got this wrong independently (review findings by codex on +// #3942). +func (ap *AutoPipeliner) dispatchSequential(ctx context.Context, groups [][]Cmder) { + var abortErr error + for _, group := range groups { + if len(group) == 0 { + continue + } + if abortErr != nil { + setCmdsErr(group, abortErr) + continue + } + ap.dispatchCmds(ctx, [][]Cmder{group}, len(group)) + for _, cmd := range group { + if err := cmd.rawErr(); err != nil && !isRedisError(err) { + abortErr = err + break + } + } + } +} + +// splitRetryRuns slices cmds into maximal CONTIGUOUS runs of one retry policy, +// preserving order: run i's commands all precede run i+1's, exactly as +// submitted. It returns nil when the whole batch is already policy-uniform — +// the overwhelmingly common case — so uniform batches allocate nothing and are +// dispatched as one pipeline. +// +// Runs are sub-slices of cmds, not copies, so they must be dispatched before +// cmds is recycled and must not be returned to the slice pool individually. +func splitRetryRuns(cmds []Cmder) [][]Cmder { + if len(cmds) < 2 { + return nil + } + first := cmds[0].NoRetry() + boundary := -1 + for i := 1; i < len(cmds); i++ { + if cmds[i].NoRetry() != first { + boundary = i + break + } + } + if boundary < 0 { + return nil // uniform: one dispatch, no split + } + runs := make([][]Cmder, 0, 4) + start := 0 + policy := first + for i := 1; i < len(cmds); i++ { + if p := cmds[i].NoRetry(); p != policy { + runs = append(runs, cmds[start:i]) + start = i + policy = p + } + } + return append(runs, cmds[start:]) +} + +// recoverDispatchPanic converts a panic on a dispatch goroutine (a hook or +// command-encoder panic inside Process/Exec) into per-command errors instead +// of crashing the process. On a plain client the same panic unwinds into the +// CALLER, who can recover; the engine's dispatch goroutines have no caller, +// so an unrecovered panic here would kill the whole program on behalf of one +// bad command. Registered LAST at each dispatch site so it runs FIRST on +// unwind (LIFO) — the errors are stamped before the deferred batch closes +// wake the waiters. setCmdsErr fills only commands without an error, so +// exec-recorded outcomes for commands that finished are preserved. +func recoverDispatchPanic(cmds ...[]Cmder) { + r := recover() + if r == nil { + return + } + err := fmt.Errorf("redis: autopipeline: panic during dispatch: %v", r) + for _, batch := range cmds { + setCmdsErr(batch, err) + } + internal.Logger.Printf(context.Background(), "autopipeline: recovered dispatch panic: %v\n%s", r, debug.Stack()) +} + +// flushBatchSlice takes the shard's currently-queued commands as one batch, +// swaps in a fresh batch for subsequent enqueues, and dispatches the taken +// batch. Completion is signalled by closing the batch's done channel once +// (waking every waiter in a single operation) rather than one channel send +// per command. +func (s *apShard) flushBatchSlice() { + ap := s.ap + + // Drain every stripe into one combined batch and roll fresh queues for the + // commands enqueued after this point. Striped enqueue spreads the hot + // mutex; one merged flush keeps the pipeline deep. accumulateBatch already + // bounds the total to roughly MaxBatchSize before we get here. + queues := make([][]Cmder, 0, len(s.stripes)) + batches := make([]*apBatch, 0, len(s.stripes)) + total := 0 + for i := range s.stripes { + st := &s.stripes[i] + // Skip provably-empty stripes without taking their mutex. Safe in + // THIS path only: an enqueue publishes queueLen under the stripe lock + // and wakes the flusher after unlocking, so a command that appears + // concurrently with this unlocked read is re-observed by the + // flusher's Len() loop or the buffered notify — the same protocol the + // flusher already relies on. The shutdown drain must keep locking + // unconditionally (see flushBatchSliceShutdown). + if st.queueLen.Load() == 0 { + continue + } + st.mu.Lock() + if len(st.queue) > 0 { + queues = append(queues, st.queue) + batches = append(batches, st.curBatch) + total += len(st.queue) + st.queue = getQueueSlice(ap.config.MaxBatchSize) + st.curBatch = newAPBatch() + st.queueLen.Store(0) + st.queueBytes.Store(0) + } + st.mu.Unlock() + } + if total == 0 { + return + } + + // Acquire a concurrency permit. The wait runs on a background context with + // a generous backstop deadline against a wedged semaphore: commands taken + // from the queue were already ACCEPTED, so a concurrent Close must not + // cancel them mid-acquire — Close's contract is to flush pending commands + // (it waits for this dispatch via wg/batchWg before tearing anything + // down). The backstop is deliberately well above both the default + // ReadTimeout and a maintnotifications relaxed window, so a legitimately + // slow batch (e.g. during a failover) holding a permit does not cause + // waiters to spuriously fail. + if !s.sem.TryAcquire() { + err := s.sem.Acquire(context.Background(), autoPipelinePermitBackstop, ErrAutoPipelineTimeout) + if err != nil { + // A permit not freeing within the backstop means the in-flight + // batch is wedged well past any configured timeout — leave an + // operator breadcrumb before failing the drained commands. + internal.Logger.Printf(context.Background(), + "redis: autopipeline: no batch permit after %s; failing %d queued commands", + autoPipelinePermitBackstop, total) + batchErr := err + for i := range queues { + for _, qc := range queues[i] { + qc.SetErr(batchErr) + } + batches[i].close() + putQueueSlice(queues[i]) + } + return + } + + // Wave merge. We took the queue and then waited a full batch round + // trip for the permit; callers whose replies landed just after our + // take re-submitted into the FRESH queue during that wait. Executing + // without them splits the group into two alternating waves — each + // observing two round trips, at half throughput — a state that is + // stable once entered (measured: p50 pinned at 2xRTT for entire runs + // at mid worker counts on a 52ms link). On the default window, let the + // wave of follow-ups land and fold it into this batch before + // executing, which merges the waves back into one batch per round + // trip. Explicit-delay configs keep their own timing. + if ap.config.MaxFlushDelay == 0 && !ap.config.AdaptiveDelay { + s.awaitExpectedArrivals(ap.config.MaxBatchSize) + for i := range s.stripes { + st := &s.stripes[i] + if st.queueLen.Load() == 0 { + continue + } + st.mu.Lock() + if len(st.queue) > 0 { + queues = append(queues, st.queue) + batches = append(batches, st.curBatch) + total += len(st.queue) + st.queue = getQueueSlice(ap.config.MaxBatchSize) + st.curBatch = newAPBatch() + st.queueLen.Store(0) + st.queueBytes.Store(0) + } + st.mu.Unlock() + } + } + } + + // Fast path for single command: skip the pipeline and Process directly, in + // its own goroutine. The dispatch MUST NOT run inline in the flusher: a + // synchronous Process blocks the flusher for a full round trip, and on a + // slow link a solo straggler then holds up an entire landed wave for one + // RTT — whose flush then delays the straggler's next command in turn, a + // stable phase-lock where everyone pays 2x RTT (measured: ~25% of runs on + // a 57ms link locked at exactly 2x RTT until perturbed). + // No expectedArrivals announcement: a single waiter waking is the + // lone-caller case, which must keep flushing immediately. + if total == 1 { + ap.batchWg.Add(1) + s.inFlight.Add(1) + go func() { + // Defer order matters: the batch close is registered BEFORE the + // permit release and inFlight decrement so it runs AFTER them + // (LIFO) — a woken lone caller's next command then observes an + // idle shard and takes the immediate-flush path instead of + // arming the silence-gap wait. + defer ap.batchWg.Done() + defer batches[0].close() + defer s.inFlight.Add(-1) + defer s.sem.Release() + defer putQueueSlice(queues[0]) + defer recoverDispatchPanic(queues[0]) + // Background for the same reason as the batch goroutine below: + // accepted commands execute even under a concurrent Close. + execStart := time.Now() + b := batches[0] + if !ap.blocking && ap.armSelfDeadlockGuard() { + b.dispGid.Store(curGoroutineID()) + } + solo := queues[0][0] + // Both faces run the user-hook chain via withProcessHook. The + // command records the CHAIN's final verdict — exactly what + // Client.Process does — before the deferred close wakes the + // waiter, so a hook that short-circuits, rewrites, or suppresses + // the error is honored. Hooks on this goroutine read the command + // deadlock-free via the dispGid guard stamped above. + // A successful short-circuit stays successful (see dispatchCmds). + err := ap.pipeliner.withProcessHook(context.Background(), solo, func(ctx context.Context, cmd Cmder) error { + return ap.pipeliner.process(ctx, cmd) + }) + solo.SetErr(err) + ap.observeBatchExec(time.Since(execStart)) + }() + return + } + + // Track this goroutine in the batchWg so Close() waits for it. + // IMPORTANT: Add to WaitGroup AFTER semaphore is acquired to avoid deadlock. + ap.batchWg.Add(1) + s.inFlight.Add(1) + go func() { + defer ap.batchWg.Done() + defer s.inFlight.Add(-1) + defer s.sem.Release() + // Signal completion with one close per taken stripe. Deferred so a + // panic in Process/Exec (e.g. a malformed command or encoder panic) + // still wakes every waiter in await() instead of hanging them forever; + // the closes run after Exec on the happy path, so results are + // populated first. + defer func() { + for i := range queues { + batches[i].close() + putQueueSlice(queues[i]) + } + }() + defer recoverDispatchPanic(queues...) + + // Execute on a background context: these commands were accepted before + // any concurrent Close, and Close waits for this goroutine (batchWg) + // before the client tears down its pools — cancelling here would + // error already-accepted commands while the shutdown sweep flushes + // later ones, an inverted outcome. The wire timeouts (Read/Write + // Timeout, or maintnotifications relaxed windows) still bound the + // execution; no per-batch timer is allocated. + ctx := context.Background() + + // The batches complete at the deferred closes, AFTER the whole hook + // chain has returned — so a hook's post-next verdict is honored and, + // like a plain pipeline, a hook may adjust results before any waiter + // wakes. Hooks on this goroutine read results deadlock-free via the + // dispGid guard in await() (armed below when hooks can exist). + if !ap.blocking && ap.armSelfDeadlockGuard() { + gid := curGoroutineID() + for i := range batches { + batches[i].dispGid.Store(gid) + } + } + + execStart := time.Now() + ap.dispatchCmdsMaybeChunked(ctx, queues, total) + ap.observeBatchExec(time.Since(execStart)) + + // Announce the expected arrivals BEFORE the deferred closes wake this + // batch's waiters, so the flusher knows the wave size the moment its + // first command lands (see expectedArrivals). + ap.expectedArrivals.Add(int64(total)) + }() +} + +// flushBatchSliceShutdown flushes commands during shutdown. +// Unlike flushBatchSlice, this doesn't use ap.ctx for semaphore acquisition +// because ap.ctx is already cancelled during shutdown. +// Executes synchronously to preserve command order. +func (s *apShard) flushBatchSliceShutdown() { + ap := s.ap + // Flush all remaining commands synchronously to preserve order. + // + // The loop condition is checked UNDER each stripe's lock (not via the + // unlocked s.Len()): a late enqueue appends to a stripe's queue and updates + // its queueLen under that stripe's mutex, so reading queueLen without the + // lock could miss a command that was just appended (seeing 0 and exiting + // while a command sits in the queue). Locking first makes "is the stripe + // empty?" and "take the stripe" atomic against that enqueue — this is what + // closes the lost-command race on Close. + for { + // Take every stripe's queue as one merged batch and roll fresh queues. + queues := make([][]Cmder, 0, len(s.stripes)) + batches := make([]*apBatch, 0, len(s.stripes)) + total := 0 + for i := range s.stripes { + st := &s.stripes[i] + st.mu.Lock() + if len(st.queue) > 0 { + queues = append(queues, st.queue) + batches = append(batches, st.curBatch) + total += len(st.queue) + st.queue = getQueueSlice(ap.config.MaxBatchSize) + st.curBatch = newAPBatch() + st.queueLen.Store(0) + st.queueBytes.Store(0) + } + st.mu.Unlock() + } + if total == 0 { + return + } + + // Serialize with any still-running in-flight batch: the shutdown drain + // used to bypass the per-shard permit, so under MaxConcurrentBatches:1 + // a drained command could execute CONCURRENTLY with the in-flight + // batch during Close and be observed out of order. Acquire the permit + // (bounded by the backstop, on a background context — ap.ctx is + // already cancelled here); if the backstop expires the permit holder + // is wedged and we proceed anyway rather than strand the commands. + acquired := s.sem.TryAcquire() + if !acquired { + acquired = s.sem.Acquire(context.Background(), autoPipelinePermitBackstop, ErrAutoPipelineTimeout) == nil + if !acquired { + internal.Logger.Printf(context.Background(), + "redis: autopipeline: no batch permit after %s during shutdown; flushing unserialized", + autoPipelinePermitBackstop) + } + } + + // Execute each batch in a func so close(batch.done) is deferred: a panic + // in Process/Exec still signals completion (waking await()) before it + // propagates, instead of leaving shutdown waiters hung. + func() { + if acquired { + defer s.sem.Release() + } + defer func() { + for i := range queues { + batches[i].close() + putQueueSlice(queues[i]) + } + }() + defer recoverDispatchPanic(queues...) + + // ap.ctx is already cancelled here (Close cancels it before draining), + // so use a fresh background context with no artificial deadline. The + // wire timeout is then governed by the connection's ReadTimeout / + // WriteTimeout — exactly like the normal flush path and a plain client + // Exec. Crucially this lets a relaxed timeout (set by maintnotifications + // during a failover/migration) take effect; a hardcoded short deadline + // here would cap that relaxed window and time out in-flight commands the + // relaxation was meant to protect. (A user who wants shutdown bounded + // sets ReadTimeout/WriteTimeout on the client, as for any command.) + if !ap.blocking && ap.armSelfDeadlockGuard() { + gid := curGoroutineID() + for i := range batches { + batches[i].dispGid.Store(gid) + } + } + ap.dispatchCmdsMaybeChunked(context.Background(), queues, total) + }() + } +} + +// Len returns the number of queued commands in this shard. +func (s *apShard) Len() int { + n := 0 + for i := range s.stripes { + n += int(s.stripes[i].queueLen.Load()) + } + return n +} + +// bytesFull reports whether the shard's queued payload volume has reached the +// configured MaxBatchBytes (false when the cap is disabled). Like the +// MaxBatchSize trigger it is soft: enqueues racing the check can overshoot. +func (s *apShard) bytesFull() bool { + limit := int64(s.ap.config.MaxBatchBytes) + if limit <= 0 { + return false + } + var n int64 + for i := range s.stripes { + n += s.stripes[i].queueBytes.Load() + if n >= limit { + return true + } + } + return false +} + +// cmdApproxBytes estimates a command's wire payload for MaxBatchBytes +// accounting: string/[]byte argument lengths plus a small fixed overhead per +// argument (type marker, length line, CRLFs). Exactness doesn't matter — the +// cap bounds burst size, it is not a protocol calculation. +func cmdApproxBytes(cmd Cmder) int64 { + const perArgOverhead = 16 + n := int64(0) + for _, a := range cmd.Args() { + switch v := a.(type) { + case string: + n += int64(len(v)) + case []byte: + n += int64(len(v)) + default: + n += 8 + } + n += perArgOverhead + } + return n +} + +// Len returns the current number of queued commands across all shards. +func (ap *AutoPipeliner) Len() int { + total := 0 + for _, s := range ap.shards { + total += s.Len() + } + return total +} + +// calculateDelay calculates the delay based on the given queue length (the +// caller's own shard, not the global total, so each shard tunes independently). +// Uses integer-only arithmetic for optimal performance (no float operations). +// Returns 0 if MaxFlushDelay is 0. +func (ap *AutoPipeliner) calculateDelay(queueLen int) time.Duration { + maxDelay := ap.config.MaxFlushDelay + if maxDelay == 0 { + return 0 + } + + // If adaptive delay is disabled, return fixed delay + if !ap.config.AdaptiveDelay { + return maxDelay + } + + if queueLen == 0 { + return 0 + } + + maxBatch := ap.config.MaxBatchSize + + // Use integer arithmetic to avoid float operations + // Calculate thresholds: 75%, 50%, 25% of maxBatch + // Multiply by 4 to avoid division: queueLen * 4 vs maxBatch * 3 (75%) + // + // Adaptive delay strategy: + // - ≥75% full: No delay (flush immediately to prevent overflow) + // - ≥50% full: 25% of max delay (queue filling up) + // - ≥25% full: 50% of max delay (moderate load) + // - <25% full: 100% of max delay (low load, maximize batching) + switch { + case queueLen*4 >= maxBatch*3: // queueLen >= 75% of maxBatch + return 0 // Flush immediately + case queueLen*2 >= maxBatch: // queueLen >= 50% of maxBatch + return maxDelay >> 2 // Divide by 4 using bit shift (faster) + case queueLen*4 >= maxBatch: // queueLen >= 25% of maxBatch + return maxDelay >> 1 // Divide by 2 using bit shift (faster) + default: + return maxDelay + } +} + +// Pipeline returns a new pipeline that uses the underlying pipeliner. +// This allows you to create a traditional pipeline from an autopipeliner. +func (ap *AutoPipeliner) Pipeline() Pipeliner { + return ap.pipeliner.Pipeline() +} + +// Pipelined executes a function in a pipeline context. +// This is a convenience method that creates a pipeline, executes the function, +// and returns the results. +func (ap *AutoPipeliner) Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) { + return ap.pipeliner.Pipeline().Pipelined(ctx, fn) +} + +// TxPipelined executes a function in a transaction pipeline context. +// This is a convenience method that creates a transaction pipeline, executes the function, +// and returns the results. It delegates to the underlying client's TxPipeline. +func (ap *AutoPipeliner) TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) { + return ap.pipeliner.TxPipeline().Pipelined(ctx, fn) +} + +// TxPipeline returns a new transaction pipeline that uses the underlying pipeliner. +// This allows you to create a traditional transaction pipeline from an autopipeliner. +// It delegates to the underlying client's TxPipeline. +func (ap *AutoPipeliner) TxPipeline() Pipeliner { + return ap.pipeliner.TxPipeline() +} + +// validate AutoPipeliner implements Cmdable +var _ Cmdable = (*AutoPipeliner)(nil) diff --git a/vendor/github.com/redis/go-redis/v9/autopipeline_bench_README.md b/vendor/github.com/redis/go-redis/v9/autopipeline_bench_README.md new file mode 100644 index 0000000000..40febdf937 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/autopipeline_bench_README.md @@ -0,0 +1,128 @@ +# Autopipelining benchmarks + +These benchmarks validate the goal of autopipelining: batching concurrent +commands into pipelines cuts network round-trips and raises throughput, without +callers writing pipeline code. + +Two measurement rules keep every number honest: + +- **Throughput is measured on executed commands** — a command is counted only + after its result has been read (`.Result()` / `.Err()`), never when it is + merely queued. On the deferred face `ap.Set` returns immediately, so counting + calls would measure enqueue speed, not throughput. +- **Rates divide by the timed region, not the nominal window.** The + fixed-duration drivers keep draining whatever was in flight when the deadline + hit; that drain is part of the timed region, so `ops/sec` cannot be inflated + by work that finished after the window closed. + +## Running + +The benchmarks talk to a real Redis on `:6379` (they skip when none answers). +Start one first: + +```sh +make docker.start # or: docker run --rm -p 6379:6379 redis + +# the headline three-way throughput comparison: +go test -run '^$' -bench BenchmarkAutoPipelineThroughput -benchtime=1x . + +# everything: +go test -run '^$' -bench Benchmark -benchmem -benchtime=1x . +``` + +The throughput benchmarks run for a fixed wall-clock duration (~3s each) and +report `ops/sec`, so `-benchtime=1x` (one iteration) is correct for them. +**Do not pass a time-based `-benchtime`** (e.g. `-benchtime=5s`): the +fixed-duration drivers ignore `b.N`, so Go's framework would re-run the full +window geometrically trying to fill the time budget. + +The per-operation benchmarks (`BenchmarkDispatchPath`, +`BenchmarkAutoPipelineZeroCopy`) are the opposite: their ns/op and allocs/op +are only meaningful at the **default** `-benchtime` — at `-benchtime=1x` the +workers still issue a minimum window each, all billed to a single iteration. +(The "everything" command above also sweeps the repo's other root-package +benchmarks; that is harmless, just broader than this file.) + +## The headline benchmark: BenchmarkAutoPipelineThroughput + +Three ways to issue the same workload (2000 goroutines), each counting only +executed commands: + +1. **Normal** — a plain client; each `Set` is a blocking round-trip. Bounded by + Redis's non-pipelined ceiling (like `redis-benchmark` without `-P`). +2. **AutoPipelineBlocking** — the blocking face with a parallel-batch config: + `ap.Set(...)` blocks until executed, the same call shape as a normal client. + Only one command per caller is in flight, but the flusher batches across the + 2000 callers into deep pipelines. +3. **AutoPipelineWindowed** — the deferred face: each caller submits a window of + 200 commands, then reads the results. Keeps pipelines deepest. + +The `WindowedGET` variant repeats (3) with GET instead of SET: SET throughput is +server-bound (Redis's write processing), GET is cheaper on the server, so the +GET number shows the client machinery itself is not the limit. + +**Absolute numbers are machine- and load-dependent and vary a lot** — CPU +count, Redis's own ceiling, network path (loopback vs docker veth vs real +network), and noisy neighbors all move them by integer factors. The signal is +the WITHIN-RUN multiplier against the `Normal` baseline measured in the same +environment, plus `allocs/op` (which is exact and stable everywhere): + +| variant | vs Normal (same run) | +| ----------------------- | ---------------------- | +| Normal | 1x (the baseline) | +| AutoPipelineBlocking | ~10x | +| AutoPipelineWindowed | ~25-30x | +| AutoPipelineWindowedGET | above Windowed (reads) | + +As one concrete example: an Apple Silicon laptop with a loopback Redis puts +`Normal` around 80k ops/sec (so ~800k blocking, ~2.5M windowed); a 4-vCPU CI +runner with dockerized Redis lands near half that on the CPU-bound variants — +different absolutes, same multipliers and ordering. + +The autopipeline variants use an explicit parallel-batch config +(`MaxBatchSize: 300, MaxConcurrentBatches: 80, Unordered: true`) — **not the +ordered default**. The default (`MaxConcurrentBatches: 1`, +`DefaultAutoPipelineOptions` / `DefaultBlockingAutoPipelineOptions`) serializes +batch execution: blocking usage lands at roughly half the parallel-batch +multiplier, while windowed submission stays well into the tens-of-x even +ordered. + +## The other benchmarks + +- **BenchmarkIndividualCommands** — plain-client baseline: one blocking + round-trip per command across GOMAXPROCS workers. Its ns/op is your + environment's RTT floor; every other number is best read against it. +- **BenchmarkManualPipeline** — hand-built 100-deep `Pipeline().Exec()`, + sequential: the per-command cost of explicit pipelining (roughly a tenth + of a round-trip per command). The ceiling autopipelining approaches + without anyone writing pipeline code. +- **BenchmarkDispatchPath** — the engine's per-command dispatch cost with + honest `b.N` accounting: ns/op and allocs/op per executed command + (4 allocs/cmd on the submit path; unordered dispatch roughly halves the + ordered ns/op), plus the lone-command blocking fast path (~1 RTT). +- **BenchmarkFutureFace** — the typed future face on the ordered default + config: per-command reads (`InOrder`) vs windowed reads (`Window200`, + roughly 2x InOrder). +- **BenchmarkAutoPipelineSubmit** — the non-blocking `Submit` entry point, + windowed, on the ordered default; lands in the same band as + `FutureFace/Window200`. +- **BenchmarkAutoPipelineZeroCopy** — `GetToBuffer`/`SetFromBuffer` vs regular + `Get`/`Set` (Set+Get pairs): B/op drops ~10x at 4KiB and ~90x at 64KiB + (payloads decode into the caller's buffer instead of fresh strings), with + throughput at parity or better; allocs/op 10 vs 11. The B/op and allocs/op + ratios are environment-independent. +- **BenchmarkClusterAutoPipelineThroughput** — the same blocking/windowed + drivers against a local 3-master cluster (slot-routed shard batches keep + per-node pipelines deep; scales past the standalone numbers in the same + environment, with a wide run-order-dependent spread). Skips when no + cluster answers on `:16600-16602`. + +## What was deliberately removed + +Earlier revisions carried "tuning sweep" benchmarks (batch sizes, flush +delays, buffer sizes) whose numbers were dominated by the configured +`MaxFlushDelay` timer at low parallelism — every swept value reported the same +timer readout, which could only mislead someone tuning from them. They were +removed rather than fixed: `BenchmarkDispatchPath` and the throughput drivers +cover the engine's real knobs. Tune with your own workload shape; the engine's +defaults need no tuning to hit the numbers above. diff --git a/vendor/github.com/redis/go-redis/v9/bitmap_commands.go b/vendor/github.com/redis/go-redis/v9/bitmap_commands.go new file mode 100644 index 0000000000..86aa9b7efc --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/bitmap_commands.go @@ -0,0 +1,197 @@ +package redis + +import ( + "context" + "errors" +) + +type BitMapCmdable interface { + GetBit(ctx context.Context, key string, offset int64) *IntCmd + SetBit(ctx context.Context, key string, offset int64, value int) *IntCmd + BitCount(ctx context.Context, key string, bitCount *BitCount) *IntCmd + BitOpAnd(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpOr(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpXor(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpDiff(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpDiff1(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpAndOr(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpOne(ctx context.Context, destKey string, keys ...string) *IntCmd + BitOpNot(ctx context.Context, destKey string, key string) *IntCmd + BitPos(ctx context.Context, key string, bit int64, pos ...int64) *IntCmd + BitPosSpan(ctx context.Context, key string, bit int8, start, end int64, span string) *IntCmd + BitField(ctx context.Context, key string, values ...interface{}) *IntSliceCmd + BitFieldRO(ctx context.Context, key string, values ...interface{}) *IntSliceCmd +} + +func (c cmdable) GetBit(ctx context.Context, key string, offset int64) *IntCmd { + cmd := NewIntCmd(ctx, "getbit", key, offset) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) SetBit(ctx context.Context, key string, offset int64, value int) *IntCmd { + cmd := NewIntCmd( + ctx, + "setbit", + key, + offset, + value, + ) + _ = c(ctx, cmd) + return cmd +} + +type BitCount struct { + Start, End int64 + Unit string // BYTE(default) | BIT +} + +const BitCountIndexByte string = "BYTE" +const BitCountIndexBit string = "BIT" + +func (c cmdable) BitCount(ctx context.Context, key string, bitCount *BitCount) *IntCmd { + args := make([]any, 2, 5) + args[0] = "bitcount" + args[1] = key + if bitCount != nil { + args = append(args, bitCount.Start, bitCount.End) + if bitCount.Unit != "" { + if bitCount.Unit != BitCountIndexByte && bitCount.Unit != BitCountIndexBit { + cmd := NewIntCmd(ctx) + cmd.SetErr(errors.New("redis: invalid bitcount index")) + return cmd + } + args = append(args, bitCount.Unit) + } + } + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) bitOp(ctx context.Context, op, destKey string, keys ...string) *IntCmd { + args := make([]interface{}, 3+len(keys)) + args[0] = "bitop" + args[1] = op + args[2] = destKey + for i, key := range keys { + args[3+i] = key + } + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// BitOpAnd creates a new bitmap in which users are members of all given bitmaps +func (c cmdable) BitOpAnd(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "and", destKey, keys...) +} + +// BitOpOr creates a new bitmap in which users are member of at least one given bitmap +func (c cmdable) BitOpOr(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "or", destKey, keys...) +} + +// BitOpXor creates a new bitmap in which users are the result of XORing all given bitmaps +func (c cmdable) BitOpXor(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "xor", destKey, keys...) +} + +// BitOpNot creates a new bitmap in which users are not members of a given bitmap +func (c cmdable) BitOpNot(ctx context.Context, destKey string, key string) *IntCmd { + return c.bitOp(ctx, "not", destKey, key) +} + +// BitOpDiff creates a new bitmap in which users are members of bitmap X but not of any of bitmaps Y1, Y2, … +// Introduced with Redis 8.2 +func (c cmdable) BitOpDiff(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "diff", destKey, keys...) +} + +// BitOpDiff1 creates a new bitmap in which users are members of one or more of bitmaps Y1, Y2, … but not members of bitmap X +// Introduced with Redis 8.2 +func (c cmdable) BitOpDiff1(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "diff1", destKey, keys...) +} + +// BitOpAndOr creates a new bitmap in which users are members of bitmap X and also members of one or more of bitmaps Y1, Y2, … +// Introduced with Redis 8.2 +func (c cmdable) BitOpAndOr(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "andor", destKey, keys...) +} + +// BitOpOne creates a new bitmap in which users are members of exactly one of the given bitmaps +// Introduced with Redis 8.2 +func (c cmdable) BitOpOne(ctx context.Context, destKey string, keys ...string) *IntCmd { + return c.bitOp(ctx, "one", destKey, keys...) +} + +// BitPos is an API before Redis version 7.0, cmd: bitpos key bit start end +// if you need the `byte | bit` parameter, please use `BitPosSpan`. +func (c cmdable) BitPos(ctx context.Context, key string, bit int64, pos ...int64) *IntCmd { + args := make([]interface{}, 3+len(pos)) + args[0] = "bitpos" + args[1] = key + args[2] = bit + switch len(pos) { + case 0: + case 1: + args[3] = pos[0] + case 2: + args[3] = pos[0] + args[4] = pos[1] + default: + cmd := NewIntCmd(ctx) + cmd.SetErr(errors.New("too many arguments")) + return cmd + } + cmd := NewIntCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// BitPosSpan supports the `byte | bit` parameters in redis version 7.0, +// the bitpos command defaults to using byte type for the `start-end` range, +// which means it counts in bytes from start to end. you can set the value +// of "span" to determine the type of `start-end`. +// span = "bit", cmd: bitpos key bit start end bit +// span = "byte", cmd: bitpos key bit start end byte +func (c cmdable) BitPosSpan(ctx context.Context, key string, bit int8, start, end int64, span string) *IntCmd { + cmd := NewIntCmd(ctx, "bitpos", key, bit, start, end, span) + _ = c(ctx, cmd) + return cmd +} + +// BitField accepts multiple values: +// - BitField("set", "i1", "offset1", "value1","cmd2", "type2", "offset2", "value2") +// - BitField([]string{"cmd1", "type1", "offset1", "value1","cmd2", "type2", "offset2", "value2"}) +// - BitField([]interface{}{"cmd1", "type1", "offset1", "value1","cmd2", "type2", "offset2", "value2"}) +func (c cmdable) BitField(ctx context.Context, key string, values ...interface{}) *IntSliceCmd { + args := make([]interface{}, 2, 2+len(values)) + args[0] = "bitfield" + args[1] = key + args = appendArgs(args, values) + cmd := NewIntSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// BitFieldRO - Read-only variant of the BITFIELD command. +// It is like the original BITFIELD but only accepts GET subcommand and can safely be used in read-only replicas. +// - BitFieldRO(ctx, key, "", "", "","") +func (c cmdable) BitFieldRO(ctx context.Context, key string, values ...interface{}) *IntSliceCmd { + args := make([]interface{}, 2, 2+len(values)) + args[0] = "BITFIELD_RO" + args[1] = key + if len(values)%2 != 0 { + c := NewIntSliceCmd(ctx) + c.SetErr(errors.New("BitFieldRO: invalid number of arguments, must be even")) + return c + } + for i := 0; i < len(values); i += 2 { + args = append(args, "GET", values[i], values[i+1]) + } + cmd := NewIntSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} diff --git a/vendor/github.com/redis/go-redis/v9/cluster_commands.go b/vendor/github.com/redis/go-redis/v9/cluster_commands.go new file mode 100644 index 0000000000..a02683f207 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/cluster_commands.go @@ -0,0 +1,205 @@ +package redis + +import "context" + +type ClusterCmdable interface { + ClusterMyShardID(ctx context.Context) *StringCmd + ClusterMyID(ctx context.Context) *StringCmd + ClusterSlots(ctx context.Context) *ClusterSlotsCmd + ClusterShards(ctx context.Context) *ClusterShardsCmd + ClusterLinks(ctx context.Context) *ClusterLinksCmd + ClusterNodes(ctx context.Context) *StringCmd + ClusterMeet(ctx context.Context, host, port string) *StatusCmd + ClusterForget(ctx context.Context, nodeID string) *StatusCmd + ClusterReplicate(ctx context.Context, nodeID string) *StatusCmd + ClusterResetSoft(ctx context.Context) *StatusCmd + ClusterResetHard(ctx context.Context) *StatusCmd + ClusterInfo(ctx context.Context) *StringCmd + ClusterKeySlot(ctx context.Context, key string) *IntCmd + ClusterGetKeysInSlot(ctx context.Context, slot int, count int) *StringSliceCmd + ClusterCountFailureReports(ctx context.Context, nodeID string) *IntCmd + ClusterCountKeysInSlot(ctx context.Context, slot int) *IntCmd + ClusterDelSlots(ctx context.Context, slots ...int) *StatusCmd + ClusterDelSlotsRange(ctx context.Context, min, max int) *StatusCmd + ClusterSaveConfig(ctx context.Context) *StatusCmd + ClusterSlaves(ctx context.Context, nodeID string) *StringSliceCmd + ClusterFailover(ctx context.Context) *StatusCmd + ClusterAddSlots(ctx context.Context, slots ...int) *StatusCmd + ClusterAddSlotsRange(ctx context.Context, min, max int) *StatusCmd + ReadOnly(ctx context.Context) *StatusCmd + ReadWrite(ctx context.Context) *StatusCmd +} + +func (c cmdable) ClusterMyShardID(ctx context.Context) *StringCmd { + cmd := NewStringCmd(ctx, "cluster", "myshardid") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterMyID(ctx context.Context) *StringCmd { + cmd := NewStringCmd(ctx, "cluster", "myid") + _ = c(ctx, cmd) + return cmd +} + +// ClusterSlots returns the mapping of cluster slots to nodes. +// +// Deprecated: Use ClusterShards instead as of Redis 7.0.0. +func (c cmdable) ClusterSlots(ctx context.Context) *ClusterSlotsCmd { + cmd := NewClusterSlotsCmd(ctx, "cluster", "slots") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterShards(ctx context.Context) *ClusterShardsCmd { + cmd := NewClusterShardsCmd(ctx, "cluster", "shards") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterLinks(ctx context.Context) *ClusterLinksCmd { + cmd := NewClusterLinksCmd(ctx, "cluster", "links") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterNodes(ctx context.Context) *StringCmd { + cmd := NewStringCmd(ctx, "cluster", "nodes") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterMeet(ctx context.Context, host, port string) *StatusCmd { + cmd := NewStatusCmd(ctx, "cluster", "meet", host, port) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterForget(ctx context.Context, nodeID string) *StatusCmd { + cmd := NewStatusCmd(ctx, "cluster", "forget", nodeID) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterReplicate(ctx context.Context, nodeID string) *StatusCmd { + cmd := NewStatusCmd(ctx, "cluster", "replicate", nodeID) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterResetSoft(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "cluster", "reset", "soft") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterResetHard(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "cluster", "reset", "hard") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterInfo(ctx context.Context) *StringCmd { + cmd := NewStringCmd(ctx, "cluster", "info") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterKeySlot(ctx context.Context, key string) *IntCmd { + cmd := NewIntCmd(ctx, "cluster", "keyslot", key) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterGetKeysInSlot(ctx context.Context, slot int, count int) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "cluster", "getkeysinslot", slot, count) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterCountFailureReports(ctx context.Context, nodeID string) *IntCmd { + cmd := NewIntCmd(ctx, "cluster", "count-failure-reports", nodeID) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterCountKeysInSlot(ctx context.Context, slot int) *IntCmd { + cmd := NewIntCmd(ctx, "cluster", "countkeysinslot", slot) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterDelSlots(ctx context.Context, slots ...int) *StatusCmd { + args := make([]interface{}, 2+len(slots)) + args[0] = "cluster" + args[1] = "delslots" + for i, slot := range slots { + args[2+i] = slot + } + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterDelSlotsRange(ctx context.Context, min, max int) *StatusCmd { + size := max - min + 1 + slots := make([]int, size) + for i := 0; i < size; i++ { + slots[i] = min + i + } + return c.ClusterDelSlots(ctx, slots...) +} + +func (c cmdable) ClusterSaveConfig(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "cluster", "saveconfig") + _ = c(ctx, cmd) + return cmd +} + +// ClusterSlaves lists the replica nodes of a master node. +// +// Deprecated: Use ClusterReplicas instead as of Redis 5.0.0. +func (c cmdable) ClusterSlaves(ctx context.Context, nodeID string) *StringSliceCmd { + cmd := NewStringSliceCmd(ctx, "cluster", "slaves", nodeID) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterFailover(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "cluster", "failover") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterAddSlots(ctx context.Context, slots ...int) *StatusCmd { + args := make([]interface{}, 2+len(slots)) + args[0] = "cluster" + args[1] = "addslots" + for i, num := range slots { + args[2+i] = num + } + cmd := NewStatusCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClusterAddSlotsRange(ctx context.Context, min, max int) *StatusCmd { + size := max - min + 1 + slots := make([]int, size) + for i := 0; i < size; i++ { + slots[i] = min + i + } + return c.ClusterAddSlots(ctx, slots...) +} + +func (c cmdable) ReadOnly(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "readonly") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ReadWrite(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "readwrite") + _ = c(ctx, cmd) + return cmd +} diff --git a/vendor/github.com/redis/go-redis/v9/command.go b/vendor/github.com/redis/go-redis/v9/command.go new file mode 100644 index 0000000000..f0575c0e30 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/command.go @@ -0,0 +1,9714 @@ +package redis + +import ( + "bufio" + "context" + "fmt" + "io" + "maps" + "net" + "regexp" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/redis/go-redis/v9/internal" + "github.com/redis/go-redis/v9/internal/hscan" + "github.com/redis/go-redis/v9/internal/proto" + "github.com/redis/go-redis/v9/internal/routing" + "github.com/redis/go-redis/v9/internal/util" +) + +// keylessCommands contains Redis commands that have empty key specifications (9th slot empty) +// Only includes core Redis commands, excludes FT.*, ts.*, timeseries.*, search.* and subcommands +var keylessCommands = map[string]struct{}{ + "acl": {}, + "asking": {}, + "auth": {}, + "bgrewriteaof": {}, + "bgsave": {}, + "client": {}, + "cluster": {}, + "config": {}, + "debug": {}, + "discard": {}, + "echo": {}, + "exec": {}, + "failover": {}, + "function": {}, + "hello": {}, + "hotkeys": {}, + "latency": {}, + "lolwut": {}, + "module": {}, + "monitor": {}, + "multi": {}, + "pfselftest": {}, + "ping": {}, + "psubscribe": {}, + "psync": {}, + "publish": {}, + "pubsub": {}, + "punsubscribe": {}, + "quit": {}, + "readonly": {}, + "readwrite": {}, + "replconf": {}, + "replicaof": {}, + "role": {}, + "save": {}, + "script": {}, + "select": {}, + "shutdown": {}, + "slaveof": {}, + "slowlog": {}, + "subscribe": {}, + "swapdb": {}, + "sync": {}, + "time": {}, + "unsubscribe": {}, + "unwatch": {}, + "wait": {}, +} + +// CmdTyper interface for getting command type +type CmdTyper interface { + GetCmdType() CmdType +} + +// CmdTypeGetter interface for getting command type without circular imports +type CmdTypeGetter interface { + GetCmdType() CmdType +} + +type CmdType uint8 + +const ( + CmdTypeGeneric CmdType = iota + CmdTypeString + CmdTypeInt + CmdTypeBool + CmdTypeFloat + CmdTypeStringSlice + CmdTypeIntSlice + CmdTypeFloatSlice + CmdTypeBoolSlice + CmdTypeMapStringString + CmdTypeMapStringInt + CmdTypeMapStringInterface + CmdTypeMapStringInterfaceSlice + CmdTypeSlice + CmdTypeStatus + CmdTypeDuration + CmdTypeTime + CmdTypeKeyValueSlice + CmdTypeStringStructMap + CmdTypeXMessageSlice + CmdTypeXStreamSlice + CmdTypeXPending + CmdTypeXPendingExt + CmdTypeXAutoClaim + CmdTypeXAutoClaimWithDeleted + CmdTypeXAutoClaimJustID + CmdTypeXInfoConsumers + CmdTypeXInfoGroups + CmdTypeXInfoStream + CmdTypeXInfoStreamFull + CmdTypeZSlice + CmdTypeZWithKey + CmdTypeScan + CmdTypeClusterSlots + CmdTypeGeoLocation + CmdTypeGeoSearchLocation + CmdTypeGeoPos + CmdTypeCommandsInfo + CmdTypeSlowLog + CmdTypeMapStringStringSlice + CmdTypeMapMapStringInterface + CmdTypeKeyValues + CmdTypeZSliceWithKey + CmdTypeFunctionList + CmdTypeFunctionStats + CmdTypeLCS + CmdTypeKeyFlags + CmdTypeClusterLinks + CmdTypeClusterShards + CmdTypeRankWithScore + CmdTypeClientInfo + CmdTypeACLLog + CmdTypeInfo + CmdTypeMonitor + CmdTypeJSON + CmdTypeJSONSlice + CmdTypeIntPointerSlice + CmdTypeScanDump + CmdTypeBFInfo + CmdTypeCFInfo + CmdTypeCMSInfo + CmdTypeTopKInfo + CmdTypeTDigestInfo + CmdTypeFTSynDump + CmdTypeAggregate + CmdTypeFTInfo + CmdTypeFTSpellCheck + CmdTypeFTSearch + CmdTypeTSTimestampValue + CmdTypeTSTimestampValueSlice + CmdTypeTSNRangePivotRowSlice + CmdTypeHotKeys + CmdTypeIncrEXInt + CmdTypeIncrEXFloat + CmdTypeUint + CmdTypeUintSlice + CmdTypeAREntrySlice +) + +type ( + CmdTypeXAutoClaimValue struct { + messages []XMessage + start string + } + + CmdTypeXAutoClaimWithDeletedValue struct { + messages []XMessage + start string + deletedIDs []string + } + + CmdTypeXAutoClaimJustIDValue struct { + ids []string + start string + } + + CmdTypeScanValue struct { + keys []string + cursor uint64 + } + + CmdTypeKeyValuesValue struct { + key string + values []string + } + + CmdTypeZSliceWithKeyValue struct { + key string + zSlice []Z + } +) + +type Cmder interface { + // command name. + // e.g. "set k v ex 10" -> "set", "cluster info" -> "cluster". + Name() string + + // full command name. + // e.g. "set k v ex 10" -> "set", "cluster info" -> "cluster info". + FullName() string + + // all args of the command. + // e.g. "set k v ex 10" -> "[set k v ex 10]". + Args() []interface{} + + // format request and response string. + // e.g. "set k v ex 10" -> "set k v ex 10: OK", "get k" -> "get k: v". + String() string + + // Clone creates a copy of the command. + Clone() Cmder + + stringArg(int) string + firstKeyPos() int8 + SetFirstKeyPos(int8) + stepCount() int8 + SetStepCount(int8) + + // cachedSlot/setCachedSlot memoize the cluster slot so it is computed once + // (in the autopipeline shard router) and reused at pipeline-flush routing. + cachedSlot() (int, bool) + setCachedSlot(int) + + readTimeout() *time.Duration + readReply(rd *proto.Reader) error + readRawReply(rd *proto.Reader) error + SetErr(error) + Err() error + + // setReady marks a command as asynchronously pending (autopipeline async + // faces); await blocks the public accessors until it has executed; rawErr + // reads the error without awaiting (internal execution path). + setReady(*apBatch) + await() + rawErr() error + + // NoRetry returns true if the command should not be retried on failure. + // Commands that write directly to an io.Writer should return true since + // partial writes cannot be undone on retry. + NoRetry() bool + + // GetCmdType returns the command type for fast value extraction + GetCmdType() CmdType +} + +func setCmdsErr(cmds []Cmder, e error) { + for _, cmd := range cmds { + // rawErr: this runs on the execution path; never await here. + if cmd.rawErr() == nil { + cmd.SetErr(e) + } + } +} + +func cmdsFirstErr(cmds []Cmder) error { + for _, cmd := range cmds { + // rawErr: this runs on the execution path; never await here. + if err := cmd.rawErr(); err != nil { + return err + } + } + return nil +} + +// cmdsContainNoRetry returns true if any command in the slice has NoRetry() == true. +// If a pipeline contains a non-retryable command (e.g., RawWriteToCmd), the entire +// pipeline must not be retried to prevent data corruption from partial writes. +func cmdsContainNoRetry(cmds []Cmder) bool { + for _, cmd := range cmds { + if cmd.NoRetry() { + return true + } + } + return false +} + +func writeCmds(wr *proto.Writer, cmds []Cmder) error { + for _, cmd := range cmds { + if err := writeCmd(wr, cmd); err != nil { + return err + } + } + return nil +} + +func writeCmd(wr *proto.Writer, cmd Cmder) error { + return wr.WriteArgs(cmd.Args()) +} + +// cmdFirstKeyPosWithInfo returns the first key position in a command's args (0 if none). +// Uses CommandInfo.FirstKeyPos when available (via cache peek, no network call), falling +// back to a hardcoded table. eval/evalsha variants are resolved from the runtime numkeys arg. +func cmdFirstKeyPosWithInfo(cmd Cmder, info *CommandInfo) int { + if pos := cmd.firstKeyPos(); pos != 0 { + return int(pos) + } + + name := cmd.Name() + + // first check if the command is keyless + if _, ok := keylessCommands[name]; ok { + return 0 + } + + // Module commands registered keyless in the static policy table (e.g. + // ft.aliaslist) route as keyless even while the command-info cache is + // cold, so the first calls of a process don't hash a non-key argument + // (such as an index name) into a slot. + if defaultPolicyKeyless(name) { + return 0 + } + + switch name { + case "eval", "evalsha", "eval_ro", "evalsha_ro": + if cmd.stringArg(2) != "0" { + return 3 + } + + return 0 + case "memory": + // https://github.com/redis/redis/issues/7493 + if cmd.stringArg(1) == "usage" { + return 2 + } + // CommandInfo (if available) gives the correct answer + // otherwise the hardcoded fallback applies. + } + + // Use CommandInfo cache when warm (in-memory only, no extra round-trips). + if info != nil { + return int(info.FirstKeyPos) + } + + return 1 +} + +func cmdString(cmd Cmder, val interface{}) string { + b := make([]byte, 0, 64) + + for i, arg := range cmd.Args() { + if i > 0 { + b = append(b, ' ') + } + b = internal.AppendArg(b, arg) + } + + if err := cmd.rawErr(); err != nil { + b = append(b, ": "...) + b = append(b, err.Error()...) + } else if val != nil { + b = append(b, ": "...) + b = internal.AppendArg(b, val) + } + + return util.BytesToString(b) +} + +//------------------------------------------------------------------------------ + +type baseCmd struct { + ctx context.Context + args []interface{} + err error + keyPos int8 + _stepCount int8 + rawVal interface{} + _readTimeout *time.Duration + cmdType CmdType + // slotCache memoizes the cluster slot once computed, so the cluster + // autopipeline shard router and the pipeline flush router don't each + // recompute it. 0 = not computed; it stores slot+1 so a real slot of 0 is + // distinguishable from unset. A plain field is safe by construction: it + // is written at most once, on the submitting goroutine BEFORE the command + // is published to a stripe queue (the stripe mutex is the happens-before + // edge to the flusher that later reads it). Do not write it from any + // other point in the command's life. + slotCache uint16 + + // ready, when non-nil, is the batch whose done channel closes once the + // command has executed. It is set only by the deferred (async) + // autopipeliner, which hands the command back to the caller before it + // runs. The public result accessors (Err/Val/Result/String) call await + // so they transparently block until execution; internal execution-path + // reads use rawErr to avoid awaiting the very batch they are producing + // (formatting included: cmdString reads rawErr and receives the value + // snapshot from its caller, so String methods await BEFORE reading their + // val field — otherwise formatting an in-flight async command would race + // with reply processing). ready stays + // nil for ordinary synchronous commands, whose accessors never block. + ready atomic.Pointer[apBatch] +} + +// setReady publishes the batch gating this command's result accessors. The +// field is atomic, NOT lock-ordered with the enqueue: a dispatch-side hook +// racing this store simply reads nil and takes the non-blocking path — the +// correct "not executed yet" view — while the setting goroutine always sees +// its own store before it awaits. +func (cmd *baseCmd) setReady(b *apBatch) { cmd.ready.Store(b) } + +// await blocks until an asynchronously-submitted command has executed. It is a +// single nil-pointer load for synchronous commands, so the common path stays +// allocation- and contention-free. +func (cmd *baseCmd) await() { + b := cmd.ready.Load() + if b == nil { + return + } + select { + case <-b.done: + return + default: + } + if b.isExecutorGoroutine() { + // A hook on one of the batch's own executor goroutines (the + // dispatcher, or a cluster per-node executor) is reading this + // command's result BEFORE next() has executed it. Blocking would + // self-deadlock (the batch completes only after that goroutine + // returns); return the not-yet-executed state instead — the same + // view a plain pipeline hook has before next(). + return + } + <-b.done +} + +// rawErr returns the command error WITHOUT awaiting. The internal +// execution/serialization path (setCmdsErr, cmdsFirstErr, and the cmdString +// formatter — public String methods await before calling it) uses it so that +// reading errors while a batch is being executed does not deadlock on the +// batch's own completion signal. +func (cmd *baseCmd) rawErr() error { return cmd.err } + +// readyBatch exposes the deferred-face batch gating this command (nil for +// synchronous commands) to the cluster fan-out, which registers its per-node +// goroutines as executors of every batch they carry. +func (cmd *baseCmd) readyBatch() *apBatch { return cmd.ready.Load() } + +// resultReady reports whether the command's result can be read WITHOUT +// blocking: either it never rode the deferred autopipeline face (no gating +// batch) or that batch has already completed. Post-execution bookkeeping in +// the command wrappers — the OTel metric emissions — consults it so that +// enabling telemetry cannot turn a deferred submission into a blocking call. +func (cmd *baseCmd) resultReady() bool { + b := cmd.ready.Load() + if b == nil { + return true + } + select { + case <-b.done: + return true + default: + return false + } +} + +var _ Cmder = (*Cmd)(nil) + +func (cmd *baseCmd) Name() string { + if len(cmd.args) == 0 { + return "" + } + // Cmd name must be lower cased. + return internal.ToLower(cmd.stringArg(0)) +} + +func (cmd *baseCmd) FullName() string { + switch name := cmd.Name(); name { + case "cluster", "command": + if len(cmd.args) == 1 { + return name + } + if s2, ok := cmd.args[1].(string); ok { + return name + " " + s2 + } + return name + default: + return name + } +} + +func (cmd *baseCmd) Args() []interface{} { + return cmd.args +} + +func (cmd *baseCmd) stringArg(pos int) string { + if pos < 0 || pos >= len(cmd.args) { + return "" + } + arg := cmd.args[pos] + switch v := arg.(type) { + case string: + return v + case *string: + if v == nil { + return "" + } + return *v + case []byte: + return string(v) + default: + // TODO: consider using appendArg + return fmt.Sprint(v) + } +} + +func (cmd *baseCmd) firstKeyPos() int8 { + return cmd.keyPos +} + +func (cmd *baseCmd) SetFirstKeyPos(keyPos int8) { + cmd.keyPos = keyPos +} + +// cachedSlot returns the cached cluster slot and whether one was set. +func (cmd *baseCmd) cachedSlot() (int, bool) { + if cmd.slotCache == 0 { + return 0, false + } + return int(cmd.slotCache - 1), true +} + +// setCachedSlot stores the computed cluster slot (0..16383) for reuse. +func (cmd *baseCmd) setCachedSlot(slot int) { + if slot >= 0 && slot < 16384 { + cmd.slotCache = uint16(slot + 1) + } +} + +func (cmd *baseCmd) stepCount() int8 { + return cmd._stepCount +} + +func (cmd *baseCmd) SetStepCount(stepCount int8) { + cmd._stepCount = stepCount +} + +func (cmd *baseCmd) SetErr(e error) { + cmd.err = e +} + +func (cmd *baseCmd) Err() error { + cmd.await() + return cmd.err +} + +func (cmd *baseCmd) readTimeout() *time.Duration { + return cmd._readTimeout +} + +func (cmd *baseCmd) setReadTimeout(d time.Duration) { + cmd._readTimeout = &d +} + +func (cmd *baseCmd) readRawReply(rd *proto.Reader) (err error) { + cmd.rawVal, err = rd.ReadReply() + return err +} + +// NoRetry returns true if the command should not be retried on failure. +// By default, commands can be retried. Commands that write directly to an +// io.Writer (like RawWriteToCmd) should override this to return true since +// partial writes cannot be undone on retry. +func (cmd *baseCmd) NoRetry() bool { + return false +} + +func (cmd *baseCmd) GetCmdType() CmdType { + return cmd.cmdType +} + +func (cmd *baseCmd) cloneBaseCmd() baseCmd { + var readTimeout *time.Duration + if cmd._readTimeout != nil { + timeout := *cmd._readTimeout + readTimeout = &timeout + } + + // Create a copy of args slice + args := make([]interface{}, len(cmd.args)) + copy(args, cmd.args) + + return baseCmd{ + ctx: cmd.ctx, + args: args, + err: cmd.err, + keyPos: cmd.keyPos, + _stepCount: cmd._stepCount, + rawVal: cmd.rawVal, + _readTimeout: readTimeout, + cmdType: cmd.cmdType, + } +} + +//------------------------------------------------------------------------------ + +type Cmd struct { + baseCmd + + val interface{} +} + +func NewCmd(ctx context.Context, args ...interface{}) *Cmd { + return &Cmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeGeneric, + }, + } +} + +func (cmd *Cmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *Cmd) SetVal(val interface{}) { + cmd.val = val +} + +func (cmd *Cmd) Val() interface{} { + cmd.await() + return cmd.val +} + +func (cmd *Cmd) Result() (interface{}, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *Cmd) Text() (string, error) { + cmd.await() + if cmd.err != nil { + return "", cmd.err + } + return toString(cmd.val) +} + +func toString(val interface{}) (string, error) { + switch val := val.(type) { + case string: + return val, nil + default: + err := fmt.Errorf("redis: unexpected type=%T for String", val) + return "", err + } +} + +func (cmd *Cmd) Int() (int, error) { + cmd.await() + if cmd.err != nil { + return 0, cmd.err + } + switch val := cmd.val.(type) { + case int64: + return int(val), nil + case string: + return strconv.Atoi(val) + default: + err := fmt.Errorf("redis: unexpected type=%T for Int", val) + return 0, err + } +} + +func (cmd *Cmd) Int64() (int64, error) { + cmd.await() + if cmd.err != nil { + return 0, cmd.err + } + return toInt64(cmd.val) +} + +func toInt64(val interface{}) (int64, error) { + switch val := val.(type) { + case int64: + return val, nil + case string: + return strconv.ParseInt(val, 10, 64) + default: + err := fmt.Errorf("redis: unexpected type=%T for Int64", val) + return 0, err + } +} + +func (cmd *Cmd) Uint64() (uint64, error) { + cmd.await() + if cmd.err != nil { + return 0, cmd.err + } + return toUint64(cmd.val) +} + +func toUint64(val interface{}) (uint64, error) { + switch val := val.(type) { + case int64: + return uint64(val), nil + case string: + return strconv.ParseUint(val, 10, 64) + default: + err := fmt.Errorf("redis: unexpected type=%T for Uint64", val) + return 0, err + } +} + +func (cmd *Cmd) Float32() (float32, error) { + cmd.await() + if cmd.err != nil { + return 0, cmd.err + } + return toFloat32(cmd.val) +} + +func toFloat32(val interface{}) (float32, error) { + switch val := val.(type) { + case int64: + return float32(val), nil + case string: + f, err := strconv.ParseFloat(val, 32) + if err != nil { + return 0, err + } + return float32(f), nil + default: + err := fmt.Errorf("redis: unexpected type=%T for Float32", val) + return 0, err + } +} + +func (cmd *Cmd) Float64() (float64, error) { + cmd.await() + if cmd.err != nil { + return 0, cmd.err + } + return toFloat64(cmd.val) +} + +func toFloat64(val interface{}) (float64, error) { + switch val := val.(type) { + case int64: + return float64(val), nil + case string: + return strconv.ParseFloat(val, 64) + default: + err := fmt.Errorf("redis: unexpected type=%T for Float64", val) + return 0, err + } +} + +func (cmd *Cmd) Bool() (bool, error) { + cmd.await() + if cmd.err != nil { + return false, cmd.err + } + return toBool(cmd.val) +} + +func toBool(val interface{}) (bool, error) { + switch val := val.(type) { + case bool: + return val, nil + case int64: + return val != 0, nil + case string: + return strconv.ParseBool(val) + default: + err := fmt.Errorf("redis: unexpected type=%T for Bool", val) + return false, err + } +} + +func (cmd *Cmd) Slice() ([]interface{}, error) { + cmd.await() + if cmd.err != nil { + return nil, cmd.err + } + switch val := cmd.val.(type) { + case []interface{}: + return val, nil + default: + return nil, fmt.Errorf("redis: unexpected type=%T for Slice", val) + } +} + +func (cmd *Cmd) StringSlice() ([]string, error) { + slice, err := cmd.Slice() + if err != nil { + return nil, err + } + + ss := make([]string, len(slice)) + for i, iface := range slice { + val, err := toString(iface) + if err != nil { + return nil, err + } + ss[i] = val + } + return ss, nil +} + +func (cmd *Cmd) Int64Slice() ([]int64, error) { + slice, err := cmd.Slice() + if err != nil { + return nil, err + } + + nums := make([]int64, len(slice)) + for i, iface := range slice { + val, err := toInt64(iface) + if err != nil { + return nil, err + } + nums[i] = val + } + return nums, nil +} + +func (cmd *Cmd) Uint64Slice() ([]uint64, error) { + slice, err := cmd.Slice() + if err != nil { + return nil, err + } + + nums := make([]uint64, len(slice)) + for i, iface := range slice { + val, err := toUint64(iface) + if err != nil { + return nil, err + } + nums[i] = val + } + return nums, nil +} + +func (cmd *Cmd) Float32Slice() ([]float32, error) { + slice, err := cmd.Slice() + if err != nil { + return nil, err + } + + floats := make([]float32, len(slice)) + for i, iface := range slice { + val, err := toFloat32(iface) + if err != nil { + return nil, err + } + floats[i] = val + } + return floats, nil +} + +func (cmd *Cmd) Float64Slice() ([]float64, error) { + slice, err := cmd.Slice() + if err != nil { + return nil, err + } + + floats := make([]float64, len(slice)) + for i, iface := range slice { + val, err := toFloat64(iface) + if err != nil { + return nil, err + } + floats[i] = val + } + return floats, nil +} + +func (cmd *Cmd) BoolSlice() ([]bool, error) { + slice, err := cmd.Slice() + if err != nil { + return nil, err + } + + bools := make([]bool, len(slice)) + for i, iface := range slice { + val, err := toBool(iface) + if err != nil { + return nil, err + } + bools[i] = val + } + return bools, nil +} + +func (cmd *Cmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadReply() + return err +} + +func (cmd *Cmd) Clone() Cmder { + return &Cmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +//------------------------------------------------------------------------------ + +// RawCmd returns raw RESP protocol bytes without parsing. +type RawCmd struct { + baseCmd + val []byte +} + +var _ Cmder = (*RawCmd)(nil) + +func NewRawCmd(ctx context.Context, args ...interface{}) *RawCmd { + return &RawCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeGeneric, + }, + } +} + +func (cmd *RawCmd) SetVal(val []byte) { + cmd.val = val +} + +func (cmd *RawCmd) Val() []byte { + cmd.await() + return cmd.val +} + +func (cmd *RawCmd) Result() ([]byte, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *RawCmd) Bytes() ([]byte, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *RawCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *RawCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadRawReply() + return err +} + +func (cmd *RawCmd) Clone() Cmder { + var val []byte + if cmd.val != nil { + val = make([]byte, len(cmd.val)) + copy(val, cmd.val) + } + return &RawCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +// RawWriteToCmd streams raw RESP protocol bytes directly to an io.Writer without intermediate allocations. +type RawWriteToCmd struct { + baseCmd + w io.Writer + written int64 +} + +var _ Cmder = (*RawWriteToCmd)(nil) + +func NewRawWriteToCmd(ctx context.Context, w io.Writer, args ...interface{}) *RawWriteToCmd { + return &RawWriteToCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeGeneric, + }, + w: w, + } +} + +func (cmd *RawWriteToCmd) SetVal(written int64) { + cmd.written = written +} + +func (cmd *RawWriteToCmd) Val() int64 { + cmd.await() + return cmd.written +} + +func (cmd *RawWriteToCmd) Result() (int64, error) { + cmd.await() + return cmd.written, cmd.err +} + +func (cmd *RawWriteToCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.written) +} + +func (cmd *RawWriteToCmd) readReply(rd *proto.Reader) (err error) { + cmd.written, err = rd.ReadRawReplyWriteTo(cmd.w) + return err +} + +// NoRetry returns true because RawWriteToCmd writes directly to an io.Writer. +// If a retry occurs, partial data from failed attempts would be appended to +// the writer, causing data corruption. The caller must handle retries manually +// if needed, using a fresh writer for each attempt. +func (cmd *RawWriteToCmd) NoRetry() bool { + return true +} + +func (cmd *RawWriteToCmd) Clone() Cmder { + return &RawWriteToCmd{ + baseCmd: cmd.cloneBaseCmd(), + w: cmd.w, + written: cmd.written, + } +} + +//------------------------------------------------------------------------------ + +// ZeroCopyStringCmd reads a bulk string response directly into a user-provided +// buffer, avoiding intermediate allocations. The RESP header is parsed through +// the buffered reader, then bulk data is read straight into the caller's buffer +// via proto.Reader.ReadStringInto — for values larger than the bufio buffer, +// this is effectively zero-copy from the socket to the user buffer. +// +// The buffer must be sized to fit the value; if it is too small an error is +// returned and the payload plus trailing CRLF are drained from the reader so +// the connection stays aligned for subsequent commands. +type ZeroCopyStringCmd struct { + baseCmd + buf []byte // user-provided buffer to read into + n int // number of bytes read into buf + cloned bool // set by Clone(); causes readReply to drain + error +} + +var _ Cmder = (*ZeroCopyStringCmd)(nil) + +func NewZeroCopyStringCmd(ctx context.Context, buf []byte, args ...interface{}) *ZeroCopyStringCmd { + return &ZeroCopyStringCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeString, + }, + buf: buf, + } +} + +func (cmd *ZeroCopyStringCmd) SetVal(n int) { + cmd.n = n +} + +func (cmd *ZeroCopyStringCmd) Val() int { + cmd.await() + return cmd.n +} + +// Result returns the number of bytes read and any error. +func (cmd *ZeroCopyStringCmd) Result() (int, error) { + cmd.await() + return cmd.n, cmd.err +} + +// Bytes returns the slice of the user-provided buffer containing the read data. +func (cmd *ZeroCopyStringCmd) Bytes() []byte { + cmd.await() + return cmd.buf[:cmd.n] +} + +func (cmd *ZeroCopyStringCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.n) +} + +func (cmd *ZeroCopyStringCmd) readReply(rd *proto.Reader) error { + // Reset the byte count before reading so a previous successful run + // can't leak its data through Bytes() if this call errors out before + // updating cmd.n. + cmd.n = 0 + if cmd.cloned { + // A cloned ZeroCopyStringCmd has no usable destination buffer + // (see Clone for the rationale). Drain the network reply so the + // connection stays aligned for the next command, then surface a + // clear error rather than silently producing a wrong result. + if err := rd.DiscardNext(); err != nil { + return err + } + return fmt.Errorf("redis: ZeroCopyStringCmd cannot be cloned (cmd writes into caller-owned memory)") + } + n, err := rd.ReadStringInto(cmd.buf) + if err != nil { + return err + } + cmd.n = n + return nil +} + +// NoRetry returns true because the response is written directly into the +// caller's buffer. A retry could leave partial data from a failed attempt in +// the buffer, so the caller must handle retries explicitly if needed. +func (cmd *ZeroCopyStringCmd) NoRetry() bool { + return true +} + +// Clone returns a clone that is intentionally non-functional. Cloning a +// ZeroCopyStringCmd has no well-defined semantics: the cmd writes into +// caller-owned memory (the buf passed to GetToBuffer), and a clone can +// neither safely share that buf (concurrent writes from sibling clones +// would race, last-writer wins) nor allocate its own buf (the result +// would be invisible to whoever asked for the original cmd's reply). +// +// The Cmder interface requires Clone, so we return a clone marked so +// that its readReply drains the network reply (keeping the connection +// aligned) and fails the cmd with a clear error. Whoever processes the +// clone gets an explicit error instead of silently-wrong bytes. +// +// In practice this path is unreachable through normal client flows: +// Clone is only called from cluster fan-out routing +// (osscluster_router.go) for multi-shard commands like DBSIZE / KEYS / +// FLUSHDB, and ZeroCopyStringCmd is only produced by GetToBuffer which +// issues GET — a single-key command routed to one shard, never fanned +// out. Combined with NoRetry() returning true, the retry path also will +// not clone this cmd. +func (cmd *ZeroCopyStringCmd) Clone() Cmder { + return &ZeroCopyStringCmd{ + baseCmd: cmd.cloneBaseCmd(), + cloned: true, + } +} + +//------------------------------------------------------------------------------ + +type SliceCmd struct { + baseCmd + + val []interface{} +} + +var _ Cmder = (*SliceCmd)(nil) + +func NewSliceCmd(ctx context.Context, args ...interface{}) *SliceCmd { + return &SliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeSlice, + }, + } +} + +func (cmd *SliceCmd) SetVal(val []interface{}) { + cmd.val = val +} + +func (cmd *SliceCmd) Val() []interface{} { + cmd.await() + return cmd.val +} + +func (cmd *SliceCmd) Result() ([]interface{}, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *SliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +// Scan scans the results from the map into a destination struct. The map keys +// are matched in the Redis struct fields by the `redis:"field"` tag. +func (cmd *SliceCmd) Scan(dst interface{}) error { + cmd.await() + if cmd.err != nil { + return cmd.err + } + + // Pass the list of keys and values. + // Skip the first two args for: HMGET key + var args []interface{} + if cmd.args[0] == "hmget" { + args = cmd.args[2:] + } else { + // Otherwise, it's: MGET field field ... + args = cmd.args[1:] + } + + return hscan.Scan(dst, args, cmd.val) +} + +func (cmd *SliceCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadSlice() + return err +} + +func (cmd *SliceCmd) Clone() Cmder { + var val []interface{} + if cmd.val != nil { + val = make([]interface{}, len(cmd.val)) + copy(val, cmd.val) + } + return &SliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type StatusCmd struct { + baseCmd + + val string +} + +var _ Cmder = (*StatusCmd)(nil) + +func NewStatusCmd(ctx context.Context, args ...interface{}) *StatusCmd { + return &StatusCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeStatus, + }, + } +} + +func (cmd *StatusCmd) SetVal(val string) { + cmd.val = val +} + +func (cmd *StatusCmd) Val() string { + cmd.await() + return cmd.val +} + +func (cmd *StatusCmd) Result() (string, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *StatusCmd) Bytes() ([]byte, error) { + cmd.await() + return util.StringToBytes(cmd.val), cmd.err +} + +func (cmd *StatusCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *StatusCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadString() + return err +} + +func (cmd *StatusCmd) Clone() Cmder { + return &StatusCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +//------------------------------------------------------------------------------ + +type IntCmd struct { + baseCmd + + val int64 +} + +var _ Cmder = (*IntCmd)(nil) + +func NewIntCmd(ctx context.Context, args ...interface{}) *IntCmd { + return &IntCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeInt, + }, + } +} + +func (cmd *IntCmd) SetVal(val int64) { + cmd.val = val +} + +func (cmd *IntCmd) Val() int64 { + cmd.await() + return cmd.val +} + +func (cmd *IntCmd) Result() (int64, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *IntCmd) Uint64() (uint64, error) { + cmd.await() + return uint64(cmd.val), cmd.err +} + +func (cmd *IntCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *IntCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadInt() + return err +} + +func (cmd *IntCmd) Clone() Cmder { + return &IntCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +type UintCmd struct { + baseCmd + + val uint64 +} + +var _ Cmder = (*UintCmd)(nil) + +func NewUintCmd(ctx context.Context, args ...any) *UintCmd { + return &UintCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeUint, + }, + } +} + +func (cmd *UintCmd) SetVal(val uint64) { + cmd.val = val +} + +func (cmd *UintCmd) Val() uint64 { + cmd.await() + return cmd.val +} + +func (cmd *UintCmd) Result() (uint64, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *UintCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *UintCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadUint() + return err +} + +func (cmd *UintCmd) Clone() Cmder { + return &UintCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +//------------------------------------------------------------------------------ + +// DigestCmd is a command that returns a uint64 xxh3 hash digest. +// +// This command is specifically designed for the Redis DIGEST command, +// which returns the xxh3 hash of a key's value as a hex string. +// The hex string is automatically parsed to a uint64 value. +// +// The digest can be used for optimistic locking with SetIFDEQ, SetIFDNE, +// and DelExArgs commands. +// +// For examples of client-side digest generation and usage patterns, see: +// example/digest-optimistic-locking/ +// +// Redis 8.4+. See https://redis.io/commands/digest/ +type DigestCmd struct { + baseCmd + + val uint64 +} + +var _ Cmder = (*DigestCmd)(nil) + +func NewDigestCmd(ctx context.Context, args ...interface{}) *DigestCmd { + return &DigestCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *DigestCmd) SetVal(val uint64) { + cmd.val = val +} + +func (cmd *DigestCmd) Val() uint64 { + cmd.await() + return cmd.val +} + +func (cmd *DigestCmd) Result() (uint64, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *DigestCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *DigestCmd) Clone() Cmder { + return &DigestCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +func (cmd *DigestCmd) readReply(rd *proto.Reader) (err error) { + // Redis DIGEST command returns a hex string (e.g., "a1b2c3d4e5f67890") + // We parse it as a uint64 xxh3 hash value + var hexStr string + hexStr, err = rd.ReadString() + if err != nil { + return err + } + + // Parse hex string to uint64 + cmd.val, err = strconv.ParseUint(hexStr, 16, 64) + return err +} + +//------------------------------------------------------------------------------ + +type IntSliceCmd struct { + baseCmd + + val []int64 +} + +var _ Cmder = (*IntSliceCmd)(nil) + +func NewIntSliceCmd(ctx context.Context, args ...interface{}) *IntSliceCmd { + return &IntSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeIntSlice, + }, + } +} + +func (cmd *IntSliceCmd) SetVal(val []int64) { + cmd.val = val +} + +func (cmd *IntSliceCmd) Val() []int64 { + cmd.await() + return cmd.val +} + +func (cmd *IntSliceCmd) Result() ([]int64, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *IntSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *IntSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]int64, n) + for i := 0; i < len(cmd.val); i++ { + switch num, err := rd.ReadInt(); { + case err == Nil: + cmd.val[i] = 0 + case err != nil: + return err + default: + cmd.val[i] = num + } + } + return nil +} + +func (cmd *IntSliceCmd) Clone() Cmder { + var val []int64 + if cmd.val != nil { + val = make([]int64, len(cmd.val)) + copy(val, cmd.val) + } + return &IntSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +type UintSliceCmd struct { + baseCmd + + val []uint64 +} + +var _ Cmder = (*UintSliceCmd)(nil) + +func NewUintSliceCmd(ctx context.Context, args ...any) *UintSliceCmd { + return &UintSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeUintSlice, + }, + } +} + +func (cmd *UintSliceCmd) SetVal(val []uint64) { + cmd.val = val +} + +func (cmd *UintSliceCmd) Val() []uint64 { + cmd.await() + return cmd.val +} + +func (cmd *UintSliceCmd) Result() ([]uint64, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *UintSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *UintSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]uint64, n) + for i := range cmd.val { + switch num, err := rd.ReadUint(); { + case err == Nil: + cmd.val[i] = 0 + case err != nil: + return err + default: + cmd.val[i] = num + } + } + return nil +} + +func (cmd *UintSliceCmd) Clone() Cmder { + var val []uint64 + if cmd.val != nil { + val = make([]uint64, len(cmd.val)) + copy(val, cmd.val) + } + return &UintSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type DurationCmd struct { + baseCmd + + val time.Duration + precision time.Duration +} + +var _ Cmder = (*DurationCmd)(nil) + +func NewDurationCmd(ctx context.Context, precision time.Duration, args ...interface{}) *DurationCmd { + return &DurationCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeDuration, + }, + precision: precision, + } +} + +func (cmd *DurationCmd) SetVal(val time.Duration) { + cmd.val = val +} + +func (cmd *DurationCmd) Val() time.Duration { + cmd.await() + return cmd.val +} + +func (cmd *DurationCmd) Result() (time.Duration, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *DurationCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *DurationCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadInt() + if err != nil { + return err + } + switch n { + // -2 if the key does not exist + // -1 if the key exists but has no associated expire + case -2, -1: + cmd.val = time.Duration(n) + default: + cmd.val = time.Duration(n) * cmd.precision + } + return nil +} + +func (cmd *DurationCmd) Clone() Cmder { + return &DurationCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + precision: cmd.precision, + } +} + +//------------------------------------------------------------------------------ + +type TimeCmd struct { + baseCmd + + val time.Time +} + +var _ Cmder = (*TimeCmd)(nil) + +func NewTimeCmd(ctx context.Context, args ...interface{}) *TimeCmd { + return &TimeCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeTime, + }, + } +} + +func (cmd *TimeCmd) SetVal(val time.Time) { + cmd.val = val +} + +func (cmd *TimeCmd) Val() time.Time { + cmd.await() + return cmd.val +} + +func (cmd *TimeCmd) Result() (time.Time, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *TimeCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *TimeCmd) readReply(rd *proto.Reader) error { + if err := rd.ReadFixedArrayLen(2); err != nil { + return err + } + second, err := rd.ReadInt() + if err != nil { + return err + } + microsecond, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val = time.Unix(second, microsecond*1000) + return nil +} + +func (cmd *TimeCmd) Clone() Cmder { + return &TimeCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +//------------------------------------------------------------------------------ + +type BoolCmd struct { + baseCmd + + val bool +} + +var _ Cmder = (*BoolCmd)(nil) + +func NewBoolCmd(ctx context.Context, args ...interface{}) *BoolCmd { + return &BoolCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeBool, + }, + } +} + +func (cmd *BoolCmd) SetVal(val bool) { + cmd.val = val +} + +func (cmd *BoolCmd) Val() bool { + cmd.await() + return cmd.val +} + +func (cmd *BoolCmd) Result() (bool, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *BoolCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *BoolCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadBool() + + // `SET key value NX` returns nil when key already exists. But + // `SETNX key value` returns bool (0/1). So convert nil to bool. + if err == Nil { + cmd.val = false + err = nil + } + return err +} + +func (cmd *BoolCmd) Clone() Cmder { + return &BoolCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +//------------------------------------------------------------------------------ + +type StringCmd struct { + baseCmd + + val string +} + +var _ Cmder = (*StringCmd)(nil) + +func NewStringCmd(ctx context.Context, args ...interface{}) *StringCmd { + return &StringCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeString, + }, + } +} + +func (cmd *StringCmd) SetVal(val string) { + cmd.val = val +} + +func (cmd *StringCmd) Val() string { + cmd.await() + return cmd.val +} + +func (cmd *StringCmd) Result() (string, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *StringCmd) Bytes() ([]byte, error) { + cmd.await() + return util.StringToBytes(cmd.val), cmd.err +} + +func (cmd *StringCmd) Bool() (bool, error) { + cmd.await() + if cmd.err != nil { + return false, cmd.err + } + return strconv.ParseBool(cmd.val) +} + +func (cmd *StringCmd) Int() (int, error) { + cmd.await() + if cmd.err != nil { + return 0, cmd.err + } + return strconv.Atoi(cmd.val) +} + +func (cmd *StringCmd) Int64() (int64, error) { + cmd.await() + if cmd.err != nil { + return 0, cmd.err + } + return strconv.ParseInt(cmd.val, 10, 64) +} + +func (cmd *StringCmd) Uint64() (uint64, error) { + cmd.await() + if cmd.err != nil { + return 0, cmd.err + } + return strconv.ParseUint(cmd.val, 10, 64) +} + +func (cmd *StringCmd) Float32() (float32, error) { + cmd.await() + if cmd.err != nil { + return 0, cmd.err + } + f, err := strconv.ParseFloat(cmd.val, 32) + if err != nil { + return 0, err + } + return float32(f), nil +} + +func (cmd *StringCmd) Float64() (float64, error) { + cmd.await() + if cmd.err != nil { + return 0, cmd.err + } + return strconv.ParseFloat(cmd.val, 64) +} + +func (cmd *StringCmd) Time() (time.Time, error) { + cmd.await() + if cmd.err != nil { + return time.Time{}, cmd.err + } + return time.Parse(time.RFC3339Nano, cmd.val) +} + +func (cmd *StringCmd) Scan(val interface{}) error { + cmd.await() + if cmd.err != nil { + return cmd.err + } + return proto.Scan([]byte(cmd.val), val) +} + +func (cmd *StringCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *StringCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadString() + return err +} + +func (cmd *StringCmd) Clone() Cmder { + return &StringCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +//------------------------------------------------------------------------------ + +type FloatCmd struct { + baseCmd + + val float64 +} + +var _ Cmder = (*FloatCmd)(nil) + +func NewFloatCmd(ctx context.Context, args ...interface{}) *FloatCmd { + return &FloatCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeFloat, + }, + } +} + +func (cmd *FloatCmd) SetVal(val float64) { + cmd.val = val +} + +func (cmd *FloatCmd) Val() float64 { + cmd.await() + return cmd.val +} + +func (cmd *FloatCmd) Result() (float64, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *FloatCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *FloatCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = rd.ReadFloat() + return err +} + +func (cmd *FloatCmd) Clone() Cmder { + return &FloatCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +//------------------------------------------------------------------------------ + +type FloatSliceCmd struct { + baseCmd + + val []float64 +} + +var _ Cmder = (*FloatSliceCmd)(nil) + +func NewFloatSliceCmd(ctx context.Context, args ...interface{}) *FloatSliceCmd { + return &FloatSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeFloatSlice, + }, + } +} + +func (cmd *FloatSliceCmd) SetVal(val []float64) { + cmd.val = val +} + +func (cmd *FloatSliceCmd) Val() []float64 { + cmd.await() + return cmd.val +} + +func (cmd *FloatSliceCmd) Result() ([]float64, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *FloatSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *FloatSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.val = make([]float64, n) + for i := 0; i < len(cmd.val); i++ { + switch num, err := rd.ReadFloat(); { + case err == Nil: + cmd.val[i] = 0 + case err != nil: + return err + default: + cmd.val[i] = num + } + } + return nil +} + +func (cmd *FloatSliceCmd) Clone() Cmder { + var val []float64 + if cmd.val != nil { + val = make([]float64, len(cmd.val)) + copy(val, cmd.val) + } + return &FloatSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type StringSliceCmd struct { + baseCmd + + val []string +} + +var _ Cmder = (*StringSliceCmd)(nil) + +func NewStringSliceCmd(ctx context.Context, args ...interface{}) *StringSliceCmd { + return &StringSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeStringSlice, + }, + } +} + +func (cmd *StringSliceCmd) SetVal(val []string) { + cmd.val = val +} + +func (cmd *StringSliceCmd) Val() []string { + cmd.await() + return cmd.val +} + +func (cmd *StringSliceCmd) Result() ([]string, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *StringSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *StringSliceCmd) ScanSlice(container interface{}) error { + cmd.await() + return proto.ScanSlice(cmd.val, container) +} + +func (cmd *StringSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]string, n) + for i := 0; i < len(cmd.val); i++ { + switch s, err := rd.ReadString(); { + case err == Nil: + cmd.val[i] = "" + case err != nil: + return err + default: + cmd.val[i] = s + } + } + return nil +} + +func (cmd *StringSliceCmd) Clone() Cmder { + var val []string + if cmd.val != nil { + val = make([]string, len(cmd.val)) + copy(val, cmd.val) + } + return &StringSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +// StringSliceSliceCmd returns a slice of string slices ([][]string). +// This is used for commands like VLINKS that return an array of arrays. +type StringSliceSliceCmd struct { + baseCmd + + val [][]string +} + +var _ Cmder = (*StringSliceSliceCmd)(nil) + +func NewStringSliceSliceCmd(ctx context.Context, args ...any) *StringSliceSliceCmd { + return &StringSliceSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *StringSliceSliceCmd) SetVal(val [][]string) { + cmd.val = val +} + +func (cmd *StringSliceSliceCmd) Val() [][]string { + cmd.await() + return cmd.val +} + +func (cmd *StringSliceSliceCmd) Result() ([][]string, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *StringSliceSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *StringSliceSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([][]string, n) + for i := range n { + // Read inner array + innerN, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val[i] = make([]string, innerN) + for j := range innerN { + switch s, err := rd.ReadString(); { + case err == Nil: + cmd.val[i][j] = "" + case err != nil: + return err + default: + cmd.val[i][j] = s + } + } + } + return nil +} + +func (cmd *StringSliceSliceCmd) Clone() Cmder { + var val [][]string + if cmd.val != nil { + val = make([][]string, len(cmd.val)) + for i, slice := range cmd.val { + if slice != nil { + val[i] = make([]string, len(slice)) + copy(val[i], slice) + } + } + } + return &StringSliceSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type KeyValue struct { + Key string + Value string +} + +type KeyValueSliceCmd struct { + baseCmd + + val []KeyValue +} + +var _ Cmder = (*KeyValueSliceCmd)(nil) + +func NewKeyValueSliceCmd(ctx context.Context, args ...interface{}) *KeyValueSliceCmd { + return &KeyValueSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeKeyValueSlice, + }, + } +} + +func (cmd *KeyValueSliceCmd) SetVal(val []KeyValue) { + cmd.val = val +} + +func (cmd *KeyValueSliceCmd) Val() []KeyValue { + cmd.await() + return cmd.val +} + +func (cmd *KeyValueSliceCmd) Result() ([]KeyValue, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *KeyValueSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +// Many commands will respond to two formats: +// 1. 1) "one" +// 2. (double) 1 +// 2. 1) "two" +// 2. (double) 2 +// +// OR: +// 1. "two" +// 2. (double) 2 +// 3. "one" +// 4. (double) 1 +func (cmd *KeyValueSliceCmd) readReply(rd *proto.Reader) error { // nolint:dupl + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + // If the n is 0, can't continue reading. + if n == 0 { + cmd.val = make([]KeyValue, 0) + return nil + } + + typ, err := rd.PeekReplyType() + if err != nil { + return err + } + array := typ == proto.RespArray + + if array { + cmd.val = make([]KeyValue, n) + } else { + if n%2 != 0 { + return fmt.Errorf("redis: got %d elements in the key-value array, wanted a multiple of 2", n) + } + cmd.val = make([]KeyValue, n/2) + } + + for i := 0; i < len(cmd.val); i++ { + if array { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + } + + if cmd.val[i].Key, err = rd.ReadString(); err != nil { + return err + } + + if cmd.val[i].Value, err = rd.ReadString(); err != nil { + return err + } + } + + return nil +} + +func (cmd *KeyValueSliceCmd) Clone() Cmder { + var val []KeyValue + if cmd.val != nil { + val = make([]KeyValue, len(cmd.val)) + copy(val, cmd.val) + } + return &KeyValueSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type BoolSliceCmd struct { + baseCmd + + val []bool +} + +var _ Cmder = (*BoolSliceCmd)(nil) + +func NewBoolSliceCmd(ctx context.Context, args ...interface{}) *BoolSliceCmd { + return &BoolSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeBoolSlice, + }, + } +} + +func (cmd *BoolSliceCmd) SetVal(val []bool) { + cmd.val = val +} + +func (cmd *BoolSliceCmd) Val() []bool { + cmd.await() + return cmd.val +} + +func (cmd *BoolSliceCmd) Result() ([]bool, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *BoolSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *BoolSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]bool, n) + for i := 0; i < len(cmd.val); i++ { + switch b, err := rd.ReadBool(); { + case err == Nil: + cmd.val[i] = false + case err != nil: + return err + default: + cmd.val[i] = b + } + } + return nil +} + +func (cmd *BoolSliceCmd) Clone() Cmder { + var val []bool + if cmd.val != nil { + val = make([]bool, len(cmd.val)) + copy(val, cmd.val) + } + return &BoolSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type MapStringStringCmd struct { + baseCmd + + val map[string]string +} + +var _ Cmder = (*MapStringStringCmd)(nil) + +func NewMapStringStringCmd(ctx context.Context, args ...interface{}) *MapStringStringCmd { + return &MapStringStringCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeMapStringString, + }, + } +} + +func (cmd *MapStringStringCmd) Val() map[string]string { + cmd.await() + return cmd.val +} + +func (cmd *MapStringStringCmd) SetVal(val map[string]string) { + cmd.val = val +} + +func (cmd *MapStringStringCmd) Result() (map[string]string, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *MapStringStringCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +// Scan scans the results from the map into a destination struct. The map keys +// are matched in the Redis struct fields by the `redis:"field"` tag. +func (cmd *MapStringStringCmd) Scan(dest interface{}) error { + cmd.await() + if cmd.err != nil { + return cmd.err + } + + strct, err := hscan.Struct(dest) + if err != nil { + return err + } + + for k, v := range cmd.val { + if err := strct.Scan(k, v); err != nil { + return err + } + } + + return nil +} + +func (cmd *MapStringStringCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + + cmd.val = make(map[string]string, n) + for i := 0; i < n; i++ { + key, err := rd.ReadString() + if err != nil { + return err + } + + value, err := rd.ReadString() + if err != nil { + return err + } + + cmd.val[key] = value + } + return nil +} + +func (cmd *MapStringStringCmd) Clone() Cmder { + var val map[string]string + if cmd.val != nil { + val = make(map[string]string, len(cmd.val)) + for k, v := range cmd.val { + val[k] = v + } + } + return &MapStringStringCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type MapStringIntCmd struct { + baseCmd + + val map[string]int64 +} + +var _ Cmder = (*MapStringIntCmd)(nil) + +func NewMapStringIntCmd(ctx context.Context, args ...interface{}) *MapStringIntCmd { + return &MapStringIntCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeMapStringInt, + }, + } +} + +func (cmd *MapStringIntCmd) SetVal(val map[string]int64) { + cmd.val = val +} + +func (cmd *MapStringIntCmd) Val() map[string]int64 { + cmd.await() + return cmd.val +} + +func (cmd *MapStringIntCmd) Result() (map[string]int64, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *MapStringIntCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *MapStringIntCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + + cmd.val = make(map[string]int64, n) + for i := 0; i < n; i++ { + key, err := rd.ReadString() + if err != nil { + return err + } + + nn, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val[key] = nn + } + return nil +} + +func (cmd *MapStringIntCmd) Clone() Cmder { + var val map[string]int64 + if cmd.val != nil { + val = make(map[string]int64, len(cmd.val)) + for k, v := range cmd.val { + val[k] = v + } + } + return &MapStringIntCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +// ------------------------------------------------------------------------------ +type MapStringSliceInterfaceCmd struct { + baseCmd + val map[string][]interface{} +} + +func NewMapStringSliceInterfaceCmd(ctx context.Context, args ...interface{}) *MapStringSliceInterfaceCmd { + return &MapStringSliceInterfaceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeMapStringInterfaceSlice, + }, + } +} + +func (cmd *MapStringSliceInterfaceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *MapStringSliceInterfaceCmd) SetVal(val map[string][]interface{}) { + cmd.val = val +} + +func (cmd *MapStringSliceInterfaceCmd) Result() (map[string][]interface{}, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *MapStringSliceInterfaceCmd) Val() map[string][]interface{} { + cmd.await() + return cmd.val +} + +func (cmd *MapStringSliceInterfaceCmd) readReply(rd *proto.Reader) (err error) { + readType, err := rd.PeekReplyType() + if err != nil { + return err + } + + cmd.val = make(map[string][]interface{}) + + switch readType { + case proto.RespMap: + n, err := rd.ReadMapLen() + if err != nil { + return err + } + for i := 0; i < n; i++ { + k, err := rd.ReadString() + if err != nil { + return err + } + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val[k] = make([]interface{}, nn) + for j := 0; j < nn; j++ { + value, err := rd.ReadReply() + if err != nil { + return err + } + cmd.val[k][j] = value + } + } + case proto.RespArray: + // RESP2 response + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + for i := 0; i < n; i++ { + // Each entry in this array is itself an array with key details + itemLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + + key, err := rd.ReadString() + if err != nil { + return err + } + cmd.val[key] = make([]interface{}, 0, itemLen-1) + for j := 1; j < itemLen; j++ { + // Read the inner array for timestamp-value pairs + data, err := rd.ReadReply() + if err != nil { + return err + } + cmd.val[key] = append(cmd.val[key], data) + } + } + default: + // Any other reply type leaves the peeked frame unread. Returning nil + // here would put the connection back in the pool with those bytes + // buffered, so the next command reads them as its own reply. + return fmt.Errorf("redis: can't parse map-string-slice-interface reply: unexpected type %c", readType) + } + + return nil +} + +func (cmd *MapStringSliceInterfaceCmd) Clone() Cmder { + var val map[string][]interface{} + if cmd.val != nil { + val = make(map[string][]interface{}, len(cmd.val)) + for k, v := range cmd.val { + if v != nil { + newSlice := make([]interface{}, len(v)) + copy(newSlice, v) + val[k] = newSlice + } + } + } + return &MapStringSliceInterfaceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type StringStructMapCmd struct { + baseCmd + + val map[string]struct{} +} + +var _ Cmder = (*StringStructMapCmd)(nil) + +func NewStringStructMapCmd(ctx context.Context, args ...interface{}) *StringStructMapCmd { + return &StringStructMapCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeStringStructMap, + }, + } +} + +func (cmd *StringStructMapCmd) SetVal(val map[string]struct{}) { + cmd.val = val +} + +func (cmd *StringStructMapCmd) Val() map[string]struct{} { + cmd.await() + return cmd.val +} + +func (cmd *StringStructMapCmd) Result() (map[string]struct{}, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *StringStructMapCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *StringStructMapCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.val = make(map[string]struct{}, n) + for i := 0; i < n; i++ { + key, err := rd.ReadString() + if err != nil { + return err + } + cmd.val[key] = struct{}{} + } + return nil +} + +func (cmd *StringStructMapCmd) Clone() Cmder { + var val map[string]struct{} + if cmd.val != nil { + val = maps.Clone(cmd.val) + } + return &StringStructMapCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type XMessage struct { + ID string + Values map[string]interface{} + // MillisElapsedFromDelivery is the number of milliseconds since the entry was last delivered. + // Only populated when using XREADGROUP with CLAIM argument for claimed entries. + MillisElapsedFromDelivery int64 + // DeliveredCount is the number of times the entry was delivered. + // Only populated when using XREADGROUP with CLAIM argument for claimed entries. + DeliveredCount int64 +} + +type XMessageSliceCmd struct { + baseCmd + + val []XMessage +} + +var _ Cmder = (*XMessageSliceCmd)(nil) + +func NewXMessageSliceCmd(ctx context.Context, args ...interface{}) *XMessageSliceCmd { + return &XMessageSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeXMessageSlice, + }, + } +} + +func (cmd *XMessageSliceCmd) SetVal(val []XMessage) { + cmd.val = val +} + +func (cmd *XMessageSliceCmd) Val() []XMessage { + cmd.await() + return cmd.val +} + +func (cmd *XMessageSliceCmd) Result() ([]XMessage, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *XMessageSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XMessageSliceCmd) readReply(rd *proto.Reader) (err error) { + cmd.val, err = readXMessageSlice(rd) + return err +} + +func (cmd *XMessageSliceCmd) Clone() Cmder { + var val []XMessage + if cmd.val != nil { + val = make([]XMessage, len(cmd.val)) + for i, msg := range cmd.val { + val[i] = XMessage{ + ID: msg.ID, + } + if msg.Values != nil { + val[i].Values = maps.Clone(msg.Values) + } + } + } + return &XMessageSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +func readXMessageSlice(rd *proto.Reader) ([]XMessage, error) { + n, err := rd.ReadArrayLen() + if err != nil { + return nil, err + } + + msgs := make([]XMessage, n) + for i := 0; i < len(msgs); i++ { + if msgs[i], err = readXMessage(rd); err != nil { + return nil, err + } + } + return msgs, nil +} + +func readXMessage(rd *proto.Reader) (XMessage, error) { + // Read array length can be 2 or 4 (with CLAIM metadata) + n, err := rd.ReadArrayLen() + if err != nil { + return XMessage{}, err + } + + if n != 2 && n != 4 { + return XMessage{}, fmt.Errorf("redis: got %d elements in the XMessage array, expected 2 or 4", n) + } + + id, err := rd.ReadString() + if err != nil { + return XMessage{}, err + } + + v, err := stringInterfaceMapParser(rd) + if err != nil { + if err != proto.Nil { + return XMessage{}, err + } + } + + msg := XMessage{ + ID: id, + Values: v, + } + + if n == 4 { + msg.MillisElapsedFromDelivery, err = rd.ReadInt() + if err != nil { + return XMessage{}, err + } + + msg.DeliveredCount, err = rd.ReadInt() + if err != nil { + return XMessage{}, err + } + } + + return msg, nil +} + +func stringInterfaceMapParser(rd *proto.Reader) (map[string]interface{}, error) { + n, err := rd.ReadMapLen() + if err != nil { + return nil, err + } + + m := make(map[string]interface{}, n) + for i := 0; i < n; i++ { + key, err := rd.ReadString() + if err != nil { + return nil, err + } + + value, err := rd.ReadString() + if err != nil { + return nil, err + } + + m[key] = value + } + return m, nil +} + +//------------------------------------------------------------------------------ + +type XStream struct { + Stream string + Messages []XMessage +} + +type XStreamSliceCmd struct { + baseCmd + + val []XStream +} + +var _ Cmder = (*XStreamSliceCmd)(nil) + +func NewXStreamSliceCmd(ctx context.Context, args ...interface{}) *XStreamSliceCmd { + return &XStreamSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeXStreamSlice, + }, + } +} + +func (cmd *XStreamSliceCmd) SetVal(val []XStream) { + cmd.val = val +} + +func (cmd *XStreamSliceCmd) Val() []XStream { + cmd.await() + return cmd.val +} + +func (cmd *XStreamSliceCmd) Result() ([]XStream, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *XStreamSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XStreamSliceCmd) readReply(rd *proto.Reader) error { + typ, err := rd.PeekReplyType() + if err != nil { + return err + } + + var n int + if typ == proto.RespMap { + n, err = rd.ReadMapLen() + } else { + n, err = rd.ReadArrayLen() + } + if err != nil { + return err + } + cmd.val = make([]XStream, n) + for i := 0; i < len(cmd.val); i++ { + if typ != proto.RespMap { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + } + if cmd.val[i].Stream, err = rd.ReadString(); err != nil { + return err + } + if cmd.val[i].Messages, err = readXMessageSlice(rd); err != nil { + return err + } + } + return nil +} + +func (cmd *XStreamSliceCmd) Clone() Cmder { + var val []XStream + if cmd.val != nil { + val = make([]XStream, len(cmd.val)) + for i, stream := range cmd.val { + val[i] = XStream{ + Stream: stream.Stream, + } + if stream.Messages != nil { + val[i].Messages = make([]XMessage, len(stream.Messages)) + for j, msg := range stream.Messages { + val[i].Messages[j] = XMessage{ + ID: msg.ID, + } + if msg.Values != nil { + val[i].Messages[j].Values = make(map[string]interface{}, len(msg.Values)) + for k, v := range msg.Values { + val[i].Messages[j].Values[k] = v + } + } + } + } + } + } + return &XStreamSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type XPending struct { + Count int64 + Lower string + Higher string + Consumers map[string]int64 +} + +type XPendingCmd struct { + baseCmd + val *XPending +} + +var _ Cmder = (*XPendingCmd)(nil) + +func NewXPendingCmd(ctx context.Context, args ...interface{}) *XPendingCmd { + return &XPendingCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeXPending, + }, + } +} + +func (cmd *XPendingCmd) SetVal(val *XPending) { + cmd.val = val +} + +func (cmd *XPendingCmd) Val() *XPending { + cmd.await() + return cmd.val +} + +func (cmd *XPendingCmd) Result() (*XPending, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *XPendingCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XPendingCmd) readReply(rd *proto.Reader) error { + var err error + if err = rd.ReadFixedArrayLen(4); err != nil { + return err + } + cmd.val = &XPending{} + + if cmd.val.Count, err = rd.ReadInt(); err != nil { + return err + } + + if cmd.val.Lower, err = rd.ReadString(); err != nil && err != Nil { + return err + } + + if cmd.val.Higher, err = rd.ReadString(); err != nil && err != Nil { + return err + } + + n, err := rd.ReadArrayLen() + if err != nil && err != Nil { + return err + } + cmd.val.Consumers = make(map[string]int64, n) + for i := 0; i < n; i++ { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + + consumerName, err := rd.ReadString() + if err != nil { + return err + } + consumerPending, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val.Consumers[consumerName] = consumerPending + } + return nil +} + +func (cmd *XPendingCmd) Clone() Cmder { + var val *XPending + if cmd.val != nil { + val = &XPending{ + Count: cmd.val.Count, + Lower: cmd.val.Lower, + Higher: cmd.val.Higher, + } + if cmd.val.Consumers != nil { + val.Consumers = make(map[string]int64, len(cmd.val.Consumers)) + for k, v := range cmd.val.Consumers { + val.Consumers[k] = v + } + } + } + return &XPendingCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type XPendingExt struct { + ID string + Consumer string + Idle time.Duration + RetryCount int64 +} + +type XPendingExtCmd struct { + baseCmd + val []XPendingExt +} + +var _ Cmder = (*XPendingExtCmd)(nil) + +func NewXPendingExtCmd(ctx context.Context, args ...interface{}) *XPendingExtCmd { + return &XPendingExtCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeXPendingExt, + }, + } +} + +func (cmd *XPendingExtCmd) SetVal(val []XPendingExt) { + cmd.val = val +} + +func (cmd *XPendingExtCmd) Val() []XPendingExt { + cmd.await() + return cmd.val +} + +func (cmd *XPendingExtCmd) Result() ([]XPendingExt, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *XPendingExtCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XPendingExtCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]XPendingExt, n) + + for i := 0; i < len(cmd.val); i++ { + if err = rd.ReadFixedArrayLen(4); err != nil { + return err + } + + if cmd.val[i].ID, err = rd.ReadString(); err != nil { + return err + } + + if cmd.val[i].Consumer, err = rd.ReadString(); err != nil && err != Nil { + return err + } + + idle, err := rd.ReadInt() + if err != nil && err != Nil { + return err + } + cmd.val[i].Idle = time.Duration(idle) * time.Millisecond + + if cmd.val[i].RetryCount, err = rd.ReadInt(); err != nil && err != Nil { + return err + } + } + + return nil +} + +func (cmd *XPendingExtCmd) Clone() Cmder { + var val []XPendingExt + if cmd.val != nil { + val = make([]XPendingExt, len(cmd.val)) + copy(val, cmd.val) + } + return &XPendingExtCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type XAutoClaimCmd struct { + baseCmd + + start string + val []XMessage +} + +var _ Cmder = (*XAutoClaimCmd)(nil) + +func NewXAutoClaimCmd(ctx context.Context, args ...interface{}) *XAutoClaimCmd { + return &XAutoClaimCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeXAutoClaim, + }, + } +} + +func (cmd *XAutoClaimCmd) SetVal(val []XMessage, start string) { + cmd.val = val + cmd.start = start +} + +func (cmd *XAutoClaimCmd) Val() (messages []XMessage, start string) { + cmd.await() + return cmd.val, cmd.start +} + +func (cmd *XAutoClaimCmd) Result() (messages []XMessage, start string, err error) { + cmd.await() + return cmd.val, cmd.start, cmd.err +} + +func (cmd *XAutoClaimCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XAutoClaimCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + switch n { + case 2, // Redis 6 + 3: // Redis 7: + // ok + default: + return fmt.Errorf("redis: got %d elements in XAutoClaim reply, wanted 2/3", n) + } + + cmd.start, err = rd.ReadString() + if err != nil { + return err + } + + cmd.val, err = readXMessageSlice(rd) + if err != nil { + return err + } + + if n >= 3 { + return rd.DiscardNext() + } + + return nil +} + +func (cmd *XAutoClaimCmd) Clone() Cmder { + var val []XMessage + if cmd.val != nil { + val = make([]XMessage, len(cmd.val)) + for i, msg := range cmd.val { + val[i] = XMessage{ + ID: msg.ID, + } + if msg.Values != nil { + val[i].Values = make(map[string]interface{}, len(msg.Values)) + for k, v := range msg.Values { + val[i].Values[k] = v + } + } + } + } + return &XAutoClaimCmd{ + baseCmd: cmd.cloneBaseCmd(), + start: cmd.start, + val: val, + } +} + +//------------------------------------------------------------------------------ + +type XAutoClaimWithDeletedCmd struct { + baseCmd + + start string + val []XMessage + deletedIDs []string +} + +var _ Cmder = (*XAutoClaimWithDeletedCmd)(nil) + +func NewXAutoClaimWithDeletedCmd(ctx context.Context, args ...interface{}) *XAutoClaimWithDeletedCmd { + return &XAutoClaimWithDeletedCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeXAutoClaimWithDeleted, + }, + } +} + +func (cmd *XAutoClaimWithDeletedCmd) SetVal(val []XMessage, start string, deletedIDs []string) { + cmd.val = val + cmd.start = start + cmd.deletedIDs = deletedIDs +} + +func (cmd *XAutoClaimWithDeletedCmd) Val() (messages []XMessage, start string, deletedIDs []string) { + cmd.await() + return cmd.val, cmd.start, cmd.deletedIDs +} + +func (cmd *XAutoClaimWithDeletedCmd) Result() (messages []XMessage, start string, deletedIDs []string, err error) { + cmd.await() + return cmd.val, cmd.start, cmd.deletedIDs, cmd.err +} + +func (cmd *XAutoClaimWithDeletedCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XAutoClaimWithDeletedCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + switch n { + case 2, // Redis 6 + 3: // Redis 7: + // ok + default: + return fmt.Errorf("redis: got %d elements in XAutoClaim reply, wanted 2/3", n) + } + + cmd.start, err = rd.ReadString() + if err != nil { + return err + } + + cmd.val, err = readXMessageSlice(rd) + if err != nil { + return err + } + + if n < 3 { + return nil + } + + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.deletedIDs = make([]string, nn) + for i := 0; i < nn; i++ { + cmd.deletedIDs[i], err = rd.ReadString() + if err != nil { + return err + } + } + + return nil +} + +func (cmd *XAutoClaimWithDeletedCmd) Clone() Cmder { + var val []XMessage + if cmd.val != nil { + val = make([]XMessage, len(cmd.val)) + for i, msg := range cmd.val { + val[i] = XMessage{ + ID: msg.ID, + } + if msg.Values != nil { + val[i].Values = make(map[string]interface{}, len(msg.Values)) + for k, v := range msg.Values { + val[i].Values[k] = v + } + } + } + } + var deletedIDs []string + if cmd.deletedIDs != nil { + deletedIDs = make([]string, len(cmd.deletedIDs)) + copy(deletedIDs, cmd.deletedIDs) + } + return &XAutoClaimWithDeletedCmd{ + baseCmd: cmd.cloneBaseCmd(), + start: cmd.start, + val: val, + deletedIDs: deletedIDs, + } +} + +//------------------------------------------------------------------------------ + +type XAutoClaimJustIDCmd struct { + baseCmd + + start string + val []string +} + +var _ Cmder = (*XAutoClaimJustIDCmd)(nil) + +func NewXAutoClaimJustIDCmd(ctx context.Context, args ...interface{}) *XAutoClaimJustIDCmd { + return &XAutoClaimJustIDCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeXAutoClaimJustID, + }, + } +} + +func (cmd *XAutoClaimJustIDCmd) SetVal(val []string, start string) { + cmd.val = val + cmd.start = start +} + +func (cmd *XAutoClaimJustIDCmd) Val() (ids []string, start string) { + cmd.await() + return cmd.val, cmd.start +} + +func (cmd *XAutoClaimJustIDCmd) Result() (ids []string, start string, err error) { + cmd.await() + return cmd.val, cmd.start, cmd.err +} + +func (cmd *XAutoClaimJustIDCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XAutoClaimJustIDCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + switch n { + case 2, // Redis 6 + 3: // Redis 7: + // ok + default: + return fmt.Errorf("redis: got %d elements in XAutoClaimJustID reply, wanted 2/3", n) + } + + cmd.start, err = rd.ReadString() + if err != nil { + return err + } + + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.val = make([]string, nn) + for i := 0; i < nn; i++ { + cmd.val[i], err = rd.ReadString() + if err != nil { + return err + } + } + + if n >= 3 { + if err := rd.DiscardNext(); err != nil { + return err + } + } + + return nil +} + +func (cmd *XAutoClaimJustIDCmd) Clone() Cmder { + var val []string + if cmd.val != nil { + val = make([]string, len(cmd.val)) + copy(val, cmd.val) + } + return &XAutoClaimJustIDCmd{ + baseCmd: cmd.cloneBaseCmd(), + start: cmd.start, + val: val, + } +} + +//------------------------------------------------------------------------------ + +type XInfoConsumersCmd struct { + baseCmd + val []XInfoConsumer +} + +type XInfoConsumer struct { + Name string + Pending int64 + Idle time.Duration + Inactive time.Duration +} + +var _ Cmder = (*XInfoConsumersCmd)(nil) + +func NewXInfoConsumersCmd(ctx context.Context, stream string, group string) *XInfoConsumersCmd { + return &XInfoConsumersCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: []interface{}{"xinfo", "consumers", stream, group}, + cmdType: CmdTypeXInfoConsumers, + }, + } +} + +func (cmd *XInfoConsumersCmd) SetVal(val []XInfoConsumer) { + cmd.val = val +} + +func (cmd *XInfoConsumersCmd) Val() []XInfoConsumer { + cmd.await() + return cmd.val +} + +func (cmd *XInfoConsumersCmd) Result() ([]XInfoConsumer, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *XInfoConsumersCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XInfoConsumersCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]XInfoConsumer, n) + + for i := 0; i < len(cmd.val); i++ { + nn, err := rd.ReadMapLen() + if err != nil { + return err + } + + var key string + for f := 0; f < nn; f++ { + key, err = rd.ReadString() + if err != nil { + return err + } + + switch key { + case "name": + cmd.val[i].Name, err = rd.ReadString() + case "pending": + cmd.val[i].Pending, err = rd.ReadInt() + case "idle": + var idle int64 + idle, err = rd.ReadInt() + cmd.val[i].Idle = time.Duration(idle) * time.Millisecond + case "inactive": + var inactive int64 + inactive, err = rd.ReadInt() + cmd.val[i].Inactive = time.Duration(inactive) * time.Millisecond + default: + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return err + } + } + if err != nil { + return err + } + } + } + + return nil +} + +func (cmd *XInfoConsumersCmd) Clone() Cmder { + var val []XInfoConsumer + if cmd.val != nil { + val = make([]XInfoConsumer, len(cmd.val)) + copy(val, cmd.val) + } + return &XInfoConsumersCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type XInfoGroupsCmd struct { + baseCmd + val []XInfoGroup +} + +type XInfoGroup struct { + Name string + Consumers int64 + Pending int64 + LastDeliveredID string + EntriesRead int64 + // Lag represents the number of pending messages in the stream not yet + // delivered to this consumer group. Returns -1 when the lag cannot be determined. + Lag int64 +} + +var _ Cmder = (*XInfoGroupsCmd)(nil) + +func NewXInfoGroupsCmd(ctx context.Context, stream string) *XInfoGroupsCmd { + return &XInfoGroupsCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: []interface{}{"xinfo", "groups", stream}, + cmdType: CmdTypeXInfoGroups, + }, + } +} + +func (cmd *XInfoGroupsCmd) SetVal(val []XInfoGroup) { + cmd.val = val +} + +func (cmd *XInfoGroupsCmd) Val() []XInfoGroup { + cmd.await() + return cmd.val +} + +func (cmd *XInfoGroupsCmd) Result() ([]XInfoGroup, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *XInfoGroupsCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XInfoGroupsCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]XInfoGroup, n) + + for i := 0; i < len(cmd.val); i++ { + group := &cmd.val[i] + + nn, err := rd.ReadMapLen() + if err != nil { + return err + } + + var key string + for j := 0; j < nn; j++ { + key, err = rd.ReadString() + if err != nil { + return err + } + + switch key { + case "name": + group.Name, err = rd.ReadString() + if err != nil { + return err + } + case "consumers": + group.Consumers, err = rd.ReadInt() + if err != nil { + return err + } + case "pending": + group.Pending, err = rd.ReadInt() + if err != nil { + return err + } + case "last-delivered-id": + group.LastDeliveredID, err = rd.ReadString() + if err != nil { + return err + } + case "entries-read": + group.EntriesRead, err = rd.ReadInt() + if err != nil && err != Nil { + return err + } + case "lag": + group.Lag, err = rd.ReadInt() + + // lag: the number of entries in the stream that are still waiting to be delivered + // to the group's consumers, or a NULL(Nil) when that number can't be determined. + // In that case, we return -1. + if err != nil && err != Nil { + return err + } else if err == Nil { + group.Lag = -1 + } + default: + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return err + } + } + } + } + + return nil +} + +func (cmd *XInfoGroupsCmd) Clone() Cmder { + var val []XInfoGroup + if cmd.val != nil { + val = make([]XInfoGroup, len(cmd.val)) + copy(val, cmd.val) + } + return &XInfoGroupsCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type XInfoStreamCmd struct { + baseCmd + val *XInfoStream +} + +type XInfoStream struct { + Length int64 + RadixTreeKeys int64 + RadixTreeNodes int64 + Groups int64 + LastGeneratedID string + MaxDeletedEntryID string + EntriesAdded int64 + FirstEntry XMessage + LastEntry XMessage + RecordedFirstEntryID string + + IDMPDuration int64 + IDMPMaxSize int64 + PIDsTracked int64 + IIDsTracked int64 + IIDsAdded int64 + IIDsDuplicates int64 +} + +var _ Cmder = (*XInfoStreamCmd)(nil) + +func NewXInfoStreamCmd(ctx context.Context, stream string) *XInfoStreamCmd { + return &XInfoStreamCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: []interface{}{"xinfo", "stream", stream}, + cmdType: CmdTypeXInfoStream, + }, + } +} + +func (cmd *XInfoStreamCmd) SetVal(val *XInfoStream) { + cmd.val = val +} + +func (cmd *XInfoStreamCmd) Val() *XInfoStream { + cmd.await() + return cmd.val +} + +func (cmd *XInfoStreamCmd) Result() (*XInfoStream, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *XInfoStreamCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XInfoStreamCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + cmd.val = &XInfoStream{} + + for i := 0; i < n; i++ { + key, err := rd.ReadString() + if err != nil { + return err + } + switch key { + case "length": + cmd.val.Length, err = rd.ReadInt() + if err != nil { + return err + } + case "radix-tree-keys": + cmd.val.RadixTreeKeys, err = rd.ReadInt() + if err != nil { + return err + } + case "radix-tree-nodes": + cmd.val.RadixTreeNodes, err = rd.ReadInt() + if err != nil { + return err + } + case "groups": + cmd.val.Groups, err = rd.ReadInt() + if err != nil { + return err + } + case "last-generated-id": + cmd.val.LastGeneratedID, err = rd.ReadString() + if err != nil { + return err + } + case "max-deleted-entry-id": + cmd.val.MaxDeletedEntryID, err = rd.ReadString() + if err != nil { + return err + } + case "entries-added": + cmd.val.EntriesAdded, err = rd.ReadInt() + if err != nil { + return err + } + case "first-entry": + cmd.val.FirstEntry, err = readXMessage(rd) + if err != nil && err != Nil { + return err + } + case "last-entry": + cmd.val.LastEntry, err = readXMessage(rd) + if err != nil && err != Nil { + return err + } + case "recorded-first-entry-id": + cmd.val.RecordedFirstEntryID, err = rd.ReadString() + if err != nil { + return err + } + case "idmp-duration": + cmd.val.IDMPDuration, err = rd.ReadInt() + if err != nil { + return err + } + case "idmp-maxsize": + cmd.val.IDMPMaxSize, err = rd.ReadInt() + if err != nil { + return err + } + case "pids-tracked": + cmd.val.PIDsTracked, err = rd.ReadInt() + if err != nil { + return err + } + case "iids-tracked": + cmd.val.IIDsTracked, err = rd.ReadInt() + if err != nil { + return err + } + case "iids-added": + cmd.val.IIDsAdded, err = rd.ReadInt() + if err != nil { + return err + } + case "iids-duplicates": + cmd.val.IIDsDuplicates, err = rd.ReadInt() + if err != nil { + return err + } + default: + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return err + } + } + } + return nil +} + +func (cmd *XInfoStreamCmd) Clone() Cmder { + var val *XInfoStream + if cmd.val != nil { + val = &XInfoStream{ + Length: cmd.val.Length, + RadixTreeKeys: cmd.val.RadixTreeKeys, + RadixTreeNodes: cmd.val.RadixTreeNodes, + Groups: cmd.val.Groups, + LastGeneratedID: cmd.val.LastGeneratedID, + MaxDeletedEntryID: cmd.val.MaxDeletedEntryID, + EntriesAdded: cmd.val.EntriesAdded, + RecordedFirstEntryID: cmd.val.RecordedFirstEntryID, + } + // Clone XMessage fields + val.FirstEntry = XMessage{ + ID: cmd.val.FirstEntry.ID, + } + if cmd.val.FirstEntry.Values != nil { + val.FirstEntry.Values = make(map[string]interface{}, len(cmd.val.FirstEntry.Values)) + for k, v := range cmd.val.FirstEntry.Values { + val.FirstEntry.Values[k] = v + } + } + val.LastEntry = XMessage{ + ID: cmd.val.LastEntry.ID, + } + if cmd.val.LastEntry.Values != nil { + val.LastEntry.Values = make(map[string]interface{}, len(cmd.val.LastEntry.Values)) + for k, v := range cmd.val.LastEntry.Values { + val.LastEntry.Values[k] = v + } + } + } + return &XInfoStreamCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type XInfoStreamFullCmd struct { + baseCmd + val *XInfoStreamFull +} + +type XInfoStreamFull struct { + Length int64 + RadixTreeKeys int64 + RadixTreeNodes int64 + LastGeneratedID string + MaxDeletedEntryID string + EntriesAdded int64 + Entries []XMessage + Groups []XInfoStreamGroup + RecordedFirstEntryID string + IDMPDuration int64 + IDMPMaxSize int64 + PIDsTracked int64 + IIDsTracked int64 + IIDsAdded int64 + IIDsDuplicates int64 +} + +type XInfoStreamGroup struct { + Name string + LastDeliveredID string + EntriesRead int64 + Lag int64 + PelCount int64 + NackedCount uint64 // redis version 8.8, number of NACK'd messages in the group + Pending []XInfoStreamGroupPending + Consumers []XInfoStreamConsumer +} + +type XInfoStreamGroupPending struct { + ID string + Consumer string + DeliveryTime time.Time + DeliveryCount int64 +} + +type XInfoStreamConsumer struct { + Name string + SeenTime time.Time + ActiveTime time.Time + PelCount int64 + Pending []XInfoStreamConsumerPending +} + +type XInfoStreamConsumerPending struct { + ID string + DeliveryTime time.Time + DeliveryCount int64 +} + +var _ Cmder = (*XInfoStreamFullCmd)(nil) + +func NewXInfoStreamFullCmd(ctx context.Context, args ...interface{}) *XInfoStreamFullCmd { + return &XInfoStreamFullCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeXInfoStreamFull, + }, + } +} + +func (cmd *XInfoStreamFullCmd) SetVal(val *XInfoStreamFull) { + cmd.val = val +} + +func (cmd *XInfoStreamFullCmd) Val() *XInfoStreamFull { + cmd.await() + return cmd.val +} + +func (cmd *XInfoStreamFullCmd) Result() (*XInfoStreamFull, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *XInfoStreamFullCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *XInfoStreamFullCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + + cmd.val = &XInfoStreamFull{} + + for i := 0; i < n; i++ { + key, err := rd.ReadString() + if err != nil { + return err + } + + switch key { + case "length": + cmd.val.Length, err = rd.ReadInt() + if err != nil { + return err + } + case "radix-tree-keys": + cmd.val.RadixTreeKeys, err = rd.ReadInt() + if err != nil { + return err + } + case "radix-tree-nodes": + cmd.val.RadixTreeNodes, err = rd.ReadInt() + if err != nil { + return err + } + case "last-generated-id": + cmd.val.LastGeneratedID, err = rd.ReadString() + if err != nil { + return err + } + case "entries-added": + cmd.val.EntriesAdded, err = rd.ReadInt() + if err != nil { + return err + } + case "entries": + cmd.val.Entries, err = readXMessageSlice(rd) + if err != nil { + return err + } + case "groups": + cmd.val.Groups, err = readStreamGroups(rd) + if err != nil { + return err + } + case "max-deleted-entry-id": + cmd.val.MaxDeletedEntryID, err = rd.ReadString() + if err != nil { + return err + } + case "recorded-first-entry-id": + cmd.val.RecordedFirstEntryID, err = rd.ReadString() + if err != nil { + return err + } + case "idmp-duration": + cmd.val.IDMPDuration, err = rd.ReadInt() + if err != nil { + return err + } + case "idmp-maxsize": + cmd.val.IDMPMaxSize, err = rd.ReadInt() + if err != nil { + return err + } + case "pids-tracked": + cmd.val.PIDsTracked, err = rd.ReadInt() + if err != nil { + return err + } + case "iids-tracked": + cmd.val.IIDsTracked, err = rd.ReadInt() + if err != nil { + return err + } + case "iids-added": + cmd.val.IIDsAdded, err = rd.ReadInt() + if err != nil { + return err + } + case "iids-duplicates": + cmd.val.IIDsDuplicates, err = rd.ReadInt() + if err != nil { + return err + } + default: + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return err + } + } + } + return nil +} + +func readStreamGroups(rd *proto.Reader) ([]XInfoStreamGroup, error) { + n, err := rd.ReadArrayLen() + if err != nil { + return nil, err + } + groups := make([]XInfoStreamGroup, 0, n) + for i := 0; i < n; i++ { + nn, err := rd.ReadMapLen() + if err != nil { + return nil, err + } + + group := XInfoStreamGroup{} + + for j := 0; j < nn; j++ { + key, err := rd.ReadString() + if err != nil { + return nil, err + } + + switch key { + case "name": + group.Name, err = rd.ReadString() + if err != nil { + return nil, err + } + case "last-delivered-id": + group.LastDeliveredID, err = rd.ReadString() + if err != nil { + return nil, err + } + case "entries-read": + group.EntriesRead, err = rd.ReadInt() + if err != nil && err != Nil { + return nil, err + } + case "lag": + // lag: the number of entries in the stream that are still waiting to be delivered + // to the group's consumers, or a NULL(Nil) when that number can't be determined. + group.Lag, err = rd.ReadInt() + if err != nil && err != Nil { + return nil, err + } + case "pel-count": + group.PelCount, err = rd.ReadInt() + if err != nil { + return nil, err + } + case "nacked-count": + group.NackedCount, err = rd.ReadUint() + if err != nil { + return nil, err + } + case "pending": + group.Pending, err = readXInfoStreamGroupPending(rd) + if err != nil { + return nil, err + } + case "consumers": + group.Consumers, err = readXInfoStreamConsumers(rd) + if err != nil { + return nil, err + } + default: + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return nil, err + } + } + } + + groups = append(groups, group) + } + + return groups, nil +} + +func readXInfoStreamGroupPending(rd *proto.Reader) ([]XInfoStreamGroupPending, error) { + n, err := rd.ReadArrayLen() + if err != nil { + return nil, err + } + + pending := make([]XInfoStreamGroupPending, 0, n) + + for i := 0; i < n; i++ { + if err = rd.ReadFixedArrayLen(4); err != nil { + return nil, err + } + + p := XInfoStreamGroupPending{} + + p.ID, err = rd.ReadString() + if err != nil { + return nil, err + } + + p.Consumer, err = rd.ReadString() + if err != nil { + return nil, err + } + + delivery, err := rd.ReadInt() + if err != nil { + return nil, err + } + p.DeliveryTime = time.Unix(delivery/1000, delivery%1000*int64(time.Millisecond)) + + p.DeliveryCount, err = rd.ReadInt() + if err != nil { + return nil, err + } + + pending = append(pending, p) + } + + return pending, nil +} + +func readXInfoStreamConsumers(rd *proto.Reader) ([]XInfoStreamConsumer, error) { + n, err := rd.ReadArrayLen() + if err != nil { + return nil, err + } + + consumers := make([]XInfoStreamConsumer, 0, n) + + for i := 0; i < n; i++ { + nn, err := rd.ReadMapLen() + if err != nil { + return nil, err + } + + c := XInfoStreamConsumer{} + + for f := 0; f < nn; f++ { + cKey, err := rd.ReadString() + if err != nil { + return nil, err + } + + switch cKey { + case "name": + c.Name, err = rd.ReadString() + case "seen-time": + seen, err := rd.ReadInt() + if err != nil { + return nil, err + } + c.SeenTime = time.UnixMilli(seen) + case "active-time": + active, err := rd.ReadInt() + if err != nil { + return nil, err + } + c.ActiveTime = time.UnixMilli(active) + case "pel-count": + c.PelCount, err = rd.ReadInt() + case "pending": + pendingNumber, err := rd.ReadArrayLen() + if err != nil { + return nil, err + } + + c.Pending = make([]XInfoStreamConsumerPending, 0, pendingNumber) + + for pn := 0; pn < pendingNumber; pn++ { + if err = rd.ReadFixedArrayLen(3); err != nil { + return nil, err + } + + p := XInfoStreamConsumerPending{} + + p.ID, err = rd.ReadString() + if err != nil { + return nil, err + } + + delivery, err := rd.ReadInt() + if err != nil { + return nil, err + } + p.DeliveryTime = time.Unix(delivery/1000, delivery%1000*int64(time.Millisecond)) + + p.DeliveryCount, err = rd.ReadInt() + if err != nil { + return nil, err + } + + c.Pending = append(c.Pending, p) + } + default: + // skip unknown fields + if err = rd.DiscardNext(); err != nil { + return nil, err + } + } + if err != nil { + return nil, err + } + } + consumers = append(consumers, c) + } + + return consumers, nil +} + +func (cmd *XInfoStreamFullCmd) Clone() Cmder { + var val *XInfoStreamFull + if cmd.val != nil { + val = &XInfoStreamFull{ + Length: cmd.val.Length, + RadixTreeKeys: cmd.val.RadixTreeKeys, + RadixTreeNodes: cmd.val.RadixTreeNodes, + LastGeneratedID: cmd.val.LastGeneratedID, + MaxDeletedEntryID: cmd.val.MaxDeletedEntryID, + EntriesAdded: cmd.val.EntriesAdded, + RecordedFirstEntryID: cmd.val.RecordedFirstEntryID, + } + // Clone Entries + if cmd.val.Entries != nil { + val.Entries = make([]XMessage, len(cmd.val.Entries)) + for i, msg := range cmd.val.Entries { + val.Entries[i] = XMessage{ + ID: msg.ID, + } + if msg.Values != nil { + val.Entries[i].Values = make(map[string]interface{}, len(msg.Values)) + for k, v := range msg.Values { + val.Entries[i].Values[k] = v + } + } + } + } + // Clone Groups - simplified copy for now due to complexity + if cmd.val.Groups != nil { + val.Groups = make([]XInfoStreamGroup, len(cmd.val.Groups)) + copy(val.Groups, cmd.val.Groups) + } + } + return &XInfoStreamFullCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type ZSliceCmd struct { + baseCmd + + val []Z +} + +var _ Cmder = (*ZSliceCmd)(nil) + +func NewZSliceCmd(ctx context.Context, args ...interface{}) *ZSliceCmd { + return &ZSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeZSlice, + }, + } +} + +func (cmd *ZSliceCmd) SetVal(val []Z) { + cmd.val = val +} + +func (cmd *ZSliceCmd) Val() []Z { + cmd.await() + return cmd.val +} + +func (cmd *ZSliceCmd) Result() ([]Z, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *ZSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *ZSliceCmd) readReply(rd *proto.Reader) error { // nolint:dupl + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + // If the n is 0, can't continue reading. + if n == 0 { + cmd.val = make([]Z, 0) + return nil + } + + typ, err := rd.PeekReplyType() + if err != nil { + return err + } + array := typ == proto.RespArray + + if array { + cmd.val = make([]Z, n) + } else { + if n%2 != 0 { + return fmt.Errorf("redis: got %d elements in the sorted set array, wanted a multiple of 2", n) + } + cmd.val = make([]Z, n/2) + } + + for i := 0; i < len(cmd.val); i++ { + if array { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + } + + if cmd.val[i].Member, err = rd.ReadString(); err != nil { + return err + } + + if cmd.val[i].Score, err = rd.ReadFloat(); err != nil { + return err + } + } + + return nil +} + +func (cmd *ZSliceCmd) Clone() Cmder { + var val []Z + if cmd.val != nil { + val = make([]Z, len(cmd.val)) + copy(val, cmd.val) + } + return &ZSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type ZWithKeyCmd struct { + baseCmd + + val *ZWithKey +} + +var _ Cmder = (*ZWithKeyCmd)(nil) + +func NewZWithKeyCmd(ctx context.Context, args ...interface{}) *ZWithKeyCmd { + return &ZWithKeyCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeZWithKey, + }, + } +} + +func (cmd *ZWithKeyCmd) SetVal(val *ZWithKey) { + cmd.val = val +} + +func (cmd *ZWithKeyCmd) Val() *ZWithKey { + cmd.await() + return cmd.val +} + +func (cmd *ZWithKeyCmd) Result() (*ZWithKey, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *ZWithKeyCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *ZWithKeyCmd) readReply(rd *proto.Reader) (err error) { + if err = rd.ReadFixedArrayLen(3); err != nil { + return err + } + cmd.val = &ZWithKey{} + + if cmd.val.Key, err = rd.ReadString(); err != nil { + return err + } + if cmd.val.Member, err = rd.ReadString(); err != nil { + return err + } + if cmd.val.Score, err = rd.ReadFloat(); err != nil { + return err + } + + return nil +} + +func (cmd *ZWithKeyCmd) Clone() Cmder { + var val *ZWithKey + if cmd.val != nil { + val = &ZWithKey{ + Z: Z{ + Score: cmd.val.Score, + Member: cmd.val.Member, + }, + Key: cmd.val.Key, + } + } + return &ZWithKeyCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type ScanCmd struct { + baseCmd + + page []string + cursor uint64 + + process cmdable +} + +var _ Cmder = (*ScanCmd)(nil) + +func NewScanCmd(ctx context.Context, process cmdable, args ...interface{}) *ScanCmd { + return &ScanCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeScan, + }, + process: process, + } +} + +func (cmd *ScanCmd) SetVal(page []string, cursor uint64) { + cmd.page = page + cmd.cursor = cursor +} + +func (cmd *ScanCmd) Val() (keys []string, cursor uint64) { + cmd.await() + return cmd.page, cmd.cursor +} + +func (cmd *ScanCmd) Result() (keys []string, cursor uint64, err error) { + cmd.await() + return cmd.page, cmd.cursor, cmd.err +} + +func (cmd *ScanCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.page) +} + +func (cmd *ScanCmd) readReply(rd *proto.Reader) error { + if err := rd.ReadFixedArrayLen(2); err != nil { + return err + } + + cursor, err := rd.ReadUint() + if err != nil { + return err + } + cmd.cursor = cursor + + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.page = make([]string, n) + + for i := 0; i < len(cmd.page); i++ { + if cmd.page[i], err = rd.ReadString(); err != nil { + return err + } + } + return nil +} + +func (cmd *ScanCmd) Clone() Cmder { + var page []string + if cmd.page != nil { + page = make([]string, len(cmd.page)) + copy(page, cmd.page) + } + return &ScanCmd{ + baseCmd: cmd.cloneBaseCmd(), + page: page, + cursor: cmd.cursor, + process: cmd.process, + } +} + +// Iterator creates a new ScanIterator. +func (cmd *ScanCmd) Iterator() *ScanIterator { + return &ScanIterator{ + cmd: cmd, + } +} + +//------------------------------------------------------------------------------ + +type ClusterNode struct { + ID string + Addr string + NetworkingMetadata map[string]string +} + +type ClusterSlot struct { + Start int + End int + Nodes []ClusterNode +} + +type ClusterSlotsCmd struct { + baseCmd + + val []ClusterSlot +} + +var _ Cmder = (*ClusterSlotsCmd)(nil) + +func NewClusterSlotsCmd(ctx context.Context, args ...interface{}) *ClusterSlotsCmd { + return &ClusterSlotsCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeClusterSlots, + }, + } +} + +func (cmd *ClusterSlotsCmd) SetVal(val []ClusterSlot) { + cmd.val = val +} + +func (cmd *ClusterSlotsCmd) Val() []ClusterSlot { + cmd.await() + return cmd.val +} + +func (cmd *ClusterSlotsCmd) Result() ([]ClusterSlot, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *ClusterSlotsCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *ClusterSlotsCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]ClusterSlot, n) + + for i := 0; i < len(cmd.val); i++ { + n, err = rd.ReadArrayLen() + if err != nil { + return err + } + if n < 2 { + return fmt.Errorf("redis: got %d elements in cluster info, expected at least 2", n) + } + + start, err := rd.ReadInt() + if err != nil { + return err + } + + end, err := rd.ReadInt() + if err != nil { + return err + } + + // subtract start and end. + nodes := make([]ClusterNode, n-2) + + for j := 0; j < len(nodes); j++ { + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + if nn < 2 || nn > 4 { + return fmt.Errorf("got %d elements in cluster info address, expected 2, 3, or 4", n) + } + + ip, err := rd.ReadString() + if err != nil { + return err + } + + port, err := rd.ReadString() + if err != nil { + return err + } + + nodes[j].Addr = net.JoinHostPort(ip, port) + + if nn >= 3 { + id, err := rd.ReadString() + if err != nil { + return err + } + nodes[j].ID = id + } + + if nn >= 4 { + metadataLength, err := rd.ReadMapLen() + if err != nil { + return err + } + + networkingMetadata := make(map[string]string, metadataLength) + + for i := 0; i < metadataLength; i++ { + key, err := rd.ReadString() + if err != nil { + return err + } + value, err := rd.ReadString() + if err != nil { + return err + } + networkingMetadata[key] = value + } + + nodes[j].NetworkingMetadata = networkingMetadata + } + } + + cmd.val[i] = ClusterSlot{ + Start: int(start), + End: int(end), + Nodes: nodes, + } + } + + return nil +} + +func (cmd *ClusterSlotsCmd) Clone() Cmder { + var val []ClusterSlot + if cmd.val != nil { + val = make([]ClusterSlot, len(cmd.val)) + for i, slot := range cmd.val { + val[i] = ClusterSlot{ + Start: slot.Start, + End: slot.End, + } + if slot.Nodes != nil { + val[i].Nodes = make([]ClusterNode, len(slot.Nodes)) + for j, node := range slot.Nodes { + val[i].Nodes[j] = ClusterNode{ + ID: node.ID, + Addr: node.Addr, + } + if node.NetworkingMetadata != nil { + val[i].Nodes[j].NetworkingMetadata = make(map[string]string, len(node.NetworkingMetadata)) + for k, v := range node.NetworkingMetadata { + val[i].Nodes[j].NetworkingMetadata[k] = v + } + } + } + } + } + } + return &ClusterSlotsCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +// GeoLocation is used with GeoAdd to add geospatial location. +type GeoLocation struct { + Name string + Longitude, Latitude, Dist float64 + GeoHash int64 +} + +// GeoRadiusQuery is used with GeoRadius to query geospatial index. +type GeoRadiusQuery struct { + Radius float64 + // Can be m, km, ft, or mi. Default is km. + Unit string + WithCoord bool + WithDist bool + WithGeoHash bool + Count int + // Can be ASC or DESC. Default is no sort order. + Sort string + Store string + StoreDist string + + // WithCoord+WithDist+WithGeoHash + withLen int +} + +type GeoLocationCmd struct { + baseCmd + + q *GeoRadiusQuery + locations []GeoLocation +} + +var _ Cmder = (*GeoLocationCmd)(nil) + +func NewGeoLocationCmd(ctx context.Context, q *GeoRadiusQuery, args ...interface{}) *GeoLocationCmd { + return &GeoLocationCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: geoLocationArgs(q, args...), + cmdType: CmdTypeGeoLocation, + }, + q: q, + } +} + +func geoLocationArgs(q *GeoRadiusQuery, args ...interface{}) []interface{} { + args = append(args, q.Radius) + if q.Unit != "" { + args = append(args, q.Unit) + } else { + args = append(args, "km") + } + if q.WithCoord { + args = append(args, "withcoord") + q.withLen++ + } + if q.WithDist { + args = append(args, "withdist") + q.withLen++ + } + if q.WithGeoHash { + args = append(args, "withhash") + q.withLen++ + } + if q.Count > 0 { + args = append(args, "count", q.Count) + } + if q.Sort != "" { + args = append(args, q.Sort) + } + if q.Store != "" { + args = append(args, "store") + args = append(args, q.Store) + } + if q.StoreDist != "" { + args = append(args, "storedist") + args = append(args, q.StoreDist) + } + return args +} + +func (cmd *GeoLocationCmd) SetVal(locations []GeoLocation) { + cmd.locations = locations +} + +func (cmd *GeoLocationCmd) Val() []GeoLocation { + cmd.await() + return cmd.locations +} + +func (cmd *GeoLocationCmd) Result() ([]GeoLocation, error) { + cmd.await() + return cmd.locations, cmd.err +} + +func (cmd *GeoLocationCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.locations) +} + +func (cmd *GeoLocationCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.locations = make([]GeoLocation, n) + + for i := 0; i < len(cmd.locations); i++ { + // only name + if cmd.q.withLen == 0 { + if cmd.locations[i].Name, err = rd.ReadString(); err != nil { + return err + } + continue + } + + // +name + if err = rd.ReadFixedArrayLen(cmd.q.withLen + 1); err != nil { + return err + } + + if cmd.locations[i].Name, err = rd.ReadString(); err != nil { + return err + } + if cmd.q.WithDist { + if cmd.locations[i].Dist, err = rd.ReadFloat(); err != nil { + return err + } + } + if cmd.q.WithGeoHash { + if cmd.locations[i].GeoHash, err = rd.ReadInt(); err != nil { + return err + } + } + if cmd.q.WithCoord { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + if cmd.locations[i].Longitude, err = rd.ReadFloat(); err != nil { + return err + } + if cmd.locations[i].Latitude, err = rd.ReadFloat(); err != nil { + return err + } + } + } + + return nil +} + +func (cmd *GeoLocationCmd) Clone() Cmder { + var q *GeoRadiusQuery + if cmd.q != nil { + q = &GeoRadiusQuery{ + Radius: cmd.q.Radius, + Unit: cmd.q.Unit, + WithCoord: cmd.q.WithCoord, + WithDist: cmd.q.WithDist, + WithGeoHash: cmd.q.WithGeoHash, + Count: cmd.q.Count, + Sort: cmd.q.Sort, + Store: cmd.q.Store, + StoreDist: cmd.q.StoreDist, + withLen: cmd.q.withLen, + } + } + var locations []GeoLocation + if cmd.locations != nil { + locations = make([]GeoLocation, len(cmd.locations)) + copy(locations, cmd.locations) + } + return &GeoLocationCmd{ + baseCmd: cmd.cloneBaseCmd(), + q: q, + locations: locations, + } +} + +//------------------------------------------------------------------------------ + +// GeoSearchQuery is used for GEOSearch/GEOSearchStore command query. +type GeoSearchQuery struct { + Member string + + // Latitude and Longitude when using FromLonLat option. + Longitude float64 + Latitude float64 + + // Distance and unit when using ByRadius option. + // Can use m, km, ft, or mi. Default is km. + Radius float64 + RadiusUnit string + + // Height, width and unit when using ByBox option. + // Can be m, km, ft, or mi. Default is km. + BoxWidth float64 + BoxHeight float64 + BoxUnit string + + // Can be ASC or DESC. Default is no sort order. + Sort string + Count int + CountAny bool +} + +type GeoSearchLocationQuery struct { + GeoSearchQuery + + WithCoord bool + WithDist bool + WithHash bool +} + +type GeoSearchStoreQuery struct { + GeoSearchQuery + + // When using the StoreDist option, the command stores the items in a + // sorted set populated with their distance from the center of the circle or box, + // as a floating-point number, in the same unit specified for that shape. + StoreDist bool +} + +func geoSearchLocationArgs(q *GeoSearchLocationQuery, args []interface{}) []interface{} { + args = geoSearchArgs(&q.GeoSearchQuery, args) + + if q.WithCoord { + args = append(args, "withcoord") + } + if q.WithDist { + args = append(args, "withdist") + } + if q.WithHash { + args = append(args, "withhash") + } + + return args +} + +func geoSearchArgs(q *GeoSearchQuery, args []interface{}) []interface{} { + if q.Member != "" { + args = append(args, "frommember", q.Member) + } else { + args = append(args, "fromlonlat", q.Longitude, q.Latitude) + } + + if q.Radius > 0 { + if q.RadiusUnit == "" { + q.RadiusUnit = "km" + } + args = append(args, "byradius", q.Radius, q.RadiusUnit) + } else { + if q.BoxUnit == "" { + q.BoxUnit = "km" + } + args = append(args, "bybox", q.BoxWidth, q.BoxHeight, q.BoxUnit) + } + + if q.Sort != "" { + args = append(args, q.Sort) + } + + if q.Count > 0 { + args = append(args, "count", q.Count) + if q.CountAny { + args = append(args, "any") + } + } + + return args +} + +type GeoSearchLocationCmd struct { + baseCmd + + opt *GeoSearchLocationQuery + val []GeoLocation +} + +var _ Cmder = (*GeoSearchLocationCmd)(nil) + +func NewGeoSearchLocationCmd( + ctx context.Context, opt *GeoSearchLocationQuery, args ...interface{}, +) *GeoSearchLocationCmd { + return &GeoSearchLocationCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: geoSearchLocationArgs(opt, args), + cmdType: CmdTypeGeoSearchLocation, + }, + opt: opt, + } +} + +func (cmd *GeoSearchLocationCmd) SetVal(val []GeoLocation) { + cmd.val = val +} + +func (cmd *GeoSearchLocationCmd) Val() []GeoLocation { + cmd.await() + return cmd.val +} + +func (cmd *GeoSearchLocationCmd) Result() ([]GeoLocation, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *GeoSearchLocationCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *GeoSearchLocationCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.val = make([]GeoLocation, n) + // Each element is an array of [name, ...] whose minimum length is set by + // the requested WITH flags. Entries shorter than that would make the + // parser read into the next reply; extra elements are drained below so a + // longer entry (e.g. from a newer server) can't leave frames on the wire. + withLen := 1 + if cmd.opt.WithDist { + withLen++ + } + if cmd.opt.WithHash { + withLen++ + } + if cmd.opt.WithCoord { + withLen++ + } + for i := 0; i < n; i++ { + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + if nn < withLen { + return fmt.Errorf("redis: got %d elements in GEOSEARCH reply, expected at least %d", nn, withLen) + } + + var loc GeoLocation + + loc.Name, err = rd.ReadString() + if err != nil { + return err + } + if cmd.opt.WithDist { + loc.Dist, err = rd.ReadFloat() + if err != nil { + return err + } + } + if cmd.opt.WithHash { + loc.GeoHash, err = rd.ReadInt() + if err != nil { + return err + } + } + if cmd.opt.WithCoord { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + loc.Longitude, err = rd.ReadFloat() + if err != nil { + return err + } + loc.Latitude, err = rd.ReadFloat() + if err != nil { + return err + } + } + for j := withLen; j < nn; j++ { + if err := rd.DiscardNext(); err != nil { + return err + } + } + + cmd.val[i] = loc + } + + return nil +} + +func (cmd *GeoSearchLocationCmd) Clone() Cmder { + var opt *GeoSearchLocationQuery + if cmd.opt != nil { + opt = &GeoSearchLocationQuery{ + GeoSearchQuery: GeoSearchQuery{ + Member: cmd.opt.Member, + Longitude: cmd.opt.Longitude, + Latitude: cmd.opt.Latitude, + Radius: cmd.opt.Radius, + RadiusUnit: cmd.opt.RadiusUnit, + BoxWidth: cmd.opt.BoxWidth, + BoxHeight: cmd.opt.BoxHeight, + BoxUnit: cmd.opt.BoxUnit, + Sort: cmd.opt.Sort, + Count: cmd.opt.Count, + CountAny: cmd.opt.CountAny, + }, + WithCoord: cmd.opt.WithCoord, + WithDist: cmd.opt.WithDist, + WithHash: cmd.opt.WithHash, + } + } + var val []GeoLocation + if cmd.val != nil { + val = make([]GeoLocation, len(cmd.val)) + copy(val, cmd.val) + } + return &GeoSearchLocationCmd{ + baseCmd: cmd.cloneBaseCmd(), + opt: opt, + val: val, + } +} + +//------------------------------------------------------------------------------ + +type GeoPos struct { + Longitude, Latitude float64 +} + +type GeoPosCmd struct { + baseCmd + + val []*GeoPos +} + +var _ Cmder = (*GeoPosCmd)(nil) + +func NewGeoPosCmd(ctx context.Context, args ...interface{}) *GeoPosCmd { + return &GeoPosCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeGeoPos, + }, + } +} + +func (cmd *GeoPosCmd) SetVal(val []*GeoPos) { + cmd.val = val +} + +func (cmd *GeoPosCmd) Val() []*GeoPos { + cmd.await() + return cmd.val +} + +func (cmd *GeoPosCmd) Result() ([]*GeoPos, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *GeoPosCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *GeoPosCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]*GeoPos, n) + + for i := 0; i < len(cmd.val); i++ { + err = rd.ReadFixedArrayLen(2) + if err != nil { + if err == Nil { + cmd.val[i] = nil + continue + } + return err + } + + longitude, err := rd.ReadFloat() + if err != nil { + return err + } + latitude, err := rd.ReadFloat() + if err != nil { + return err + } + + cmd.val[i] = &GeoPos{ + Longitude: longitude, + Latitude: latitude, + } + } + + return nil +} + +func (cmd *GeoPosCmd) Clone() Cmder { + var val []*GeoPos + if cmd.val != nil { + val = make([]*GeoPos, len(cmd.val)) + for i, pos := range cmd.val { + if pos != nil { + val[i] = &GeoPos{ + Longitude: pos.Longitude, + Latitude: pos.Latitude, + } + } + } + } + return &GeoPosCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type CommandInfo struct { + Name string + Arity int8 + Flags []string + ACLFlags []string + FirstKeyPos int8 + LastKeyPos int8 + StepCount int8 + ReadOnly bool + CommandPolicy *routing.CommandPolicy +} + +type CommandsInfoCmd struct { + baseCmd + + val map[string]*CommandInfo +} + +var _ Cmder = (*CommandsInfoCmd)(nil) + +func NewCommandsInfoCmd(ctx context.Context, args ...interface{}) *CommandsInfoCmd { + return &CommandsInfoCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeCommandsInfo, + }, + } +} + +func (cmd *CommandsInfoCmd) SetVal(val map[string]*CommandInfo) { + cmd.val = val +} + +func (cmd *CommandsInfoCmd) Val() map[string]*CommandInfo { + cmd.await() + return cmd.val +} + +func (cmd *CommandsInfoCmd) Result() (map[string]*CommandInfo, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *CommandsInfoCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *CommandsInfoCmd) readReply(rd *proto.Reader) error { + const numArgRedis5 = 6 + const numArgRedis6 = 7 + const numArgRedis7 = 10 // Also matches redis 8 + + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make(map[string]*CommandInfo, n) + + for i := 0; i < n; i++ { + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + + switch nn { + case numArgRedis5, numArgRedis6, numArgRedis7: + // ok + default: + return fmt.Errorf("redis: got %d elements in COMMAND reply, wanted 6/7/10", nn) + } + + cmdInfo := &CommandInfo{} + if cmdInfo.Name, err = rd.ReadString(); err != nil { + return err + } + + arity, err := rd.ReadInt() + if err != nil { + return err + } + cmdInfo.Arity = int8(arity) + + flagLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmdInfo.Flags = make([]string, flagLen) + for f := 0; f < len(cmdInfo.Flags); f++ { + switch s, err := rd.ReadString(); { + case err == Nil: + cmdInfo.Flags[f] = "" + case err != nil: + return err + default: + if !cmdInfo.ReadOnly && s == "readonly" { + cmdInfo.ReadOnly = true + } + cmdInfo.Flags[f] = s + } + } + + firstKeyPos, err := rd.ReadInt() + if err != nil { + return err + } + cmdInfo.FirstKeyPos = int8(firstKeyPos) + + lastKeyPos, err := rd.ReadInt() + if err != nil { + return err + } + cmdInfo.LastKeyPos = int8(lastKeyPos) + + stepCount, err := rd.ReadInt() + if err != nil { + return err + } + cmdInfo.StepCount = int8(stepCount) + + if nn >= numArgRedis6 { + aclFlagLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmdInfo.ACLFlags = make([]string, aclFlagLen) + for f := 0; f < len(cmdInfo.ACLFlags); f++ { + switch s, err := rd.ReadString(); { + case err == Nil: + cmdInfo.ACLFlags[f] = "" + case err != nil: + return err + default: + cmdInfo.ACLFlags[f] = s + } + } + } + + if nn >= numArgRedis7 { + // The 8th argument is an array of tips. + tipsLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + + rawTips := make(map[string]string, tipsLen) + if cmdInfo.ReadOnly { + rawTips[routing.ReadOnlyCMD] = "" + } + for f := 0; f < tipsLen; f++ { + tip, err := rd.ReadString() + if err != nil { + return err + } + + k, v, ok := strings.Cut(tip, ":") + if !ok { + // Handle tips that don't have a colon (like "nondeterministic_output") + rawTips[tip] = "" + } else { + // Handle normal key:value tips + rawTips[k] = v + } + } + cmdInfo.CommandPolicy = parseCommandPolicies(rawTips, cmdInfo.FirstKeyPos) + + if err := rd.DiscardNext(); err != nil { + return err + } + if err := rd.DiscardNext(); err != nil { + return err + } + } + + cmd.val[cmdInfo.Name] = cmdInfo + } + + return nil +} + +func (cmd *CommandsInfoCmd) Clone() Cmder { + var val map[string]*CommandInfo + if cmd.val != nil { + val = make(map[string]*CommandInfo, len(cmd.val)) + for k, v := range cmd.val { + if v != nil { + newInfo := &CommandInfo{ + Name: v.Name, + Arity: v.Arity, + FirstKeyPos: v.FirstKeyPos, + LastKeyPos: v.LastKeyPos, + StepCount: v.StepCount, + ReadOnly: v.ReadOnly, + CommandPolicy: v.CommandPolicy, // CommandPolicy can be shared as it's immutable + } + if v.Flags != nil { + newInfo.Flags = make([]string, len(v.Flags)) + copy(newInfo.Flags, v.Flags) + } + if v.ACLFlags != nil { + newInfo.ACLFlags = make([]string, len(v.ACLFlags)) + copy(newInfo.ACLFlags, v.ACLFlags) + } + val[k] = newInfo + } + } + } + return &CommandsInfoCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type cmdsInfoCache struct { + fn func(ctx context.Context) (map[string]*CommandInfo, error) + + once internal.Once + refreshLock sync.RWMutex + cmds map[string]*CommandInfo + // cmdsAtomic mirrors cmds for lock-free reads via Peek. cmds is only ever + // replaced wholesale (never mutated in place), so an atomic pointer load is a + // safe, contention-free read — Peek is on the hot per-command cluster routing + // path where the RWMutex.RLock showed up as a bottleneck under heavy load. + cmdsAtomic atomic.Pointer[map[string]*CommandInfo] +} + +func newCmdsInfoCache(fn func(ctx context.Context) (map[string]*CommandInfo, error)) *cmdsInfoCache { + return &cmdsInfoCache{ + fn: fn, + } +} + +func (c *cmdsInfoCache) Get(ctx context.Context) (map[string]*CommandInfo, error) { + c.refreshLock.Lock() + defer c.refreshLock.Unlock() + + err := c.once.Do(func() error { + cmds, err := c.fn(ctx) + if err != nil { + return err + } + + lowerCmds := make(map[string]*CommandInfo, len(cmds)) + + // Extensions have cmd names in upper case. Convert them to lower case. + for k, v := range cmds { + lowerCmds[internal.ToLower(k)] = v + } + + c.cmds = lowerCmds + c.cmdsAtomic.Store(&lowerCmds) + return nil + }) + return c.cmds, err +} + +func (c *cmdsInfoCache) Refresh() { + c.refreshLock.Lock() + defer c.refreshLock.Unlock() + + c.once = internal.Once{} +} + +// Peek returns the cached CommandInfo map without triggering a Redis round-trip. +// Returns nil when the cache is cold; callers should fall back to other heuristics. +// The read is lock-free (a single atomic load) and never blocks, even while a +// concurrent Get() is populating the cache — it simply returns nil until the +// first population publishes the map. +// The returned map and its entries MUST NOT be mutated by the caller. +func (c *cmdsInfoCache) Peek() map[string]*CommandInfo { + if c == nil { + return nil + } + // Lock-free read: cmds is replaced wholesale, never mutated in place. + if p := c.cmdsAtomic.Load(); p != nil { + return *p + } + return nil +} + +// ------------------------------------------------------------------------------ +const ( + requestPolicy = "request_policy" + responsePolicy = "response_policy" +) + +func parseCommandPolicies(commandInfoTips map[string]string, firstKeyPos int8) *routing.CommandPolicy { + req := routing.ReqDefault + resp := routing.RespDefaultKeyless + if firstKeyPos > 0 { + resp = routing.RespDefaultHashSlot + } + + tips := make(map[string]string, len(commandInfoTips)) + for k, v := range commandInfoTips { + if k == requestPolicy { + if p, err := routing.ParseRequestPolicy(v); err == nil { + req = p + } + continue + } + if k == responsePolicy { + if p, err := routing.ParseResponsePolicy(v); err == nil { + resp = p + } + continue + } + tips[k] = v + } + + return &routing.CommandPolicy{Request: req, Response: resp, Tips: tips} +} + +//------------------------------------------------------------------------------ + +type SlowLog struct { + ID int64 + Time time.Time + Duration time.Duration + Args []string + // These are also optional fields emitted only by Redis 4.0 or greater: + // https://redis.io/commands/slowlog#output-format + ClientAddr string + ClientName string + // CommandArgc is the command's total argument count (including the command + // name), emitted only by Redis 8.10 or greater. It may exceed len(Args) when + // the slow log truncates the stored arguments (slowlog-max-argc, default 32). + CommandArgc int64 +} + +type SlowLogCmd struct { + baseCmd + + val []SlowLog +} + +var _ Cmder = (*SlowLogCmd)(nil) + +func NewSlowLogCmd(ctx context.Context, args ...interface{}) *SlowLogCmd { + return &SlowLogCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeSlowLog, + }, + } +} + +func (cmd *SlowLogCmd) SetVal(val []SlowLog) { + cmd.val = val +} + +func (cmd *SlowLogCmd) Val() []SlowLog { + cmd.await() + return cmd.val +} + +func (cmd *SlowLogCmd) Result() ([]SlowLog, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *SlowLogCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *SlowLogCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]SlowLog, n) + + for i := 0; i < len(cmd.val); i++ { + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + if nn < 4 { + return fmt.Errorf("redis: got %d elements in slowlog get, expected at least 4", nn) + } + + if cmd.val[i].ID, err = rd.ReadInt(); err != nil { + return err + } + + createdAt, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val[i].Time = time.Unix(createdAt, 0) + + costs, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val[i].Duration = time.Duration(costs) * time.Microsecond + + cmdLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + if cmdLen < 1 { + return fmt.Errorf("redis: got %d elements commands reply in slowlog get, expected at least 1", cmdLen) + } + + cmd.val[i].Args = make([]string, cmdLen) + for f := 0; f < len(cmd.val[i].Args); f++ { + cmd.val[i].Args[f], err = rd.ReadString() + if err != nil { + return err + } + } + + if nn >= 5 { + if cmd.val[i].ClientAddr, err = rd.ReadString(); err != nil { + return err + } + } + + if nn >= 6 { + if cmd.val[i].ClientName, err = rd.ReadString(); err != nil { + return err + } + } + + // Redis 8.10+ appends a 7th field: the command's total argument count. + if nn >= 7 { + if cmd.val[i].CommandArgc, err = rd.ReadInt(); err != nil { + return err + } + } + + // Drain any elements past the 7 this parser knows about so a server + // that declares a longer entry array doesn't leave frames on the wire. + for j := 7; j < nn; j++ { + if err = rd.DiscardNext(); err != nil { + return err + } + } + } + + return nil +} + +func (cmd *SlowLogCmd) Clone() Cmder { + var val []SlowLog + if cmd.val != nil { + val = make([]SlowLog, len(cmd.val)) + for i, log := range cmd.val { + val[i] = SlowLog{ + ID: log.ID, + Time: log.Time, + Duration: log.Duration, + ClientAddr: log.ClientAddr, + ClientName: log.ClientName, + CommandArgc: log.CommandArgc, + } + if log.Args != nil { + val[i].Args = make([]string, len(log.Args)) + copy(val[i].Args, log.Args) + } + } + } + return &SlowLogCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//----------------------------------------------------------------------- + +type Latency struct { + Name string + Time time.Time + Latest time.Duration + Max time.Duration +} + +type LatencyCmd struct { + baseCmd + val []Latency +} + +var _ Cmder = (*LatencyCmd)(nil) + +func NewLatencyCmd(ctx context.Context, args ...interface{}) *LatencyCmd { + return &LatencyCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *LatencyCmd) SetVal(val []Latency) { + cmd.val = val +} + +func (cmd *LatencyCmd) Val() []Latency { + cmd.await() + return cmd.val +} + +func (cmd *LatencyCmd) Result() ([]Latency, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *LatencyCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *LatencyCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]Latency, n) + for i := 0; i < len(cmd.val); i++ { + nn, err := rd.ReadArrayLen() + if err != nil { + return err + } + if nn < 4 { + return fmt.Errorf("redis: got %d elements in latency get, expected at least 4", nn) + } + if cmd.val[i].Name, err = rd.ReadString(); err != nil { + return err + } + createdAt, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val[i].Time = time.Unix(createdAt, 0) + latest, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val[i].Latest = time.Duration(latest) * time.Millisecond + maximum, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val[i].Max = time.Duration(maximum) * time.Millisecond + // Drain any elements beyond the 4 this parser reads so a server that + // declares a longer entry array can't leave frames on the wire. + for j := 4; j < nn; j++ { + if err = rd.DiscardNext(); err != nil { + return err + } + } + } + return nil +} + +func (cmd *LatencyCmd) Clone() Cmder { + var val []Latency + if cmd.val != nil { + val = make([]Latency, len(cmd.val)) + copy(val, cmd.val) + } + return &LatencyCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//----------------------------------------------------------------------- + +// HotKeysSlotRange represents a slot or slot range in the response. +// Single element slice = individual slot, two element slice = slot range [start, end]. +type HotKeysSlotRange []int64 + +// HotKeysKeyEntry represents a hot key entry with its metric value. +type HotKeysKeyEntry struct { + Key string + Value interface{} // Can be int64 or string +} + +// HotKeysResult represents the response data from HOTKEYS GET command. +// Field names match the Redis response format. +type HotKeysResult struct { + TrackingActive bool + SampleRatio uint8 + SelectedSlots []HotKeysSlotRange + SampledCommandsSelectedSlots time.Duration // Present when sample-ratio > 1 and selected-slots is not empty + AllCommandsSelectedSlots time.Duration // Present when selected-slots is not empty + AllCommandsAllSlots time.Duration + NetBytesSampledCommandsSelectedSlots int64 // Present when sample-ratio > 1 and selected-slots is not empty + NetBytesAllCommandsSelectedSlots int64 // Present when selected-slots is not empty + NetBytesAllCommandsAllSlots int64 + CollectionStartTime time.Time + CollectionDuration time.Duration + UsedCPUSys time.Duration + UsedCPUUser time.Duration + TotalNetBytes int64 + ByCPUTime []HotKeysKeyEntry + ByNetBytes []HotKeysKeyEntry +} + +type HotKeysCmd struct { + baseCmd + + val *HotKeysResult +} + +var _ Cmder = (*HotKeysCmd)(nil) + +func NewHotKeysCmd(ctx context.Context, args ...interface{}) *HotKeysCmd { + return &HotKeysCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeHotKeys, + }, + } +} + +func (cmd *HotKeysCmd) SetVal(val *HotKeysResult) { + cmd.val = val +} + +func (cmd *HotKeysCmd) Val() *HotKeysResult { + cmd.await() + return cmd.val +} + +func (cmd *HotKeysCmd) Result() (*HotKeysResult, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *HotKeysCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *HotKeysCmd) readReply(rd *proto.Reader) error { + // HOTKEYS GET response is wrapped in an array for aggregation support + arrayLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + + if arrayLen == 0 { + // Empty array means no tracking was started or after reset + cmd.val = nil + return nil + } + + // Read the first (and typically only) element which is a map + n, err := rd.ReadMapLen() + if err != nil { + return err + } + + result := &HotKeysResult{} + data := make(map[string]interface{}, n) + + for i := 0; i < n; i++ { + k, err := rd.ReadString() + if err != nil { + return err + } + v, err := rd.ReadReply() + if err != nil { + if err == Nil { + data[k] = Nil + continue + } + if err, ok := err.(proto.RedisError); ok { + data[k] = err + continue + } + return err + } + data[k] = v + } + + if v, ok := data["tracking-active"].(int64); ok { + result.TrackingActive = v == 1 + } + if v, ok := data["sample-ratio"].(int64); ok { + result.SampleRatio = uint8(v) + } + if v, ok := data["selected-slots"].([]interface{}); ok { + result.SelectedSlots = make([]HotKeysSlotRange, 0, len(v)) + for _, slot := range v { + switch s := slot.(type) { + case int64: + // Single slot + result.SelectedSlots = append(result.SelectedSlots, HotKeysSlotRange{s}) + case []interface{}: + // Slot range + slotRange := make(HotKeysSlotRange, 0, len(s)) + for _, sr := range s { + if val, ok := sr.(int64); ok { + slotRange = append(slotRange, val) + } + } + result.SelectedSlots = append(result.SelectedSlots, slotRange) + } + } + } + if v, ok := data["sampled-commands-selected-slots-us"].(int64); ok { + result.SampledCommandsSelectedSlots = time.Duration(v) * time.Microsecond + } + if v, ok := data["all-commands-selected-slots-us"].(int64); ok { + result.AllCommandsSelectedSlots = time.Duration(v) * time.Microsecond + } + if v, ok := data["all-commands-all-slots-us"].(int64); ok { + result.AllCommandsAllSlots = time.Duration(v) * time.Microsecond + } + if v, ok := data["net-bytes-sampled-commands-selected-slots"].(int64); ok { + result.NetBytesSampledCommandsSelectedSlots = v + } + if v, ok := data["net-bytes-all-commands-selected-slots"].(int64); ok { + result.NetBytesAllCommandsSelectedSlots = v + } + if v, ok := data["net-bytes-all-commands-all-slots"].(int64); ok { + result.NetBytesAllCommandsAllSlots = v + } + if v, ok := data["collection-start-time-unix-ms"].(int64); ok { + result.CollectionStartTime = time.UnixMilli(v) + } + if v, ok := data["collection-duration-ms"].(int64); ok { + result.CollectionDuration = time.Duration(v) * time.Millisecond + } + if v, ok := data["used-cpu-sys-ms"].(int64); ok { + result.UsedCPUSys = time.Duration(v) * time.Millisecond + } + if v, ok := data["used-cpu-user-ms"].(int64); ok { + result.UsedCPUUser = time.Duration(v) * time.Millisecond + } + if v, ok := data["total-net-bytes"].(int64); ok { + result.TotalNetBytes = v + } + + if v, ok := data["by-cpu-time-us"].([]interface{}); ok { + result.ByCPUTime = parseHotKeysKeyEntries(v) + } + + if v, ok := data["by-net-bytes"].([]interface{}); ok { + result.ByNetBytes = parseHotKeysKeyEntries(v) + } + + // Only the first element of the outer array is parsed; drain the rest so a + // server that wraps more than one element doesn't leave frames on the wire. + for i := 1; i < arrayLen; i++ { + if err := rd.DiscardNext(); err != nil { + return err + } + } + + cmd.val = result + return nil +} + +// parseHotKeysKeyEntries parses the key-value pairs from HOTKEYS GET response. +func parseHotKeysKeyEntries(v []interface{}) []HotKeysKeyEntry { + entries := make([]HotKeysKeyEntry, 0, len(v)/2) + for i := 0; i < len(v); i += 2 { + if i+1 < len(v) { + key, keyOk := v[i].(string) + if keyOk { + entries = append(entries, HotKeysKeyEntry{ + Key: key, + Value: v[i+1], // Can be int64 or string + }) + } + } + } + return entries +} + +func (cmd *HotKeysCmd) Clone() Cmder { + var val *HotKeysResult + if cmd.val != nil { + val = &HotKeysResult{ + TrackingActive: cmd.val.TrackingActive, + SampleRatio: cmd.val.SampleRatio, + SampledCommandsSelectedSlots: cmd.val.SampledCommandsSelectedSlots, + AllCommandsSelectedSlots: cmd.val.AllCommandsSelectedSlots, + AllCommandsAllSlots: cmd.val.AllCommandsAllSlots, + NetBytesSampledCommandsSelectedSlots: cmd.val.NetBytesSampledCommandsSelectedSlots, + NetBytesAllCommandsSelectedSlots: cmd.val.NetBytesAllCommandsSelectedSlots, + NetBytesAllCommandsAllSlots: cmd.val.NetBytesAllCommandsAllSlots, + CollectionStartTime: cmd.val.CollectionStartTime, + CollectionDuration: cmd.val.CollectionDuration, + UsedCPUSys: cmd.val.UsedCPUSys, + UsedCPUUser: cmd.val.UsedCPUUser, + TotalNetBytes: cmd.val.TotalNetBytes, + } + if cmd.val.SelectedSlots != nil { + val.SelectedSlots = make([]HotKeysSlotRange, len(cmd.val.SelectedSlots)) + for i, sr := range cmd.val.SelectedSlots { + val.SelectedSlots[i] = make(HotKeysSlotRange, len(sr)) + copy(val.SelectedSlots[i], sr) + } + } + if cmd.val.ByCPUTime != nil { + val.ByCPUTime = make([]HotKeysKeyEntry, len(cmd.val.ByCPUTime)) + copy(val.ByCPUTime, cmd.val.ByCPUTime) + } + if cmd.val.ByNetBytes != nil { + val.ByNetBytes = make([]HotKeysKeyEntry, len(cmd.val.ByNetBytes)) + copy(val.ByNetBytes, cmd.val.ByNetBytes) + } + } + return &HotKeysCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//----------------------------------------------------------------------- + +type MapStringInterfaceCmd struct { + baseCmd + + val map[string]interface{} +} + +var _ Cmder = (*MapStringInterfaceCmd)(nil) + +func NewMapStringInterfaceCmd(ctx context.Context, args ...interface{}) *MapStringInterfaceCmd { + return &MapStringInterfaceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeMapStringInterface, + }, + } +} + +func (cmd *MapStringInterfaceCmd) SetVal(val map[string]interface{}) { + cmd.val = val +} + +func (cmd *MapStringInterfaceCmd) Val() map[string]interface{} { + cmd.await() + return cmd.val +} + +func (cmd *MapStringInterfaceCmd) Result() (map[string]interface{}, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *MapStringInterfaceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *MapStringInterfaceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + + cmd.val = make(map[string]interface{}, n) + for i := 0; i < n; i++ { + k, err := rd.ReadString() + if err != nil { + return err + } + v, err := rd.ReadReply() + if err != nil { + if err == Nil { + cmd.val[k] = Nil + continue + } + if err, ok := err.(proto.RedisError); ok { + cmd.val[k] = err + continue + } + return err + } + cmd.val[k] = v + } + return nil +} + +func (cmd *MapStringInterfaceCmd) Clone() Cmder { + var val map[string]interface{} + if cmd.val != nil { + val = make(map[string]interface{}, len(cmd.val)) + for k, v := range cmd.val { + val[k] = v + } + } + return &MapStringInterfaceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//----------------------------------------------------------------------- + +type MapStringStringSliceCmd struct { + baseCmd + + val []map[string]string +} + +var _ Cmder = (*MapStringStringSliceCmd)(nil) + +func NewMapStringStringSliceCmd(ctx context.Context, args ...interface{}) *MapStringStringSliceCmd { + return &MapStringStringSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeMapStringStringSlice, + }, + } +} + +func (cmd *MapStringStringSliceCmd) SetVal(val []map[string]string) { + cmd.val = val +} + +func (cmd *MapStringStringSliceCmd) Val() []map[string]string { + cmd.await() + return cmd.val +} + +func (cmd *MapStringStringSliceCmd) Result() ([]map[string]string, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *MapStringStringSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *MapStringStringSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.val = make([]map[string]string, n) + for i := 0; i < n; i++ { + nn, err := rd.ReadMapLen() + if err != nil { + return err + } + cmd.val[i] = make(map[string]string, nn) + for f := 0; f < nn; f++ { + k, err := rd.ReadString() + if err != nil { + return err + } + + v, err := rd.ReadString() + if err != nil { + return err + } + cmd.val[i][k] = v + } + } + return nil +} + +func (cmd *MapStringStringSliceCmd) Clone() Cmder { + var val []map[string]string + if cmd.val != nil { + val = make([]map[string]string, len(cmd.val)) + for i, m := range cmd.val { + if m != nil { + val[i] = make(map[string]string, len(m)) + for k, v := range m { + val[i][k] = v + } + } + } + } + return &MapStringStringSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +// ----------------------------------------------------------------------- + +// MapMapStringInterfaceCmd represents a command that returns a map of strings to interface{}. +type MapMapStringInterfaceCmd struct { + baseCmd + val map[string]interface{} +} + +func NewMapMapStringInterfaceCmd(ctx context.Context, args ...interface{}) *MapMapStringInterfaceCmd { + return &MapMapStringInterfaceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeMapMapStringInterface, + }, + } +} + +func (cmd *MapMapStringInterfaceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *MapMapStringInterfaceCmd) SetVal(val map[string]interface{}) { + cmd.val = val +} + +func (cmd *MapMapStringInterfaceCmd) Result() (map[string]interface{}, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *MapMapStringInterfaceCmd) Val() map[string]interface{} { + cmd.await() + return cmd.val +} + +// readReply will try to parse the reply from the proto.Reader for both resp2 and resp3 +func (cmd *MapMapStringInterfaceCmd) readReply(rd *proto.Reader) (err error) { + data, err := rd.ReadReply() + if err != nil { + return err + } + resultMap := map[string]interface{}{} + + switch midResponse := data.(type) { + case map[interface{}]interface{}: // resp3 will return map + for k, v := range midResponse { + stringKey, ok := k.(string) + if !ok { + return fmt.Errorf("redis: invalid map key %#v", k) + } + resultMap[stringKey] = v + } + case []interface{}: // resp2 will return array of arrays + n := len(midResponse) + for i := 0; i < n; i++ { + finalArr, ok := midResponse[i].([]interface{}) // final array that we need to transform to map + if !ok { + return fmt.Errorf("redis: unexpected response %#v", data) + } + m := len(finalArr) + if m%2 != 0 { // since this should be map, keys should be even number + return fmt.Errorf("redis: unexpected response %#v", data) + } + + for j := 0; j < m; j += 2 { + stringKey, ok := finalArr[j].(string) // the first one + if !ok { + return fmt.Errorf("redis: invalid map key %#v", finalArr[i]) + } + resultMap[stringKey] = finalArr[j+1] // second one is value + } + } + default: + return fmt.Errorf("redis: unexpected response %#v", data) + } + + cmd.val = resultMap + return nil +} + +func (cmd *MapMapStringInterfaceCmd) Clone() Cmder { + var val map[string]interface{} + if cmd.val != nil { + val = make(map[string]interface{}, len(cmd.val)) + for k, v := range cmd.val { + val[k] = v + } + } + return &MapMapStringInterfaceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//----------------------------------------------------------------------- + +type MapStringInterfaceSliceCmd struct { + baseCmd + + val []map[string]interface{} +} + +var _ Cmder = (*MapStringInterfaceSliceCmd)(nil) + +func NewMapStringInterfaceSliceCmd(ctx context.Context, args ...interface{}) *MapStringInterfaceSliceCmd { + return &MapStringInterfaceSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeMapStringInterfaceSlice, + }, + } +} + +func (cmd *MapStringInterfaceSliceCmd) SetVal(val []map[string]interface{}) { + cmd.val = val +} + +func (cmd *MapStringInterfaceSliceCmd) Val() []map[string]interface{} { + cmd.await() + return cmd.val +} + +func (cmd *MapStringInterfaceSliceCmd) Result() ([]map[string]interface{}, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *MapStringInterfaceSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *MapStringInterfaceSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.val = make([]map[string]interface{}, n) + for i := 0; i < n; i++ { + nn, err := rd.ReadMapLen() + if err != nil { + return err + } + cmd.val[i] = make(map[string]interface{}, nn) + for f := 0; f < nn; f++ { + k, err := rd.ReadString() + if err != nil { + return err + } + v, err := rd.ReadReply() + if err != nil { + if err != Nil { + return err + } + } + cmd.val[i][k] = v + } + } + return nil +} + +func (cmd *MapStringInterfaceSliceCmd) Clone() Cmder { + var val []map[string]interface{} + if cmd.val != nil { + val = make([]map[string]interface{}, len(cmd.val)) + for i, m := range cmd.val { + if m != nil { + val[i] = make(map[string]interface{}, len(m)) + for k, v := range m { + val[i][k] = v + } + } + } + } + return &MapStringInterfaceSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +type KeyValuesCmd struct { + baseCmd + + key string + val []string +} + +var _ Cmder = (*KeyValuesCmd)(nil) + +func NewKeyValuesCmd(ctx context.Context, args ...interface{}) *KeyValuesCmd { + return &KeyValuesCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeKeyValues, + }, + } +} + +func (cmd *KeyValuesCmd) SetVal(key string, val []string) { + cmd.key = key + cmd.val = val +} + +func (cmd *KeyValuesCmd) Val() (string, []string) { + cmd.await() + return cmd.key, cmd.val +} + +func (cmd *KeyValuesCmd) Result() (string, []string, error) { + cmd.await() + return cmd.key, cmd.val, cmd.err +} + +func (cmd *KeyValuesCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *KeyValuesCmd) readReply(rd *proto.Reader) (err error) { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + + cmd.key, err = rd.ReadString() + if err != nil { + return err + } + + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]string, n) + for i := 0; i < n; i++ { + cmd.val[i], err = rd.ReadString() + if err != nil { + return err + } + } + + return nil +} + +func (cmd *KeyValuesCmd) Clone() Cmder { + var val []string + if cmd.val != nil { + val = make([]string, len(cmd.val)) + copy(val, cmd.val) + } + return &KeyValuesCmd{ + baseCmd: cmd.cloneBaseCmd(), + key: cmd.key, + val: val, + } +} + +//------------------------------------------------------------------------------ + +type ZSliceWithKeyCmd struct { + baseCmd + + key string + val []Z +} + +var _ Cmder = (*ZSliceWithKeyCmd)(nil) + +func NewZSliceWithKeyCmd(ctx context.Context, args ...interface{}) *ZSliceWithKeyCmd { + return &ZSliceWithKeyCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeZSliceWithKey, + }, + } +} + +func (cmd *ZSliceWithKeyCmd) SetVal(key string, val []Z) { + cmd.key = key + cmd.val = val +} + +func (cmd *ZSliceWithKeyCmd) Val() (string, []Z) { + cmd.await() + return cmd.key, cmd.val +} + +func (cmd *ZSliceWithKeyCmd) Result() (string, []Z, error) { + cmd.await() + return cmd.key, cmd.val, cmd.err +} + +func (cmd *ZSliceWithKeyCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *ZSliceWithKeyCmd) readReply(rd *proto.Reader) (err error) { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + + cmd.key, err = rd.ReadString() + if err != nil { + return err + } + + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + typ, err := rd.PeekReplyType() + if err != nil { + return err + } + array := typ == proto.RespArray + + if array { + cmd.val = make([]Z, n) + } else { + if n%2 != 0 { + return fmt.Errorf("redis: got %d elements in the sorted set array, wanted a multiple of 2", n) + } + cmd.val = make([]Z, n/2) + } + + for i := 0; i < len(cmd.val); i++ { + if array { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + } + + if cmd.val[i].Member, err = rd.ReadString(); err != nil { + return err + } + + if cmd.val[i].Score, err = rd.ReadFloat(); err != nil { + return err + } + } + + return nil +} + +func (cmd *ZSliceWithKeyCmd) Clone() Cmder { + var val []Z + if cmd.val != nil { + val = make([]Z, len(cmd.val)) + copy(val, cmd.val) + } + return &ZSliceWithKeyCmd{ + baseCmd: cmd.cloneBaseCmd(), + key: cmd.key, + val: val, + } +} + +type Function struct { + Name string + Description string + Flags []string +} + +type Library struct { + Name string + Engine string + Functions []Function + Code string +} + +type FunctionListCmd struct { + baseCmd + + val []Library +} + +var _ Cmder = (*FunctionListCmd)(nil) + +func NewFunctionListCmd(ctx context.Context, args ...interface{}) *FunctionListCmd { + return &FunctionListCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeFunctionList, + }, + } +} + +func (cmd *FunctionListCmd) SetVal(val []Library) { + cmd.val = val +} + +func (cmd *FunctionListCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *FunctionListCmd) Val() []Library { + cmd.await() + return cmd.val +} + +func (cmd *FunctionListCmd) Result() ([]Library, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *FunctionListCmd) First() (*Library, error) { + cmd.await() + if cmd.err != nil { + return nil, cmd.err + } + if len(cmd.val) > 0 { + return &cmd.val[0], nil + } + return nil, Nil +} + +func (cmd *FunctionListCmd) readReply(rd *proto.Reader) (err error) { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + libraries := make([]Library, n) + for i := 0; i < n; i++ { + nn, err := rd.ReadMapLen() + if err != nil { + return err + } + + library := Library{} + for f := 0; f < nn; f++ { + key, err := rd.ReadString() + if err != nil { + return err + } + + switch key { + case "library_name": + library.Name, err = rd.ReadString() + case "engine": + library.Engine, err = rd.ReadString() + case "functions": + library.Functions, err = cmd.readFunctions(rd) + case "library_code": + library.Code, err = rd.ReadString() + default: + return fmt.Errorf("redis: function list unexpected key %s", key) + } + + if err != nil { + return err + } + } + + libraries[i] = library + } + cmd.val = libraries + return nil +} + +func (cmd *FunctionListCmd) readFunctions(rd *proto.Reader) ([]Function, error) { + n, err := rd.ReadArrayLen() + if err != nil { + return nil, err + } + + functions := make([]Function, n) + for i := 0; i < n; i++ { + nn, err := rd.ReadMapLen() + if err != nil { + return nil, err + } + + function := Function{} + for f := 0; f < nn; f++ { + key, err := rd.ReadString() + if err != nil { + return nil, err + } + + switch key { + case "name": + if function.Name, err = rd.ReadString(); err != nil { + return nil, err + } + case "description": + if function.Description, err = rd.ReadString(); err != nil && err != Nil { + return nil, err + } + case "flags": + // resp set + nx, err := rd.ReadArrayLen() + if err != nil { + return nil, err + } + + function.Flags = make([]string, nx) + for j := 0; j < nx; j++ { + if function.Flags[j], err = rd.ReadString(); err != nil { + return nil, err + } + } + default: + return nil, fmt.Errorf("redis: function list unexpected key %s", key) + } + } + + functions[i] = function + } + return functions, nil +} + +func (cmd *FunctionListCmd) Clone() Cmder { + var val []Library + if cmd.val != nil { + val = make([]Library, len(cmd.val)) + for i, lib := range cmd.val { + val[i] = Library{ + Name: lib.Name, + Engine: lib.Engine, + Code: lib.Code, + } + if lib.Functions != nil { + val[i].Functions = make([]Function, len(lib.Functions)) + for j, fn := range lib.Functions { + val[i].Functions[j] = Function{ + Name: fn.Name, + Description: fn.Description, + } + if fn.Flags != nil { + val[i].Functions[j].Flags = make([]string, len(fn.Flags)) + copy(val[i].Functions[j].Flags, fn.Flags) + } + } + } + } + } + return &FunctionListCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +// FunctionStats contains information about the scripts currently executing on the server, and the available engines +// - Engines: +// Statistics about the engine like number of functions and number of libraries +// - RunningScript: +// The script currently running on the shard we're connecting to. +// For Redis Enterprise and Redis Cloud, this represents the +// function with the longest running time, across all the running functions, on all shards +// - RunningScripts +// All scripts currently running in a Redis Enterprise clustered database. +// Only available on Redis Enterprise +type FunctionStats struct { + Engines []Engine + isRunning bool + rs RunningScript + allrs []RunningScript +} + +func (fs *FunctionStats) Running() bool { + return fs.isRunning +} + +func (fs *FunctionStats) RunningScript() (RunningScript, bool) { + return fs.rs, fs.isRunning +} + +// AllRunningScripts returns all scripts currently running in a Redis Enterprise clustered database. +// Only available on Redis Enterprise +func (fs *FunctionStats) AllRunningScripts() []RunningScript { + return fs.allrs +} + +type RunningScript struct { + Name string + Command []string + Duration time.Duration +} + +type Engine struct { + Language string + LibrariesCount int64 + FunctionsCount int64 +} + +type FunctionStatsCmd struct { + baseCmd + val FunctionStats +} + +var _ Cmder = (*FunctionStatsCmd)(nil) + +func NewFunctionStatsCmd(ctx context.Context, args ...interface{}) *FunctionStatsCmd { + return &FunctionStatsCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeFunctionStats, + }, + } +} + +func (cmd *FunctionStatsCmd) SetVal(val FunctionStats) { + cmd.val = val +} + +func (cmd *FunctionStatsCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *FunctionStatsCmd) Val() FunctionStats { + cmd.await() + return cmd.val +} + +func (cmd *FunctionStatsCmd) Result() (FunctionStats, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *FunctionStatsCmd) readReply(rd *proto.Reader) (err error) { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + + var key string + var result FunctionStats + for f := 0; f < n; f++ { + key, err = rd.ReadString() + if err != nil { + return err + } + + switch key { + case "running_script": + result.rs, result.isRunning, err = cmd.readRunningScript(rd) + case "engines": + result.Engines, err = cmd.readEngines(rd) + case "all_running_scripts": // Redis Enterprise only + result.allrs, result.isRunning, err = cmd.readRunningScripts(rd) + default: + return fmt.Errorf("redis: function stats unexpected key %s", key) + } + + if err != nil { + return err + } + } + + cmd.val = result + return nil +} + +func (cmd *FunctionStatsCmd) readRunningScript(rd *proto.Reader) (RunningScript, bool, error) { + err := rd.ReadFixedMapLen(3) + if err != nil { + if err == Nil { + return RunningScript{}, false, nil + } + return RunningScript{}, false, err + } + + var runningScript RunningScript + for i := 0; i < 3; i++ { + key, err := rd.ReadString() + if err != nil { + return RunningScript{}, false, err + } + + switch key { + case "name": + runningScript.Name, err = rd.ReadString() + case "duration_ms": + runningScript.Duration, err = cmd.readDuration(rd) + case "command": + runningScript.Command, err = cmd.readCommand(rd) + default: + return RunningScript{}, false, fmt.Errorf("redis: function stats unexpected running_script key %s", key) + } + + if err != nil { + return RunningScript{}, false, err + } + } + + return runningScript, true, nil +} + +func (cmd *FunctionStatsCmd) readEngines(rd *proto.Reader) ([]Engine, error) { + n, err := rd.ReadMapLen() + if err != nil { + return nil, err + } + + engines := make([]Engine, 0, n) + for i := 0; i < n; i++ { + engine := Engine{} + engine.Language, err = rd.ReadString() + if err != nil { + return nil, err + } + + err = rd.ReadFixedMapLen(2) + if err != nil { + return nil, fmt.Errorf("redis: function stats unexpected %s engine map length", engine.Language) + } + + for i := 0; i < 2; i++ { + key, err := rd.ReadString() + if err != nil { + return nil, err + } + switch key { + case "libraries_count": + engine.LibrariesCount, err = rd.ReadInt() + case "functions_count": + engine.FunctionsCount, err = rd.ReadInt() + default: + // Unknown field: drain its value so the reader stays aligned + // with the rest of the reply. + err = rd.DiscardNext() + } + if err != nil { + return nil, err + } + } + + engines = append(engines, engine) + } + return engines, nil +} + +func (cmd *FunctionStatsCmd) readDuration(rd *proto.Reader) (time.Duration, error) { + t, err := rd.ReadInt() + if err != nil { + return time.Duration(0), err + } + return time.Duration(t) * time.Millisecond, nil +} + +func (cmd *FunctionStatsCmd) readCommand(rd *proto.Reader) ([]string, error) { + n, err := rd.ReadArrayLen() + if err != nil { + return nil, err + } + + command := make([]string, 0, n) + for i := 0; i < n; i++ { + x, err := rd.ReadString() + if err != nil { + return nil, err + } + command = append(command, x) + } + + return command, nil +} + +func (cmd *FunctionStatsCmd) readRunningScripts(rd *proto.Reader) ([]RunningScript, bool, error) { + n, err := rd.ReadArrayLen() + if err != nil { + return nil, false, err + } + + runningScripts := make([]RunningScript, 0, n) + for i := 0; i < n; i++ { + rs, _, err := cmd.readRunningScript(rd) + if err != nil { + return nil, false, err + } + runningScripts = append(runningScripts, rs) + } + + return runningScripts, len(runningScripts) > 0, nil +} + +func (cmd *FunctionStatsCmd) Clone() Cmder { + val := FunctionStats{ + isRunning: cmd.val.isRunning, + rs: cmd.val.rs, // RunningScript is a simple struct, can be copied directly + } + if cmd.val.Engines != nil { + val.Engines = make([]Engine, len(cmd.val.Engines)) + copy(val.Engines, cmd.val.Engines) + } + if cmd.val.allrs != nil { + val.allrs = make([]RunningScript, len(cmd.val.allrs)) + for i, rs := range cmd.val.allrs { + val.allrs[i] = RunningScript{ + Name: rs.Name, + Duration: rs.Duration, + } + if rs.Command != nil { + val.allrs[i].Command = make([]string, len(rs.Command)) + copy(val.allrs[i].Command, rs.Command) + } + } + } + return &FunctionStatsCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +//------------------------------------------------------------------------------ + +// LCSQuery is a parameter used for the LCS command +type LCSQuery struct { + Key1 string + Key2 string + Len bool + Idx bool + MinMatchLen int + WithMatchLen bool +} + +// LCSMatch is the result set of the LCS command. +type LCSMatch struct { + MatchString string + Matches []LCSMatchedPosition + Len int64 +} + +type LCSMatchedPosition struct { + Key1 LCSPosition + Key2 LCSPosition + + // only for withMatchLen is true + MatchLen int64 +} + +type LCSPosition struct { + Start int64 + End int64 +} + +type LCSCmd struct { + baseCmd + + // 1: match string + // 2: match len + // 3: match idx LCSMatch + readType uint8 + val *LCSMatch +} + +func NewLCSCmd(ctx context.Context, q *LCSQuery) *LCSCmd { + args := make([]interface{}, 3, 7) + args[0] = "lcs" + args[1] = q.Key1 + args[2] = q.Key2 + + cmd := &LCSCmd{readType: 1} + if q.Len { + cmd.readType = 2 + args = append(args, "len") + } else if q.Idx { + cmd.readType = 3 + args = append(args, "idx") + if q.MinMatchLen != 0 { + args = append(args, "minmatchlen", q.MinMatchLen) + } + if q.WithMatchLen { + args = append(args, "withmatchlen") + } + } + cmd.baseCmd = baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeLCS, + } + + return cmd +} + +func (cmd *LCSCmd) SetVal(val *LCSMatch) { + cmd.val = val +} + +func (cmd *LCSCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *LCSCmd) Val() *LCSMatch { + cmd.await() + return cmd.val +} + +func (cmd *LCSCmd) Result() (*LCSMatch, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *LCSCmd) readReply(rd *proto.Reader) (err error) { + lcs := &LCSMatch{} + switch cmd.readType { + case 1: + // match string + if lcs.MatchString, err = rd.ReadString(); err != nil { + return err + } + case 2: + // match len + if lcs.Len, err = rd.ReadInt(); err != nil { + return err + } + case 3: + // read LCSMatch + if err = rd.ReadFixedMapLen(2); err != nil { + return err + } + + // read matches or len field + for i := 0; i < 2; i++ { + key, err := rd.ReadString() + if err != nil { + return err + } + + switch key { + case "matches": + // read array of matched positions + if lcs.Matches, err = cmd.readMatchedPositions(rd); err != nil { + return err + } + case "len": + // read match length + if lcs.Len, err = rd.ReadInt(); err != nil { + return err + } + default: + // Unknown field: drain its value so the reader stays aligned + // with the rest of the reply. + if err = rd.DiscardNext(); err != nil { + return err + } + } + } + } + + cmd.val = lcs + return nil +} + +func (cmd *LCSCmd) readMatchedPositions(rd *proto.Reader) ([]LCSMatchedPosition, error) { + n, err := rd.ReadArrayLen() + if err != nil { + return nil, err + } + + positions := make([]LCSMatchedPosition, n) + for i := 0; i < n; i++ { + pn, err := rd.ReadArrayLen() + if err != nil { + return nil, err + } + + if positions[i].Key1, err = cmd.readPosition(rd); err != nil { + return nil, err + } + if positions[i].Key2, err = cmd.readPosition(rd); err != nil { + return nil, err + } + + // read match length if WithMatchLen is true + if pn > 2 { + if positions[i].MatchLen, err = rd.ReadInt(); err != nil { + return nil, err + } + } + } + + return positions, nil +} + +func (cmd *LCSCmd) readPosition(rd *proto.Reader) (pos LCSPosition, err error) { + if err = rd.ReadFixedArrayLen(2); err != nil { + return pos, err + } + if pos.Start, err = rd.ReadInt(); err != nil { + return pos, err + } + if pos.End, err = rd.ReadInt(); err != nil { + return pos, err + } + + return pos, nil +} + +func (cmd *LCSCmd) Clone() Cmder { + var val *LCSMatch + if cmd.val != nil { + val = &LCSMatch{ + MatchString: cmd.val.MatchString, + Len: cmd.val.Len, + } + if cmd.val.Matches != nil { + val.Matches = make([]LCSMatchedPosition, len(cmd.val.Matches)) + copy(val.Matches, cmd.val.Matches) + } + } + return &LCSCmd{ + baseCmd: cmd.cloneBaseCmd(), + readType: cmd.readType, + val: val, + } +} + +// ------------------------------------------------------------------------ + +type KeyFlags struct { + Key string + Flags []string +} + +type KeyFlagsCmd struct { + baseCmd + + val []KeyFlags +} + +var _ Cmder = (*KeyFlagsCmd)(nil) + +func NewKeyFlagsCmd(ctx context.Context, args ...interface{}) *KeyFlagsCmd { + return &KeyFlagsCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeKeyFlags, + }, + } +} + +func (cmd *KeyFlagsCmd) SetVal(val []KeyFlags) { + cmd.val = val +} + +func (cmd *KeyFlagsCmd) Val() []KeyFlags { + cmd.await() + return cmd.val +} + +func (cmd *KeyFlagsCmd) Result() ([]KeyFlags, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *KeyFlagsCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *KeyFlagsCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + if n == 0 { + cmd.val = make([]KeyFlags, 0) + return nil + } + + cmd.val = make([]KeyFlags, n) + + for i := 0; i < len(cmd.val); i++ { + + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + + if cmd.val[i].Key, err = rd.ReadString(); err != nil { + return err + } + flagsLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val[i].Flags = make([]string, flagsLen) + + for j := 0; j < flagsLen; j++ { + if cmd.val[i].Flags[j], err = rd.ReadString(); err != nil { + return err + } + } + } + + return nil +} + +func (cmd *KeyFlagsCmd) Clone() Cmder { + var val []KeyFlags + if cmd.val != nil { + val = make([]KeyFlags, len(cmd.val)) + for i, kf := range cmd.val { + val[i] = KeyFlags{ + Key: kf.Key, + } + if kf.Flags != nil { + val[i].Flags = make([]string, len(kf.Flags)) + copy(val[i].Flags, kf.Flags) + } + } + } + return &KeyFlagsCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +// --------------------------------------------------------------------------------------------------- + +type ClusterLink struct { + Direction string + Node string + CreateTime int64 + Events string + SendBufferAllocated int64 + SendBufferUsed int64 +} + +type ClusterLinksCmd struct { + baseCmd + + val []ClusterLink +} + +var _ Cmder = (*ClusterLinksCmd)(nil) + +func NewClusterLinksCmd(ctx context.Context, args ...interface{}) *ClusterLinksCmd { + return &ClusterLinksCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeClusterLinks, + }, + } +} + +func (cmd *ClusterLinksCmd) SetVal(val []ClusterLink) { + cmd.val = val +} + +func (cmd *ClusterLinksCmd) Val() []ClusterLink { + cmd.await() + return cmd.val +} + +func (cmd *ClusterLinksCmd) Result() ([]ClusterLink, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *ClusterLinksCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *ClusterLinksCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]ClusterLink, n) + + for i := 0; i < len(cmd.val); i++ { + m, err := rd.ReadMapLen() + if err != nil { + return err + } + + for j := 0; j < m; j++ { + key, err := rd.ReadString() + if err != nil { + return err + } + + switch key { + case "direction": + cmd.val[i].Direction, err = rd.ReadString() + case "node": + cmd.val[i].Node, err = rd.ReadString() + case "create-time": + cmd.val[i].CreateTime, err = rd.ReadInt() + case "events": + cmd.val[i].Events, err = rd.ReadString() + case "send-buffer-allocated": + cmd.val[i].SendBufferAllocated, err = rd.ReadInt() + case "send-buffer-used": + cmd.val[i].SendBufferUsed, err = rd.ReadInt() + default: + return fmt.Errorf("redis: unexpected key %q in CLUSTER LINKS reply", key) + } + + if err != nil { + return err + } + } + } + + return nil +} + +func (cmd *ClusterLinksCmd) Clone() Cmder { + var val []ClusterLink + if cmd.val != nil { + val = make([]ClusterLink, len(cmd.val)) + copy(val, cmd.val) + } + return &ClusterLinksCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +// ------------------------------------------------------------------------------------------------------------------ + +type SlotRange struct { + Start int64 + End int64 +} + +type Node struct { + ID string + Endpoint string + IP string + Hostname string + Port int64 + TLSPort int64 + Role string + ReplicationOffset int64 + Health string +} + +type ClusterShard struct { + Slots []SlotRange + Nodes []Node +} + +type ClusterShardsCmd struct { + baseCmd + + val []ClusterShard +} + +var _ Cmder = (*ClusterShardsCmd)(nil) + +func NewClusterShardsCmd(ctx context.Context, args ...interface{}) *ClusterShardsCmd { + return &ClusterShardsCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeClusterShards, + }, + } +} + +func (cmd *ClusterShardsCmd) SetVal(val []ClusterShard) { + cmd.val = val +} + +func (cmd *ClusterShardsCmd) Val() []ClusterShard { + cmd.await() + return cmd.val +} + +func (cmd *ClusterShardsCmd) Result() ([]ClusterShard, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *ClusterShardsCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *ClusterShardsCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val = make([]ClusterShard, n) + + for i := 0; i < n; i++ { + m, err := rd.ReadMapLen() + if err != nil { + return err + } + + for j := 0; j < m; j++ { + key, err := rd.ReadString() + if err != nil { + return err + } + + switch key { + case "slots": + l, err := rd.ReadArrayLen() + if err != nil { + return err + } + for k := 0; k < l; k += 2 { + start, err := rd.ReadInt() + if err != nil { + return err + } + + end, err := rd.ReadInt() + if err != nil { + return err + } + + cmd.val[i].Slots = append(cmd.val[i].Slots, SlotRange{Start: start, End: end}) + } + case "nodes": + nodesLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + cmd.val[i].Nodes = make([]Node, nodesLen) + for k := 0; k < nodesLen; k++ { + nodeMapLen, err := rd.ReadMapLen() + if err != nil { + return err + } + + for l := 0; l < nodeMapLen; l++ { + nodeKey, err := rd.ReadString() + if err != nil { + return err + } + + switch nodeKey { + case "id": + cmd.val[i].Nodes[k].ID, err = rd.ReadString() + case "endpoint": + cmd.val[i].Nodes[k].Endpoint, err = rd.ReadString() + case "ip": + cmd.val[i].Nodes[k].IP, err = rd.ReadString() + case "hostname": + cmd.val[i].Nodes[k].Hostname, err = rd.ReadString() + case "port": + cmd.val[i].Nodes[k].Port, err = rd.ReadInt() + case "tls-port": + cmd.val[i].Nodes[k].TLSPort, err = rd.ReadInt() + case "role": + cmd.val[i].Nodes[k].Role, err = rd.ReadString() + case "replication-offset": + cmd.val[i].Nodes[k].ReplicationOffset, err = rd.ReadInt() + case "health": + cmd.val[i].Nodes[k].Health, err = rd.ReadString() + default: + if err = rd.DiscardNext(); err != nil { + return err + } + } + + if err != nil { + return err + } + } + } + default: + if err = rd.DiscardNext(); err != nil { + return err + } + } + } + } + + return nil +} + +func (cmd *ClusterShardsCmd) Clone() Cmder { + var val []ClusterShard + if cmd.val != nil { + val = make([]ClusterShard, len(cmd.val)) + for i, shard := range cmd.val { + val[i] = ClusterShard{} + if shard.Slots != nil { + val[i].Slots = make([]SlotRange, len(shard.Slots)) + copy(val[i].Slots, shard.Slots) + } + if shard.Nodes != nil { + val[i].Nodes = make([]Node, len(shard.Nodes)) + copy(val[i].Nodes, shard.Nodes) + } + } + } + return &ClusterShardsCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +// ----------------------------------------- + +type RankScore struct { + Rank int64 + Score float64 +} + +type RankWithScoreCmd struct { + baseCmd + + val RankScore +} + +var _ Cmder = (*RankWithScoreCmd)(nil) + +func NewRankWithScoreCmd(ctx context.Context, args ...interface{}) *RankWithScoreCmd { + return &RankWithScoreCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeRankWithScore, + }, + } +} + +func (cmd *RankWithScoreCmd) SetVal(val RankScore) { + cmd.val = val +} + +func (cmd *RankWithScoreCmd) Val() RankScore { + cmd.await() + return cmd.val +} + +func (cmd *RankWithScoreCmd) Result() (RankScore, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *RankWithScoreCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *RankWithScoreCmd) readReply(rd *proto.Reader) error { + if err := rd.ReadFixedArrayLen(2); err != nil { + return err + } + + rank, err := rd.ReadInt() + if err != nil { + return err + } + + score, err := rd.ReadFloat() + if err != nil { + return err + } + + cmd.val = RankScore{Rank: rank, Score: score} + + return nil +} + +func (cmd *RankWithScoreCmd) Clone() Cmder { + return &RankWithScoreCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, // RankScore is a simple struct, can be copied directly + } +} + +// -------------------------------------------------------------------------------------------------- + +// ClientFlags is redis-server client flags, copy from redis/src/server.h (redis 7.0) +type ClientFlags uint64 + +const ( + ClientSlave ClientFlags = 1 << 0 /* This client is a replica */ + ClientMaster ClientFlags = 1 << 1 /* This client is a master */ + ClientMonitor ClientFlags = 1 << 2 /* This client is a slave monitor, see MONITOR */ + ClientMulti ClientFlags = 1 << 3 /* This client is in a MULTI context */ + ClientBlocked ClientFlags = 1 << 4 /* The client is waiting in a blocking operation */ + ClientDirtyCAS ClientFlags = 1 << 5 /* Watched keys modified. EXEC will fail. */ + ClientCloseAfterReply ClientFlags = 1 << 6 /* Close after writing entire reply. */ + ClientUnBlocked ClientFlags = 1 << 7 /* This client was unblocked and is stored in server.unblocked_clients */ + ClientScript ClientFlags = 1 << 8 /* This is a non-connected client used by Lua */ + ClientAsking ClientFlags = 1 << 9 /* Client issued the ASKING command */ + ClientCloseASAP ClientFlags = 1 << 10 /* Close this client ASAP */ + ClientUnixSocket ClientFlags = 1 << 11 /* Client connected via Unix domain socket */ + ClientDirtyExec ClientFlags = 1 << 12 /* EXEC will fail for errors while queueing */ + ClientMasterForceReply ClientFlags = 1 << 13 /* Queue replies even if is master */ + ClientForceAOF ClientFlags = 1 << 14 /* Force AOF propagation of current cmd. */ + ClientForceRepl ClientFlags = 1 << 15 /* Force replication of current cmd. */ + ClientPrePSync ClientFlags = 1 << 16 /* Instance don't understand PSYNC. */ + ClientReadOnly ClientFlags = 1 << 17 /* Cluster client is in read-only state. */ + ClientPubSub ClientFlags = 1 << 18 /* Client is in Pub/Sub mode. */ + ClientPreventAOFProp ClientFlags = 1 << 19 /* Don't propagate to AOF. */ + ClientPreventReplProp ClientFlags = 1 << 20 /* Don't propagate to slaves. */ + ClientPreventProp ClientFlags = ClientPreventAOFProp | ClientPreventReplProp + ClientPendingWrite ClientFlags = 1 << 21 /* Client has output to send but a-write handler is yet not installed. */ + ClientReplyOff ClientFlags = 1 << 22 /* Don't send replies to client. */ + ClientReplySkipNext ClientFlags = 1 << 23 /* Set ClientREPLY_SKIP for next cmd */ + ClientReplySkip ClientFlags = 1 << 24 /* Don't send just this reply. */ + ClientLuaDebug ClientFlags = 1 << 25 /* Run EVAL in debug mode. */ + ClientLuaDebugSync ClientFlags = 1 << 26 /* EVAL debugging without fork() */ + ClientModule ClientFlags = 1 << 27 /* Non connected client used by some module. */ + ClientProtected ClientFlags = 1 << 28 /* Client should not be freed for now. */ + ClientExecutingCommand ClientFlags = 1 << 29 /* Indicates that the client is currently in the process of handling + a command. usually this will be marked only during call() + however, blocked clients might have this flag kept until they + will try to reprocess the command. */ + ClientPendingCommand ClientFlags = 1 << 30 /* Indicates the client has a fully * parsed command ready for execution. */ + ClientTracking ClientFlags = 1 << 31 /* Client enabled keys tracking in order to perform client side caching. */ + ClientTrackingBrokenRedir ClientFlags = 1 << 32 /* Target client is invalid. */ + ClientTrackingBCAST ClientFlags = 1 << 33 /* Tracking in BCAST mode. */ + ClientTrackingOptIn ClientFlags = 1 << 34 /* Tracking in opt-in mode. */ + ClientTrackingOptOut ClientFlags = 1 << 35 /* Tracking in opt-out mode. */ + ClientTrackingCaching ClientFlags = 1 << 36 /* CACHING yes/no was given, depending on optin/optout mode. */ + ClientTrackingNoLoop ClientFlags = 1 << 37 /* Don't send invalidation messages about writes performed by myself.*/ + ClientInTimeoutTable ClientFlags = 1 << 38 /* This client is in the timeout table. */ + ClientProtocolError ClientFlags = 1 << 39 /* Protocol error chatting with it. */ + ClientCloseAfterCommand ClientFlags = 1 << 40 /* Close after executing commands * and writing entire reply. */ + ClientDenyBlocking ClientFlags = 1 << 41 /* Indicate that the client should not be blocked. currently, turned on inside MULTI, Lua, RM_Call, and AOF client */ + ClientReplRDBOnly ClientFlags = 1 << 42 /* This client is a replica that only wants RDB without replication buffer. */ + ClientNoEvict ClientFlags = 1 << 43 /* This client is protected against client memory eviction. */ + ClientAllowOOM ClientFlags = 1 << 44 /* Client used by RM_Call is allowed to fully execute scripts even when in OOM */ + ClientNoTouch ClientFlags = 1 << 45 /* This client will not touch LFU/LRU stats. */ + ClientPushing ClientFlags = 1 << 46 /* This client is pushing notifications. */ +) + +// ClientInfo is redis-server ClientInfo, not go-redis *Client +type ClientInfo struct { + ID int64 // redis version 2.8.12, a unique 64-bit client ID + Addr string // address/port of the client + LAddr string // address/port of local address client connected to (bind address) + FD int64 // file descriptor corresponding to the socket + Name string // the name set by the client with CLIENT SETNAME + Age time.Duration // total duration of the connection in seconds + Idle time.Duration // idle time of the connection in seconds + Flags ClientFlags // client flags (see below) + DB int // current database ID + Sub int // number of channel subscriptions + PSub int // number of pattern matching subscriptions + SSub int // redis version 7.0.3, number of shard channel subscriptions + Multi int // number of commands in a MULTI/EXEC context + Watch int // redis version 7.4 RC1, number of keys this client is currently watching. + QueryBuf int // qbuf, query buffer length (0 means no query pending) + QueryBufFree int // qbuf-free, free space of the query buffer (0 means the buffer is full) + ArgvMem int // incomplete arguments for the next command (already extracted from query buffer) + MultiMem int // redis version 7.0, memory is used up by buffered multi commands + BufferSize int // rbs, usable size of buffer + BufferPeak int // rbp, peak used size of buffer in last 5 sec interval + OutputBufferLength int // obl, output buffer length + OutputListLength int // oll, output list length (replies are queued in this list when the buffer is full) + OutputMemory int // omem, output buffer memory usage + TotalMemory int // tot-mem, total memory consumed by this client in its various buffers + TotalNetIn int // tot-net-in, total network input + TotalNetOut int // tot-net-out, total network output + TotalCmds int // tot-cmds, total number of commands processed + IoThread int // io-thread id + Events string // file descriptor events (see below) + LastCmd string // cmd, last command played + User string // the authenticated username of the client + Redir int64 // client id of current client tracking redirection + Resp int // redis version 7.0, client RESP protocol version + LibName string // redis version 7.2, client library name + LibVer string // redis version 7.2, client library version + ReadEvents uint64 // redis version 8.8, number of read events processed + AvgPipelineLenSum uint64 // redis version 8.8, sum of pipeline lengths + AvgPipelineLenCnt uint64 // redis version 8.8, count of pipeline operations +} + +type ClientInfoCmd struct { + baseCmd + + val *ClientInfo +} + +var _ Cmder = (*ClientInfoCmd)(nil) + +func NewClientInfoCmd(ctx context.Context, args ...interface{}) *ClientInfoCmd { + return &ClientInfoCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeClientInfo, + }, + } +} + +func (cmd *ClientInfoCmd) SetVal(val *ClientInfo) { + cmd.val = val +} + +func (cmd *ClientInfoCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *ClientInfoCmd) Val() *ClientInfo { + cmd.await() + return cmd.val +} + +func (cmd *ClientInfoCmd) Result() (*ClientInfo, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *ClientInfoCmd) readReply(rd *proto.Reader) (err error) { + txt, err := rd.ReadString() + if err != nil { + return err + } + + // sds o = catClientInfoString(sdsempty(), c); + // o = sdscatlen(o,"\n",1); + // addReplyVerbatim(c,o,sdslen(o),"txt"); + // sdsfree(o); + cmd.val, err = parseClientInfo(strings.TrimSpace(txt)) + return err +} + +// fmt.Sscanf() cannot handle null values +func parseClientInfo(txt string) (info *ClientInfo, err error) { + info = &ClientInfo{} + for _, s := range strings.Split(txt, " ") { + kv := strings.Split(s, "=") + if len(kv) != 2 { + return nil, fmt.Errorf("redis: unexpected client info data (%s)", s) + } + key, val := kv[0], kv[1] + + switch key { + case "id": + info.ID, err = strconv.ParseInt(val, 10, 64) + case "addr": + info.Addr = val + case "laddr": + info.LAddr = val + case "fd": + info.FD, err = strconv.ParseInt(val, 10, 64) + case "name": + info.Name = val + case "age": + var age int + if age, err = strconv.Atoi(val); err == nil { + info.Age = time.Duration(age) * time.Second + } + case "idle": + var idle int + if idle, err = strconv.Atoi(val); err == nil { + info.Idle = time.Duration(idle) * time.Second + } + case "flags": + if val == "N" { + break + } + + for i := 0; i < len(val); i++ { + switch val[i] { + case 'S': + info.Flags |= ClientSlave + case 'O': + info.Flags |= ClientSlave | ClientMonitor + case 'M': + info.Flags |= ClientMaster + case 'P': + info.Flags |= ClientPubSub + case 'x': + info.Flags |= ClientMulti + case 'b': + info.Flags |= ClientBlocked + case 't': + info.Flags |= ClientTracking + case 'R': + info.Flags |= ClientTrackingBrokenRedir + case 'B': + info.Flags |= ClientTrackingBCAST + case 'd': + info.Flags |= ClientDirtyCAS + case 'c': + info.Flags |= ClientCloseAfterCommand + case 'u': + info.Flags |= ClientUnBlocked + case 'A': + info.Flags |= ClientCloseASAP + case 'U': + info.Flags |= ClientUnixSocket + case 'r': + info.Flags |= ClientReadOnly + case 'e': + info.Flags |= ClientNoEvict + case 'T': + info.Flags |= ClientNoTouch + default: + return nil, fmt.Errorf("redis: unexpected client info flags(%s)", string(val[i])) + } + } + case "db": + info.DB, err = strconv.Atoi(val) + case "sub": + info.Sub, err = strconv.Atoi(val) + case "psub": + info.PSub, err = strconv.Atoi(val) + case "ssub": + info.SSub, err = strconv.Atoi(val) + case "multi": + info.Multi, err = strconv.Atoi(val) + case "watch": + info.Watch, err = strconv.Atoi(val) + case "qbuf": + info.QueryBuf, err = strconv.Atoi(val) + case "qbuf-free": + info.QueryBufFree, err = strconv.Atoi(val) + case "argv-mem": + info.ArgvMem, err = strconv.Atoi(val) + case "multi-mem": + info.MultiMem, err = strconv.Atoi(val) + case "rbs": + info.BufferSize, err = strconv.Atoi(val) + case "rbp": + info.BufferPeak, err = strconv.Atoi(val) + case "obl": + info.OutputBufferLength, err = strconv.Atoi(val) + case "oll": + info.OutputListLength, err = strconv.Atoi(val) + case "omem": + info.OutputMemory, err = strconv.Atoi(val) + case "tot-mem": + info.TotalMemory, err = strconv.Atoi(val) + case "tot-net-in": + info.TotalNetIn, err = strconv.Atoi(val) + case "tot-net-out": + info.TotalNetOut, err = strconv.Atoi(val) + case "tot-cmds": + info.TotalCmds, err = strconv.Atoi(val) + case "events": + info.Events = val + case "cmd": + info.LastCmd = val + case "user": + info.User = val + case "redir": + info.Redir, err = strconv.ParseInt(val, 10, 64) + case "resp": + info.Resp, err = strconv.Atoi(val) + case "lib-name": + info.LibName = val + case "lib-ver": + info.LibVer = val + case "io-thread": + info.IoThread, err = strconv.Atoi(val) + case "read-events": + info.ReadEvents, err = strconv.ParseUint(val, 10, 64) + case "avg-pipeline-len-sum": + info.AvgPipelineLenSum, err = strconv.ParseUint(val, 10, 64) + case "avg-pipeline-len-cnt": + info.AvgPipelineLenCnt, err = strconv.ParseUint(val, 10, 64) + default: + // skip unknown fields + } + + if err != nil { + return nil, err + } + } + + return info, nil +} + +func (cmd *ClientInfoCmd) Clone() Cmder { + var val *ClientInfo + if cmd.val != nil { + val = &ClientInfo{ + ID: cmd.val.ID, + Addr: cmd.val.Addr, + LAddr: cmd.val.LAddr, + FD: cmd.val.FD, + Name: cmd.val.Name, + Age: cmd.val.Age, + Idle: cmd.val.Idle, + Flags: cmd.val.Flags, + DB: cmd.val.DB, + Sub: cmd.val.Sub, + PSub: cmd.val.PSub, + SSub: cmd.val.SSub, + Multi: cmd.val.Multi, + Watch: cmd.val.Watch, + QueryBuf: cmd.val.QueryBuf, + QueryBufFree: cmd.val.QueryBufFree, + ArgvMem: cmd.val.ArgvMem, + MultiMem: cmd.val.MultiMem, + BufferSize: cmd.val.BufferSize, + BufferPeak: cmd.val.BufferPeak, + OutputBufferLength: cmd.val.OutputBufferLength, + OutputListLength: cmd.val.OutputListLength, + OutputMemory: cmd.val.OutputMemory, + TotalMemory: cmd.val.TotalMemory, + IoThread: cmd.val.IoThread, + Events: cmd.val.Events, + LastCmd: cmd.val.LastCmd, + User: cmd.val.User, + Redir: cmd.val.Redir, + Resp: cmd.val.Resp, + LibName: cmd.val.LibName, + LibVer: cmd.val.LibVer, + ReadEvents: cmd.val.ReadEvents, + AvgPipelineLenSum: cmd.val.AvgPipelineLenSum, + AvgPipelineLenCnt: cmd.val.AvgPipelineLenCnt, + } + } + return &ClientInfoCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +// ------------------------------------------- + +type ACLLogEntry struct { + Count int64 + Reason string + Context string + Object string + Username string + AgeSeconds float64 + ClientInfo *ClientInfo + EntryID int64 + TimestampCreated int64 + TimestampLastUpdated int64 +} + +type ACLLogCmd struct { + baseCmd + + val []*ACLLogEntry +} + +var _ Cmder = (*ACLLogCmd)(nil) + +func NewACLLogCmd(ctx context.Context, args ...interface{}) *ACLLogCmd { + return &ACLLogCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeACLLog, + }, + } +} + +func (cmd *ACLLogCmd) SetVal(val []*ACLLogEntry) { + cmd.val = val +} + +func (cmd *ACLLogCmd) Val() []*ACLLogEntry { + cmd.await() + return cmd.val +} + +func (cmd *ACLLogCmd) Result() ([]*ACLLogEntry, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *ACLLogCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *ACLLogCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.val = make([]*ACLLogEntry, n) + for i := 0; i < n; i++ { + cmd.val[i] = &ACLLogEntry{} + entry := cmd.val[i] + respLen, err := rd.ReadMapLen() + if err != nil { + return err + } + for j := 0; j < respLen; j++ { + key, err := rd.ReadString() + if err != nil { + return err + } + + switch key { + case "count": + entry.Count, err = rd.ReadInt() + case "reason": + entry.Reason, err = rd.ReadString() + case "context": + entry.Context, err = rd.ReadString() + case "object": + entry.Object, err = rd.ReadString() + case "username": + entry.Username, err = rd.ReadString() + case "age-seconds": + entry.AgeSeconds, err = rd.ReadFloat() + case "client-info": + txt, err := rd.ReadString() + if err != nil { + return err + } + entry.ClientInfo, err = parseClientInfo(strings.TrimSpace(txt)) + if err != nil { + return err + } + case "entry-id": + entry.EntryID, err = rd.ReadInt() + case "timestamp-created": + entry.TimestampCreated, err = rd.ReadInt() + case "timestamp-last-updated": + entry.TimestampLastUpdated, err = rd.ReadInt() + default: + // skip unknown fields + if err := rd.DiscardNext(); err != nil { + return err + } + } + + if err != nil { + return err + } + } + } + + return nil +} + +func (cmd *ACLLogCmd) Clone() Cmder { + var val []*ACLLogEntry + if cmd.val != nil { + val = make([]*ACLLogEntry, len(cmd.val)) + for i, entry := range cmd.val { + if entry != nil { + val[i] = &ACLLogEntry{ + Count: entry.Count, + Reason: entry.Reason, + Context: entry.Context, + Object: entry.Object, + Username: entry.Username, + AgeSeconds: entry.AgeSeconds, + EntryID: entry.EntryID, + TimestampCreated: entry.TimestampCreated, + TimestampLastUpdated: entry.TimestampLastUpdated, + } + // Clone ClientInfo if present + if entry.ClientInfo != nil { + val[i].ClientInfo = &ClientInfo{ + ID: entry.ClientInfo.ID, + Addr: entry.ClientInfo.Addr, + LAddr: entry.ClientInfo.LAddr, + FD: entry.ClientInfo.FD, + Name: entry.ClientInfo.Name, + Age: entry.ClientInfo.Age, + Idle: entry.ClientInfo.Idle, + Flags: entry.ClientInfo.Flags, + DB: entry.ClientInfo.DB, + Sub: entry.ClientInfo.Sub, + PSub: entry.ClientInfo.PSub, + SSub: entry.ClientInfo.SSub, + Multi: entry.ClientInfo.Multi, + Watch: entry.ClientInfo.Watch, + QueryBuf: entry.ClientInfo.QueryBuf, + QueryBufFree: entry.ClientInfo.QueryBufFree, + ArgvMem: entry.ClientInfo.ArgvMem, + MultiMem: entry.ClientInfo.MultiMem, + BufferSize: entry.ClientInfo.BufferSize, + BufferPeak: entry.ClientInfo.BufferPeak, + OutputBufferLength: entry.ClientInfo.OutputBufferLength, + OutputListLength: entry.ClientInfo.OutputListLength, + OutputMemory: entry.ClientInfo.OutputMemory, + TotalMemory: entry.ClientInfo.TotalMemory, + IoThread: entry.ClientInfo.IoThread, + Events: entry.ClientInfo.Events, + LastCmd: entry.ClientInfo.LastCmd, + User: entry.ClientInfo.User, + Redir: entry.ClientInfo.Redir, + Resp: entry.ClientInfo.Resp, + LibName: entry.ClientInfo.LibName, + LibVer: entry.ClientInfo.LibVer, + ReadEvents: entry.ClientInfo.ReadEvents, + AvgPipelineLenSum: entry.ClientInfo.AvgPipelineLenSum, + AvgPipelineLenCnt: entry.ClientInfo.AvgPipelineLenCnt, + } + } + } + } + } + return &ACLLogCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +// LibraryInfo holds the library info. +type LibraryInfo struct { + LibName *string + LibVer *string +} + +// WithLibraryName returns a valid LibraryInfo with library name only. +func WithLibraryName(libName string) LibraryInfo { + return LibraryInfo{LibName: &libName} +} + +// WithLibraryVersion returns a valid LibraryInfo with library version only. +func WithLibraryVersion(libVer string) LibraryInfo { + return LibraryInfo{LibVer: &libVer} +} + +// ------------------------------------------- + +type InfoCmd struct { + baseCmd + val map[string]map[string]string +} + +var _ Cmder = (*InfoCmd)(nil) + +func NewInfoCmd(ctx context.Context, args ...interface{}) *InfoCmd { + return &InfoCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeInfo, + }, + } +} + +func (cmd *InfoCmd) SetVal(val map[string]map[string]string) { + cmd.val = val +} + +func (cmd *InfoCmd) Val() map[string]map[string]string { + cmd.await() + return cmd.val +} + +func (cmd *InfoCmd) Result() (map[string]map[string]string, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *InfoCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *InfoCmd) readReply(rd *proto.Reader) error { + val, err := rd.ReadString() + if err != nil { + return err + } + + section := "" + scanner := bufio.NewScanner(strings.NewReader(val)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "#") { + if cmd.val == nil { + cmd.val = make(map[string]map[string]string) + } + section = strings.TrimPrefix(line, "# ") + cmd.val[section] = make(map[string]string) + } else if line != "" { + if section == "Modules" { + moduleRe := regexp.MustCompile(`module:name=(.+?),(.+)$`) + kv := moduleRe.FindStringSubmatch(line) + if len(kv) == 3 { + cmd.val[section][kv[1]] = kv[2] + } + } else { + kv := strings.SplitN(line, ":", 2) + if len(kv) == 2 { + cmd.val[section][kv[0]] = kv[1] + } + } + } + } + + return nil +} + +func (cmd *InfoCmd) Item(section, key string) string { + cmd.await() + if cmd.val == nil { + return "" + } else if cmd.val[section] == nil { + return "" + } else { + return cmd.val[section][key] + } +} + +func (cmd *InfoCmd) Clone() Cmder { + var val map[string]map[string]string + if cmd.val != nil { + val = make(map[string]map[string]string, len(cmd.val)) + for section, sectionMap := range cmd.val { + if sectionMap != nil { + val[section] = make(map[string]string, len(sectionMap)) + for k, v := range sectionMap { + val[section][k] = v + } + } + } + } + return &InfoCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +type MonitorStatus int + +const ( + monitorStatusIdle MonitorStatus = iota + monitorStatusStart + monitorStatusStop +) + +type MonitorCmd struct { + baseCmd + ch chan string + status MonitorStatus + mu sync.Mutex +} + +func newMonitorCmd(ctx context.Context, ch chan string) *MonitorCmd { + return &MonitorCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: []interface{}{"monitor"}, + cmdType: CmdTypeMonitor, + }, + ch: ch, + status: monitorStatusIdle, + mu: sync.Mutex{}, + } +} + +func (cmd *MonitorCmd) String() string { + cmd.await() + return cmdString(cmd, nil) +} + +func (cmd *MonitorCmd) readReply(rd *proto.Reader) error { + ctx, cancel := context.WithCancel(cmd.ctx) + go func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + err := cmd.readMonitor(rd, cancel) + if err != nil { + cmd.err = err + return + } + } + } + }(ctx) + return nil +} + +func (cmd *MonitorCmd) readMonitor(rd *proto.Reader, cancel context.CancelFunc) error { + for { + cmd.mu.Lock() + st := cmd.status + pk, _ := rd.Peek(1) + cmd.mu.Unlock() + if len(pk) != 0 && st == monitorStatusStart { + cmd.mu.Lock() + line, err := rd.ReadString() + cmd.mu.Unlock() + if err != nil { + return err + } + cmd.ch <- line + } + if st == monitorStatusStop { + cancel() + break + } + } + return nil +} + +func (cmd *MonitorCmd) Start() { + cmd.mu.Lock() + defer cmd.mu.Unlock() + cmd.status = monitorStatusStart +} + +func (cmd *MonitorCmd) Stop() { + cmd.mu.Lock() + defer cmd.mu.Unlock() + cmd.status = monitorStatusStop +} + +type VectorScoreSliceCmd struct { + baseCmd + + val []VectorScore +} + +var _ Cmder = (*VectorScoreSliceCmd)(nil) + +func NewVectorScoreSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceCmd { + return &VectorScoreSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +// NewVectorInfoSliceCmd is an alias for NewVectorScoreSliceCmd kept for backwards compatibility. +func NewVectorInfoSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceCmd { + return NewVectorScoreSliceCmd(ctx, args...) +} + +func (cmd *VectorScoreSliceCmd) SetVal(val []VectorScore) { + cmd.val = val +} + +func (cmd *VectorScoreSliceCmd) Val() []VectorScore { + cmd.await() + return cmd.val +} + +func (cmd *VectorScoreSliceCmd) Result() ([]VectorScore, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *VectorScoreSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *VectorScoreSliceCmd) readReply(rd *proto.Reader) error { + typ, err := rd.PeekReplyType() + if err != nil { + return err + } + + var n int + if typ == proto.RespMap { + n, err = rd.ReadMapLen() + if err != nil { + return err + } + } else { + // RESP2 returns a flat array [name, score, name, score, ...] + n, err = rd.ReadArrayLen() + if err != nil { + return err + } + if n%2 != 0 { + return fmt.Errorf("redis: VectorScoreSliceCmd expects even number of elements, got %d", n) + } + n /= 2 + } + + cmd.val = make([]VectorScore, n) + for i := 0; i < n; i++ { + name, err := rd.ReadString() + if err != nil { + return err + } + cmd.val[i].Name = name + + score, err := rd.ReadFloat() + if err != nil { + return err + } + cmd.val[i].Score = score + } + + return nil +} + +func (cmd *VectorScoreSliceCmd) Clone() Cmder { + return &VectorScoreSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +// VectorScoreSliceSliceCmd is used for VLINKS WITHSCORES which returns an array of arrays. +// In RESP3, each inner array contains maps of element -> score. +type VectorScoreSliceSliceCmd struct { + baseCmd + + val [][]VectorScore +} + +var _ Cmder = (*VectorScoreSliceSliceCmd)(nil) + +func NewVectorScoreSliceSliceCmd(ctx context.Context, args ...any) *VectorScoreSliceSliceCmd { + return &VectorScoreSliceSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *VectorScoreSliceSliceCmd) SetVal(val [][]VectorScore) { + cmd.val = val +} + +func (cmd *VectorScoreSliceSliceCmd) Val() [][]VectorScore { + cmd.await() + return cmd.val +} + +func (cmd *VectorScoreSliceSliceCmd) Result() ([][]VectorScore, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *VectorScoreSliceSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *VectorScoreSliceSliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + + cmd.val = make([][]VectorScore, n) + for i := range n { + // Each level can be either a map (RESP3) or an array (RESP2) + levelTyp, err := rd.PeekReplyType() + if err != nil { + return err + } + + if levelTyp == proto.RespMap { + // RESP3 format: each level is a map {element: score, element: score, ...} + mapLen, err := rd.ReadMapLen() + if err != nil { + return err + } + + cmd.val[i] = make([]VectorScore, mapLen) + for j := range mapLen { + name, err := rd.ReadString() + if err != nil { + return err + } + score, err := rd.ReadFloat() + if err != nil { + return err + } + cmd.val[i][j] = VectorScore{Name: name, Score: score} + } + } else { + // RESP2 format: each level is an array of [element, score, element, score, ...] pairs + innerLen, err := rd.ReadArrayLen() + if err != nil { + return err + } + + if innerLen%2 != 0 { + return fmt.Errorf("redis: got %d elements in the VLINKS array, wanted a multiple of 2", innerLen) + } + + cmd.val[i] = make([]VectorScore, innerLen/2) + for j := 0; j < innerLen; j += 2 { + name, err := rd.ReadString() + if err != nil { + return err + } + score, err := rd.ReadFloat() + if err != nil { + return err + } + cmd.val[i][j/2] = VectorScore{Name: name, Score: score} + } + } + } + + return nil +} + +func (cmd *VectorScoreSliceSliceCmd) Clone() Cmder { + var val [][]VectorScore + if cmd.val != nil { + val = make([][]VectorScore, len(cmd.val)) + for i, slice := range cmd.val { + if slice != nil { + val[i] = make([]VectorScore, len(slice)) + copy(val[i], slice) + } + } + } + return &VectorScoreSliceSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} + +func readVectorAttribStringOrNil(rd *proto.Reader) (*string, error) { + v, err := rd.ReadReply() + if err != nil { + if err == proto.Nil { + return nil, nil + } + return nil, err + } + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("redis: can't parse reply=%T reading string", v) + } + return &s, nil +} + +type VectorAttribSliceCmd struct { + baseCmd + + val []VectorAttrib +} + +var _ Cmder = (*VectorAttribSliceCmd)(nil) + +func NewVectorAttribSliceCmd(ctx context.Context, args ...any) *VectorAttribSliceCmd { + return &VectorAttribSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *VectorAttribSliceCmd) SetVal(val []VectorAttrib) { + cmd.val = val +} + +func (cmd *VectorAttribSliceCmd) Val() []VectorAttrib { + cmd.await() + return cmd.val +} + +func (cmd *VectorAttribSliceCmd) Result() ([]VectorAttrib, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *VectorAttribSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *VectorAttribSliceCmd) readReply(rd *proto.Reader) error { + replyType, err := rd.PeekReplyType() + if err != nil { + return err + } + + if replyType == proto.RespMap { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + cmd.val = make([]VectorAttrib, n) + for i := 0; i < n; i++ { + name, err := rd.ReadString() + if err != nil { + return err + } + attrib, err := readVectorAttribStringOrNil(rd) + if err != nil { + return err + } + cmd.val[i] = VectorAttrib{Name: name, Attribs: attrib} + } + return nil + } + + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + if n%2 != 0 { + return fmt.Errorf("redis: got %d elements in the VSIM array, wanted a multiple of 2", n) + } + cmd.val = make([]VectorAttrib, n/2) + for i := range cmd.val { + name, err := rd.ReadString() + if err != nil { + return err + } + attrib, err := readVectorAttribStringOrNil(rd) + if err != nil { + return err + } + cmd.val[i] = VectorAttrib{Name: name, Attribs: attrib} + } + return nil +} + +func (cmd *VectorAttribSliceCmd) Clone() Cmder { + return &VectorAttribSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +type VectorScoreAttribSliceCmd struct { + baseCmd + + val []VectorScoreAttrib +} + +var _ Cmder = (*VectorScoreAttribSliceCmd)(nil) + +func NewVectorScoreAttribSliceCmd(ctx context.Context, args ...any) *VectorScoreAttribSliceCmd { + return &VectorScoreAttribSliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + }, + } +} + +func (cmd *VectorScoreAttribSliceCmd) SetVal(val []VectorScoreAttrib) { + cmd.val = val +} + +func (cmd *VectorScoreAttribSliceCmd) Val() []VectorScoreAttrib { + cmd.await() + return cmd.val +} + +func (cmd *VectorScoreAttribSliceCmd) Result() ([]VectorScoreAttrib, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *VectorScoreAttribSliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *VectorScoreAttribSliceCmd) readReply(rd *proto.Reader) error { + replyType, err := rd.PeekReplyType() + if err != nil { + return err + } + + if replyType == proto.RespMap { + n, err := rd.ReadMapLen() + if err != nil { + return err + } + cmd.val = make([]VectorScoreAttrib, n) + for i := 0; i < n; i++ { + name, err := rd.ReadString() + if err != nil { + return err + } + if err := rd.ReadFixedArrayLen(2); err != nil { + return err + } + score, err := rd.ReadFloat() + if err != nil { + return err + } + attrib, err := readVectorAttribStringOrNil(rd) + if err != nil { + return err + } + cmd.val[i] = VectorScoreAttrib{Name: name, Score: score, Attribs: attrib} + } + return nil + } + + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + if n%3 != 0 { + return fmt.Errorf("redis: got %d elements in the VSIM array, wanted a multiple of 3", n) + } + cmd.val = make([]VectorScoreAttrib, n/3) + for i := range cmd.val { + name, err := rd.ReadString() + if err != nil { + return err + } + score, err := rd.ReadFloat() + if err != nil { + return err + } + attrib, err := readVectorAttribStringOrNil(rd) + if err != nil { + return err + } + cmd.val[i] = VectorScoreAttrib{Name: name, Score: score, Attribs: attrib} + } + return nil +} + +func (cmd *VectorScoreAttribSliceCmd) Clone() Cmder { + return &VectorScoreAttribSliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +func (cmd *MonitorCmd) Clone() Cmder { + // MonitorCmd cannot be safely cloned due to channels and goroutines + // Return a new MonitorCmd with the same channel + return newMonitorCmd(cmd.ctx, cmd.ch) +} + +// ExtractCommandValue extracts the value from a command result using the fast enum-based approach +func ExtractCommandValue(cmd interface{}) (interface{}, error) { + // First try to get the command type using the interface + if cmdTypeGetter, ok := cmd.(CmdTypeGetter); ok { + cmdType := cmdTypeGetter.GetCmdType() + + // Use fast type-based extraction + switch cmdType { + case CmdTypeGeneric: + if genericCmd, ok := cmd.(interface { + Val() interface{} + Err() error + }); ok { + return genericCmd.Val(), genericCmd.Err() + } + case CmdTypeString: + if stringCmd, ok := cmd.(interface { + Val() string + Err() error + }); ok { + return stringCmd.Val(), stringCmd.Err() + } + case CmdTypeInt: + if intCmd, ok := cmd.(interface { + Val() int64 + Err() error + }); ok { + return intCmd.Val(), intCmd.Err() + } + case CmdTypeUint: + if uintCmd, ok := cmd.(interface { + Val() uint64 + Err() error + }); ok { + return uintCmd.Val(), uintCmd.Err() + } + case CmdTypeBool: + if boolCmd, ok := cmd.(interface { + Val() bool + Err() error + }); ok { + return boolCmd.Val(), boolCmd.Err() + } + case CmdTypeFloat: + if floatCmd, ok := cmd.(interface { + Val() float64 + Err() error + }); ok { + return floatCmd.Val(), floatCmd.Err() + } + case CmdTypeStatus: + if statusCmd, ok := cmd.(interface { + Val() string + Err() error + }); ok { + return statusCmd.Val(), statusCmd.Err() + } + case CmdTypeDuration: + if durationCmd, ok := cmd.(interface { + Val() time.Duration + Err() error + }); ok { + return durationCmd.Val(), durationCmd.Err() + } + case CmdTypeTime: + if timeCmd, ok := cmd.(interface { + Val() time.Time + Err() error + }); ok { + return timeCmd.Val(), timeCmd.Err() + } + case CmdTypeStringStructMap: + if structMapCmd, ok := cmd.(interface { + Val() map[string]struct{} + Err() error + }); ok { + return structMapCmd.Val(), structMapCmd.Err() + } + case CmdTypeXMessageSlice: + if xMessageSliceCmd, ok := cmd.(interface { + Val() []XMessage + Err() error + }); ok { + return xMessageSliceCmd.Val(), xMessageSliceCmd.Err() + } + case CmdTypeXStreamSlice: + if xStreamSliceCmd, ok := cmd.(interface { + Val() []XStream + Err() error + }); ok { + return xStreamSliceCmd.Val(), xStreamSliceCmd.Err() + } + case CmdTypeXPending: + if xPendingCmd, ok := cmd.(interface { + Val() *XPending + Err() error + }); ok { + return xPendingCmd.Val(), xPendingCmd.Err() + } + case CmdTypeXPendingExt: + if xPendingExtCmd, ok := cmd.(interface { + Val() []XPendingExt + Err() error + }); ok { + return xPendingExtCmd.Val(), xPendingExtCmd.Err() + } + case CmdTypeXAutoClaim: + if xAutoClaimCmd, ok := cmd.(interface { + Val() ([]XMessage, string) + Err() error + }); ok { + messages, start := xAutoClaimCmd.Val() + return CmdTypeXAutoClaimValue{messages: messages, start: start}, xAutoClaimCmd.Err() + } + case CmdTypeXAutoClaimWithDeleted: + if xAutoClaimWithDeletedCmd, ok := cmd.(interface { + Val() ([]XMessage, string, []string) + Err() error + }); ok { + messages, start, deletedIDs := xAutoClaimWithDeletedCmd.Val() + return CmdTypeXAutoClaimWithDeletedValue{messages: messages, start: start, deletedIDs: deletedIDs}, xAutoClaimWithDeletedCmd.Err() + } + case CmdTypeXAutoClaimJustID: + if xAutoClaimJustIDCmd, ok := cmd.(interface { + Val() ([]string, string) + Err() error + }); ok { + ids, start := xAutoClaimJustIDCmd.Val() + return CmdTypeXAutoClaimJustIDValue{ids: ids, start: start}, xAutoClaimJustIDCmd.Err() + } + case CmdTypeXInfoConsumers: + if xInfoConsumersCmd, ok := cmd.(interface { + Val() []XInfoConsumer + Err() error + }); ok { + return xInfoConsumersCmd.Val(), xInfoConsumersCmd.Err() + } + case CmdTypeXInfoGroups: + if xInfoGroupsCmd, ok := cmd.(interface { + Val() []XInfoGroup + Err() error + }); ok { + return xInfoGroupsCmd.Val(), xInfoGroupsCmd.Err() + } + case CmdTypeXInfoStream: + if xInfoStreamCmd, ok := cmd.(interface { + Val() *XInfoStream + Err() error + }); ok { + return xInfoStreamCmd.Val(), xInfoStreamCmd.Err() + } + case CmdTypeXInfoStreamFull: + if xInfoStreamFullCmd, ok := cmd.(interface { + Val() *XInfoStreamFull + Err() error + }); ok { + return xInfoStreamFullCmd.Val(), xInfoStreamFullCmd.Err() + } + case CmdTypeZSlice: + if zSliceCmd, ok := cmd.(interface { + Val() []Z + Err() error + }); ok { + return zSliceCmd.Val(), zSliceCmd.Err() + } + case CmdTypeZWithKey: + if zWithKeyCmd, ok := cmd.(interface { + Val() *ZWithKey + Err() error + }); ok { + return zWithKeyCmd.Val(), zWithKeyCmd.Err() + } + case CmdTypeScan: + if scanCmd, ok := cmd.(interface { + Val() ([]string, uint64) + Err() error + }); ok { + keys, cursor := scanCmd.Val() + return CmdTypeScanValue{keys: keys, cursor: cursor}, scanCmd.Err() + } + case CmdTypeClusterSlots: + if clusterSlotsCmd, ok := cmd.(interface { + Val() []ClusterSlot + Err() error + }); ok { + return clusterSlotsCmd.Val(), clusterSlotsCmd.Err() + } + case CmdTypeGeoLocation: + if geoLocationCmd, ok := cmd.(interface { + Val() []GeoLocation + Err() error + }); ok { + return geoLocationCmd.Val(), geoLocationCmd.Err() + } + case CmdTypeGeoSearchLocation: + if geoSearchLocationCmd, ok := cmd.(interface { + Val() []GeoLocation + Err() error + }); ok { + return geoSearchLocationCmd.Val(), geoSearchLocationCmd.Err() + } + case CmdTypeGeoPos: + if geoPosCmd, ok := cmd.(interface { + Val() []*GeoPos + Err() error + }); ok { + return geoPosCmd.Val(), geoPosCmd.Err() + } + case CmdTypeCommandsInfo: + if commandsInfoCmd, ok := cmd.(interface { + Val() map[string]*CommandInfo + Err() error + }); ok { + return commandsInfoCmd.Val(), commandsInfoCmd.Err() + } + case CmdTypeSlowLog: + if slowLogCmd, ok := cmd.(interface { + Val() []SlowLog + Err() error + }); ok { + return slowLogCmd.Val(), slowLogCmd.Err() + } + case CmdTypeHotKeys: + if hotKeysCmd, ok := cmd.(interface { + Val() *HotKeysResult + Err() error + }); ok { + return hotKeysCmd.Val(), hotKeysCmd.Err() + } + case CmdTypeIncrEXInt: + if incrEXCmd, ok := cmd.(interface { + Val() IncrEXIntResult + Err() error + }); ok { + return incrEXCmd.Val(), incrEXCmd.Err() + } + case CmdTypeIncrEXFloat: + if incrEXCmd, ok := cmd.(interface { + Val() IncrEXFloatResult + Err() error + }); ok { + return incrEXCmd.Val(), incrEXCmd.Err() + } + case CmdTypeKeyValues: + if keyValuesCmd, ok := cmd.(interface { + Val() (string, []string) + Err() error + }); ok { + key, values := keyValuesCmd.Val() + return CmdTypeKeyValuesValue{key: key, values: values}, keyValuesCmd.Err() + } + case CmdTypeZSliceWithKey: + if zSliceWithKeyCmd, ok := cmd.(interface { + Val() (string, []Z) + Err() error + }); ok { + key, zSlice := zSliceWithKeyCmd.Val() + return CmdTypeZSliceWithKeyValue{key: key, zSlice: zSlice}, zSliceWithKeyCmd.Err() + } + case CmdTypeFunctionList: + if functionListCmd, ok := cmd.(interface { + Val() []Library + Err() error + }); ok { + return functionListCmd.Val(), functionListCmd.Err() + } + case CmdTypeFunctionStats: + if functionStatsCmd, ok := cmd.(interface { + Val() FunctionStats + Err() error + }); ok { + return functionStatsCmd.Val(), functionStatsCmd.Err() + } + case CmdTypeLCS: + if lcsCmd, ok := cmd.(interface { + Val() *LCSMatch + Err() error + }); ok { + return lcsCmd.Val(), lcsCmd.Err() + } + case CmdTypeKeyFlags: + if keyFlagsCmd, ok := cmd.(interface { + Val() []KeyFlags + Err() error + }); ok { + return keyFlagsCmd.Val(), keyFlagsCmd.Err() + } + case CmdTypeClusterLinks: + if clusterLinksCmd, ok := cmd.(interface { + Val() []ClusterLink + Err() error + }); ok { + return clusterLinksCmd.Val(), clusterLinksCmd.Err() + } + case CmdTypeClusterShards: + if clusterShardsCmd, ok := cmd.(interface { + Val() []ClusterShard + Err() error + }); ok { + return clusterShardsCmd.Val(), clusterShardsCmd.Err() + } + case CmdTypeRankWithScore: + if rankWithScoreCmd, ok := cmd.(interface { + Val() RankScore + Err() error + }); ok { + return rankWithScoreCmd.Val(), rankWithScoreCmd.Err() + } + case CmdTypeClientInfo: + if clientInfoCmd, ok := cmd.(interface { + Val() *ClientInfo + Err() error + }); ok { + return clientInfoCmd.Val(), clientInfoCmd.Err() + } + case CmdTypeACLLog: + if aclLogCmd, ok := cmd.(interface { + Val() []*ACLLogEntry + Err() error + }); ok { + return aclLogCmd.Val(), aclLogCmd.Err() + } + case CmdTypeInfo: + if infoCmd, ok := cmd.(interface { + Val() string + Err() error + }); ok { + return infoCmd.Val(), infoCmd.Err() + } + case CmdTypeMonitor: + if monitorCmd, ok := cmd.(interface { + Val() string + Err() error + }); ok { + return monitorCmd.Val(), monitorCmd.Err() + } + case CmdTypeJSON: + if jsonCmd, ok := cmd.(interface { + Val() string + Err() error + }); ok { + return jsonCmd.Val(), jsonCmd.Err() + } + case CmdTypeJSONSlice: + if jsonSliceCmd, ok := cmd.(interface { + Val() []interface{} + Err() error + }); ok { + return jsonSliceCmd.Val(), jsonSliceCmd.Err() + } + case CmdTypeIntPointerSlice: + if intPointerSliceCmd, ok := cmd.(interface { + Val() []*int64 + Err() error + }); ok { + return intPointerSliceCmd.Val(), intPointerSliceCmd.Err() + } + case CmdTypeScanDump: + if scanDumpCmd, ok := cmd.(interface { + Val() ScanDump + Err() error + }); ok { + return scanDumpCmd.Val(), scanDumpCmd.Err() + } + case CmdTypeBFInfo: + if bfInfoCmd, ok := cmd.(interface { + Val() BFInfo + Err() error + }); ok { + return bfInfoCmd.Val(), bfInfoCmd.Err() + } + case CmdTypeCFInfo: + if cfInfoCmd, ok := cmd.(interface { + Val() CFInfo + Err() error + }); ok { + return cfInfoCmd.Val(), cfInfoCmd.Err() + } + case CmdTypeCMSInfo: + if cmsInfoCmd, ok := cmd.(interface { + Val() CMSInfo + Err() error + }); ok { + return cmsInfoCmd.Val(), cmsInfoCmd.Err() + } + case CmdTypeTopKInfo: + if topKInfoCmd, ok := cmd.(interface { + Val() TopKInfo + Err() error + }); ok { + return topKInfoCmd.Val(), topKInfoCmd.Err() + } + case CmdTypeTDigestInfo: + if tDigestInfoCmd, ok := cmd.(interface { + Val() TDigestInfo + Err() error + }); ok { + return tDigestInfoCmd.Val(), tDigestInfoCmd.Err() + } + case CmdTypeFTSearch: + if ftSearchCmd, ok := cmd.(interface { + Val() FTSearchResult + Err() error + }); ok { + return ftSearchCmd.Val(), ftSearchCmd.Err() + } + case CmdTypeFTInfo: + if ftInfoCmd, ok := cmd.(interface { + Val() FTInfoResult + Err() error + }); ok { + return ftInfoCmd.Val(), ftInfoCmd.Err() + } + case CmdTypeFTSpellCheck: + if ftSpellCheckCmd, ok := cmd.(interface { + Val() []SpellCheckResult + Err() error + }); ok { + return ftSpellCheckCmd.Val(), ftSpellCheckCmd.Err() + } + case CmdTypeFTSynDump: + if ftSynDumpCmd, ok := cmd.(interface { + Val() []FTSynDumpResult + Err() error + }); ok { + return ftSynDumpCmd.Val(), ftSynDumpCmd.Err() + } + case CmdTypeAggregate: + if aggregateCmd, ok := cmd.(interface { + Val() *FTAggregateResult + Err() error + }); ok { + return aggregateCmd.Val(), aggregateCmd.Err() + } + case CmdTypeTSTimestampValue: + if tsTimestampValueCmd, ok := cmd.(interface { + Val() TSTimestampValue + Err() error + }); ok { + return tsTimestampValueCmd.Val(), tsTimestampValueCmd.Err() + } + case CmdTypeTSTimestampValueSlice: + if tsTimestampValueSliceCmd, ok := cmd.(interface { + Val() []TSTimestampValue + Err() error + }); ok { + return tsTimestampValueSliceCmd.Val(), tsTimestampValueSliceCmd.Err() + } + case CmdTypeTSNRangePivotRowSlice: + if tsNRangePivotRowSliceCmd, ok := cmd.(interface { + Val() []TSNRangePivotRow + Err() error + }); ok { + return tsNRangePivotRowSliceCmd.Val(), tsNRangePivotRowSliceCmd.Err() + } + case CmdTypeStringSlice: + if stringSliceCmd, ok := cmd.(interface { + Val() []string + Err() error + }); ok { + return stringSliceCmd.Val(), stringSliceCmd.Err() + } + case CmdTypeIntSlice: + if intSliceCmd, ok := cmd.(interface { + Val() []int64 + Err() error + }); ok { + return intSliceCmd.Val(), intSliceCmd.Err() + } + case CmdTypeUintSlice: + if uintSliceCmd, ok := cmd.(interface { + Val() []uint64 + Err() error + }); ok { + return uintSliceCmd.Val(), uintSliceCmd.Err() + } + case CmdTypeBoolSlice: + if boolSliceCmd, ok := cmd.(interface { + Val() []bool + Err() error + }); ok { + return boolSliceCmd.Val(), boolSliceCmd.Err() + } + case CmdTypeFloatSlice: + if floatSliceCmd, ok := cmd.(interface { + Val() []float64 + Err() error + }); ok { + return floatSliceCmd.Val(), floatSliceCmd.Err() + } + case CmdTypeSlice: + if sliceCmd, ok := cmd.(interface { + Val() []interface{} + Err() error + }); ok { + return sliceCmd.Val(), sliceCmd.Err() + } + case CmdTypeKeyValueSlice: + if keyValueSliceCmd, ok := cmd.(interface { + Val() []KeyValue + Err() error + }); ok { + return keyValueSliceCmd.Val(), keyValueSliceCmd.Err() + } + case CmdTypeAREntrySlice: + if arEntrySliceCmd, ok := cmd.(interface { + Val() []AREntry + Err() error + }); ok { + return arEntrySliceCmd.Val(), arEntrySliceCmd.Err() + } + case CmdTypeMapStringString: + if mapCmd, ok := cmd.(interface { + Val() map[string]string + Err() error + }); ok { + return mapCmd.Val(), mapCmd.Err() + } + case CmdTypeMapStringInt: + if mapCmd, ok := cmd.(interface { + Val() map[string]int64 + Err() error + }); ok { + return mapCmd.Val(), mapCmd.Err() + } + case CmdTypeMapStringInterfaceSlice: + if mapCmd, ok := cmd.(interface { + Val() []map[string]interface{} + Err() error + }); ok { + return mapCmd.Val(), mapCmd.Err() + } + case CmdTypeMapStringInterface: + if mapCmd, ok := cmd.(interface { + Val() map[string]interface{} + Err() error + }); ok { + return mapCmd.Val(), mapCmd.Err() + } + case CmdTypeMapStringStringSlice: + if mapCmd, ok := cmd.(interface { + Val() []map[string]string + Err() error + }); ok { + return mapCmd.Val(), mapCmd.Err() + } + case CmdTypeMapMapStringInterface: + if mapCmd, ok := cmd.(interface { + Val() map[string]interface{} + Err() error + }); ok { + return mapCmd.Val(), mapCmd.Err() + } + default: + // For unknown command types, return nil + return nil, nil + } + } + + // If we can't get the command type, return nil + return nil, nil +} + +//------------------------------------------------------------------------------ + +// IncrEXIntResult is the reply of an INCREX command issued via IncrEXInt. +// Value is the new value of the key; AppliedIncrement is the increment that +// the server actually applied (0 when an out-of-bounds operation was +// rejected, clamped when SATURATE was set). +type IncrEXIntResult struct { + Value int64 + AppliedIncrement int64 +} + +type IncrEXIntCmd struct { + baseCmd + + val IncrEXIntResult +} + +var _ Cmder = (*IncrEXIntCmd)(nil) + +func NewIncrEXIntCmd(ctx context.Context, args ...interface{}) *IncrEXIntCmd { + return &IncrEXIntCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeIncrEXInt, + }, + } +} + +func (cmd *IncrEXIntCmd) SetVal(val IncrEXIntResult) { cmd.val = val } +func (cmd *IncrEXIntCmd) Val() IncrEXIntResult { + cmd.await() + return cmd.val +} +func (cmd *IncrEXIntCmd) Result() (IncrEXIntResult, error) { + cmd.await() + return cmd.val, cmd.err +} +func (cmd *IncrEXIntCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *IncrEXIntCmd) readReply(rd *proto.Reader) error { + if err := rd.ReadFixedArrayLen(2); err != nil { + return err + } + value, err := rd.ReadInt() + if err != nil { + return err + } + applied, err := rd.ReadInt() + if err != nil { + return err + } + cmd.val = IncrEXIntResult{Value: value, AppliedIncrement: applied} + return nil +} + +func (cmd *IncrEXIntCmd) Clone() Cmder { + return &IncrEXIntCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +// IncrEXFloatResult is the reply of an INCREX command issued via IncrEXFloat. +type IncrEXFloatResult struct { + Value float64 + AppliedIncrement float64 +} + +type IncrEXFloatCmd struct { + baseCmd + + val IncrEXFloatResult +} + +var _ Cmder = (*IncrEXFloatCmd)(nil) + +func NewIncrEXFloatCmd(ctx context.Context, args ...interface{}) *IncrEXFloatCmd { + return &IncrEXFloatCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeIncrEXFloat, + }, + } +} + +func (cmd *IncrEXFloatCmd) SetVal(val IncrEXFloatResult) { cmd.val = val } +func (cmd *IncrEXFloatCmd) Val() IncrEXFloatResult { + cmd.await() + return cmd.val +} +func (cmd *IncrEXFloatCmd) Result() (IncrEXFloatResult, error) { + cmd.await() + return cmd.val, cmd.err +} +func (cmd *IncrEXFloatCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *IncrEXFloatCmd) readReply(rd *proto.Reader) error { + if err := rd.ReadFixedArrayLen(2); err != nil { + return err + } + value, err := rd.ReadFloat() + if err != nil { + return err + } + applied, err := rd.ReadFloat() + if err != nil { + return err + } + cmd.val = IncrEXFloatResult{Value: value, AppliedIncrement: applied} + return nil +} + +func (cmd *IncrEXFloatCmd) Clone() Cmder { + return &IncrEXFloatCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: cmd.val, + } +} + +//------------------------------------------------------------------------------ + +// AREntrySliceCmd is a command that returns index-value pairs from ARSCAN or ARGREP. +type AREntrySliceCmd struct { + baseCmd + val []AREntry +} + +var _ Cmder = (*AREntrySliceCmd)(nil) + +func NewAREntrySliceCmd(ctx context.Context, args ...any) *AREntrySliceCmd { + return &AREntrySliceCmd{ + baseCmd: baseCmd{ + ctx: ctx, + args: args, + cmdType: CmdTypeAREntrySlice, + }, + } +} + +func (cmd *AREntrySliceCmd) SetVal(val []AREntry) { + cmd.val = val +} + +func (cmd *AREntrySliceCmd) Val() []AREntry { + cmd.await() + return cmd.val +} + +func (cmd *AREntrySliceCmd) Result() ([]AREntry, error) { + cmd.await() + return cmd.val, cmd.err +} + +func (cmd *AREntrySliceCmd) String() string { + cmd.await() + return cmdString(cmd, cmd.val) +} + +func (cmd *AREntrySliceCmd) readReply(rd *proto.Reader) error { + n, err := rd.ReadArrayLen() + if err != nil { + return err + } + if n == 0 { + cmd.val = make([]AREntry, 0) + return nil + } + + cmd.val = make([]AREntry, n) + for i := range n { + if err = rd.ReadFixedArrayLen(2); err != nil { + return err + } + + cmd.val[i].Index, err = rd.ReadUint() + if err != nil { + return err + } + + cmd.val[i].Value, err = rd.ReadString() + if err != nil { + return err + } + } + return nil +} + +func (cmd *AREntrySliceCmd) Clone() Cmder { + var val []AREntry + if cmd.val != nil { + val = make([]AREntry, len(cmd.val)) + copy(val, cmd.val) + } + return &AREntrySliceCmd{ + baseCmd: cmd.cloneBaseCmd(), + val: val, + } +} diff --git a/vendor/github.com/redis/go-redis/v9/command_policy_resolver.go b/vendor/github.com/redis/go-redis/v9/command_policy_resolver.go new file mode 100644 index 0000000000..11fc5d1c74 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/command_policy_resolver.go @@ -0,0 +1,236 @@ +package redis + +import ( + "context" + "strings" + + "github.com/redis/go-redis/v9/internal/routing" +) + +type ( + module = string + commandName = string +) + +var defaultPolicies = map[module]map[commandName]*routing.CommandPolicy{ + "ft": { + "create": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + }, + "search": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "aggregate": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "dictadd": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + }, + "dictdump": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "dictdel": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + }, + "suglen": { + Request: routing.ReqDefault, + Response: routing.RespDefaultHashSlot, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "cursor": { + Request: routing.ReqSpecial, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "sugadd": { + Request: routing.ReqDefault, + Response: routing.RespDefaultHashSlot, + }, + "sugget": { + Request: routing.ReqDefault, + Response: routing.RespDefaultHashSlot, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "sugdel": { + Request: routing.ReqDefault, + Response: routing.RespDefaultHashSlot, + }, + "spellcheck": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "explain": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "explaincli": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "aliasadd": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + }, + "aliasupdate": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + }, + "aliasdel": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + }, + "aliaslist": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "info": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "tagvals": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "syndump": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "synupdate": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + }, + "profile": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + Tips: map[string]string{ + routing.ReadOnlyCMD: "", + }, + }, + "alter": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + }, + "dropindex": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + }, + "drop": { + Request: routing.ReqDefault, + Response: routing.RespDefaultKeyless, + }, + }, +} + +// defaultPolicyKeyless reports whether name (e.g. "ft.aliaslist") is registered +// in the static policy table as a plain keyless command: default request +// routing with a keyless response policy. Commands whose slot comes from a key +// (RespDefaultHashSlot, e.g. ft.suglen) or with special request routing +// (ReqSpecial, e.g. ft.cursor) are excluded — their key position must still be +// resolved. cmdFirstKeyPosWithInfo consults this so the initial slot +// computation on a cold command-info cache matches the policy the router +// applies once the command reaches routeAndRun. +func defaultPolicyKeyless(name string) bool { + i := strings.IndexByte(name, '.') + if i < 0 { + return false + } + policy, ok := defaultPolicies[name[:i]][name[i+1:]] + if !ok { + return false + } + return policy.Request == routing.ReqDefault && policy.Response == routing.RespDefaultKeyless +} + +type CommandInfoResolveFunc func(ctx context.Context, cmd Cmder) *routing.CommandPolicy + +type commandInfoResolver struct { + resolveFunc CommandInfoResolveFunc + fallBackResolver *commandInfoResolver +} + +func NewCommandInfoResolver(resolveFunc CommandInfoResolveFunc) *commandInfoResolver { + return &commandInfoResolver{ + resolveFunc: resolveFunc, + } +} + +func NewDefaultCommandPolicyResolver() *commandInfoResolver { + return NewCommandInfoResolver(func(ctx context.Context, cmd Cmder) *routing.CommandPolicy { + module := "core" + command := cmd.Name() + cmdParts := strings.Split(command, ".") + if len(cmdParts) == 2 { + module = cmdParts[0] + command = cmdParts[1] + } + + if policy, ok := defaultPolicies[module][command]; ok { + return policy + } + + return nil + }) +} + +func (r *commandInfoResolver) GetCommandPolicy(ctx context.Context, cmd Cmder) *routing.CommandPolicy { + if r.resolveFunc == nil { + return nil + } + + policy := r.resolveFunc(ctx, cmd) + if policy != nil { + return policy + } + + if r.fallBackResolver != nil { + return r.fallBackResolver.GetCommandPolicy(ctx, cmd) + } + + return nil +} + +func (r *commandInfoResolver) SetFallbackResolver(fallbackResolver *commandInfoResolver) { + r.fallBackResolver = fallbackResolver +} diff --git a/vendor/github.com/redis/go-redis/v9/commands.go b/vendor/github.com/redis/go-redis/v9/commands.go new file mode 100644 index 0000000000..c574f6e6e3 --- /dev/null +++ b/vendor/github.com/redis/go-redis/v9/commands.go @@ -0,0 +1,918 @@ +package redis + +import ( + "context" + "encoding" + "errors" + "fmt" + "io" + "net" + "reflect" + "runtime" + "strings" + "time" + + "github.com/redis/go-redis/v9/internal" +) + +// KeepTTL is a Redis KEEPTTL option to keep existing TTL, it requires your redis-server version >= 6.0, +// otherwise you will receive an error: (error) ERR syntax error. +// For example: +// +// rdb.Set(ctx, key, value, redis.KeepTTL) +const KeepTTL = -1 + +func usePrecise(dur time.Duration) bool { + return dur < time.Second || dur%time.Second != 0 +} + +func formatMs(ctx context.Context, dur time.Duration) int64 { + if dur > 0 && dur < time.Millisecond { + internal.Logger.Printf( + ctx, + "specified duration is %s, but minimal supported value is %s - truncating to 1ms", + dur, time.Millisecond, + ) + return 1 + } + return int64(dur / time.Millisecond) +} + +func formatSec(ctx context.Context, dur time.Duration) int64 { + if dur > 0 && dur < time.Second { + internal.Logger.Printf( + ctx, + "specified duration is %s, but minimal supported value is %s - truncating to 1s", + dur, time.Second, + ) + return 1 + } + return int64(dur / time.Second) +} + +func appendArgs(dst, src []interface{}) []interface{} { + if len(src) == 1 { + return appendArg(dst, src[0]) + } + + if cap(dst) < len(dst)+len(src) { + newDst := make([]interface{}, len(dst), len(dst)+len(src)) + copy(newDst, dst) + dst = newDst + } + dst = append(dst, src...) + return dst +} + +func appendArg(dst []interface{}, arg interface{}) []interface{} { + switch arg := arg.(type) { + case []string: + for _, s := range arg { + dst = append(dst, s) + } + return dst + case []interface{}: + dst = append(dst, arg...) + return dst + case map[string]interface{}: + for k, v := range arg { + dst = append(dst, k, v) + } + return dst + case map[string]string: + for k, v := range arg { + dst = append(dst, k, v) + } + return dst + case time.Time, time.Duration, encoding.BinaryMarshaler, net.IP: + return append(dst, arg) + case nil: + return dst + default: + // scan struct field + v := reflect.ValueOf(arg) + if v.Type().Kind() == reflect.Ptr { + if v.IsNil() { + // error: arg is not a valid object + return dst + } + v = v.Elem() + } + + if v.Type().Kind() == reflect.Struct { + return appendStructField(dst, v) + } + + return append(dst, arg) + } +} + +// appendStructField appends the field and value held by the structure v to dst, and returns the appended dst. +func appendStructField(dst []interface{}, v reflect.Value) []interface{} { + typ := v.Type() + for i := 0; i < typ.NumField(); i++ { + tag := typ.Field(i).Tag.Get("redis") + if tag == "" || tag == "-" { + continue + } + name, opt, _ := strings.Cut(tag, ",") + if name == "" { + continue + } + + field := v.Field(i) + + // miss field + if omitEmpty(opt) && isEmptyValue(field) { + continue + } + + if field.CanInterface() { + dst = append(dst, name, field.Interface()) + } + } + + return dst +} + +func omitEmpty(opt string) bool { + for opt != "" { + var name string + name, opt, _ = strings.Cut(opt, ",") + if name == "omitempty" { + return true + } + } + return false +} + +func isEmptyValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return v.Len() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Pointer: + return v.IsNil() + case reflect.Struct: + if v.Type() == reflect.TypeOf(time.Time{}) { + return v.IsZero() + } + // Only supports the struct time.Time, + // subsequent iterations will follow the func Scan support decoder. + } + return false +} + +type Cmdable interface { + Pipeline() Pipeliner + Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) + + TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error) + TxPipeline() Pipeliner + + Command(ctx context.Context) *CommandsInfoCmd + CommandList(ctx context.Context, filter *FilterBy) *StringSliceCmd + CommandGetKeys(ctx context.Context, commands ...interface{}) *StringSliceCmd + CommandGetKeysAndFlags(ctx context.Context, commands ...interface{}) *KeyFlagsCmd + ClientGetName(ctx context.Context) *StringCmd + Echo(ctx context.Context, message interface{}) *StringCmd + Ping(ctx context.Context) *StatusCmd + Quit(ctx context.Context) *StatusCmd + Unlink(ctx context.Context, keys ...string) *IntCmd + + BgRewriteAOF(ctx context.Context) *StatusCmd + BgSave(ctx context.Context) *StatusCmd + ClientKill(ctx context.Context, ipPort string) *StatusCmd + ClientKillByFilter(ctx context.Context, keys ...string) *IntCmd + ClientList(ctx context.Context) *StringCmd + ClientInfo(ctx context.Context) *ClientInfoCmd + ClientPause(ctx context.Context, dur time.Duration) *BoolCmd + ClientUnpause(ctx context.Context) *BoolCmd + ClientID(ctx context.Context) *IntCmd + ClientUnblock(ctx context.Context, id int64) *IntCmd + ClientUnblockWithError(ctx context.Context, id int64) *IntCmd + ClientMaintNotifications(ctx context.Context, enabled bool, endpointType string) *StatusCmd + ClientTracking(ctx context.Context, on bool, opt *ClientTrackingOptions) *StatusCmd + ClientTrackingOn(ctx context.Context, opt *ClientTrackingOptions) *StatusCmd + ClientTrackingOff(ctx context.Context) *StatusCmd + ConfigGet(ctx context.Context, parameter string) *MapStringStringCmd + ConfigResetStat(ctx context.Context) *StatusCmd + ConfigSet(ctx context.Context, parameter, value string) *StatusCmd + ConfigRewrite(ctx context.Context) *StatusCmd + DBSize(ctx context.Context) *IntCmd + FlushAll(ctx context.Context) *StatusCmd + FlushAllAsync(ctx context.Context) *StatusCmd + FlushDB(ctx context.Context) *StatusCmd + FlushDBAsync(ctx context.Context) *StatusCmd + Info(ctx context.Context, section ...string) *StringCmd + InfoMap(ctx context.Context, section ...string) *InfoCmd + LastSave(ctx context.Context) *IntCmd + Save(ctx context.Context) *StatusCmd + Shutdown(ctx context.Context) *StatusCmd + ShutdownSave(ctx context.Context) *StatusCmd + ShutdownNoSave(ctx context.Context) *StatusCmd + SlaveOf(ctx context.Context, host, port string) *StatusCmd + ReplicaOf(ctx context.Context, host, port string) *StatusCmd + SlowLogGet(ctx context.Context, num int64) *SlowLogCmd + SlowLogLen(ctx context.Context) *IntCmd + SlowLogReset(ctx context.Context) *StatusCmd + Time(ctx context.Context) *TimeCmd + DebugObject(ctx context.Context, key string) *StringCmd + MemoryUsage(ctx context.Context, key string, samples ...int) *IntCmd + Latency(ctx context.Context) *LatencyCmd + LatencyReset(ctx context.Context, events ...interface{}) *StatusCmd + + ModuleLoadex(ctx context.Context, conf *ModuleLoadexConfig) *StringCmd + + ACLCmdable + ArrayCmdable + BitMapCmdable + ClusterCmdable + GenericCmdable + GeoCmdable + HashCmdable + HyperLogLogCmdable + ListCmdable + ProbabilisticCmdable + PubSubCmdable + ScriptingFunctionsCmdable + SearchCmdable + SetCmdable + SortedSetCmdable + StringCmdable + StreamCmdable + TimeseriesCmdable + JSONCmdable + VectorSetCmdable +} + +type StatefulCmdable interface { + Cmdable + Auth(ctx context.Context, password string) *StatusCmd + AuthACL(ctx context.Context, username, password string) *StatusCmd + Select(ctx context.Context, index int) *StatusCmd + SwapDB(ctx context.Context, index1, index2 int) *StatusCmd + ClientSetName(ctx context.Context, name string) *BoolCmd + ClientSetInfo(ctx context.Context, info LibraryInfo) *StatusCmd + Hello(ctx context.Context, ver int, username, password, clientName string) *MapStringInterfaceCmd +} + +var ( + _ Cmdable = (*Client)(nil) + _ Cmdable = (*Tx)(nil) + _ Cmdable = (*Ring)(nil) + _ Cmdable = (*ClusterClient)(nil) + _ Cmdable = (*Pipeline)(nil) +) + +type cmdable func(ctx context.Context, cmd Cmder) error + +type statefulCmdable func(ctx context.Context, cmd Cmder) error + +//------------------------------------------------------------------------------ + +func (c statefulCmdable) Auth(ctx context.Context, password string) *StatusCmd { + cmd := NewStatusCmd(ctx, "auth", password) + _ = c(ctx, cmd) + return cmd +} + +// AuthACL Perform an AUTH command, using the given user and pass. +// Should be used to authenticate the current connection with one of the connections defined in the ACL list +// when connecting to a Redis 6.0 instance, or greater, that is using the Redis ACL system. +func (c statefulCmdable) AuthACL(ctx context.Context, username, password string) *StatusCmd { + cmd := NewStatusCmd(ctx, "auth", username, password) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) Wait(ctx context.Context, numSlaves int, timeout time.Duration) *IntCmd { + cmd := NewIntCmd(ctx, "wait", numSlaves, int(timeout/time.Millisecond)) + cmd.setReadTimeout(timeout) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) WaitAOF(ctx context.Context, numLocal, numSlaves int, timeout time.Duration) *IntSliceCmd { + cmd := NewIntSliceCmd(ctx, "waitAOF", numLocal, numSlaves, int(timeout/time.Millisecond)) + cmd.setReadTimeout(timeout) + _ = c(ctx, cmd) + return cmd +} + +func (c statefulCmdable) Select(ctx context.Context, index int) *StatusCmd { + cmd := NewStatusCmd(ctx, "select", index) + _ = c(ctx, cmd) + return cmd +} + +func (c statefulCmdable) SwapDB(ctx context.Context, index1, index2 int) *StatusCmd { + cmd := NewStatusCmd(ctx, "swapdb", index1, index2) + _ = c(ctx, cmd) + return cmd +} + +// ClientSetName assigns a name to the connection. +func (c statefulCmdable) ClientSetName(ctx context.Context, name string) *BoolCmd { + cmd := NewBoolCmd(ctx, "client", "setname", name) + _ = c(ctx, cmd) + return cmd +} + +// ClientSetInfo sends a CLIENT SETINFO command with the provided info. +func (c statefulCmdable) ClientSetInfo(ctx context.Context, info LibraryInfo) *StatusCmd { + err := info.Validate() + if err != nil { + panic(err.Error()) + } + + var cmd *StatusCmd + if info.LibName != nil { + libName := fmt.Sprintf("go-redis(%s,%s)", *info.LibName, internal.ReplaceSpaces(runtime.Version())) + cmd = NewStatusCmd(ctx, "client", "setinfo", "LIB-NAME", libName) + } else { + cmd = NewStatusCmd(ctx, "client", "setinfo", "LIB-VER", *info.LibVer) + } + + _ = c(ctx, cmd) + return cmd +} + +// Validate checks if only one field in the struct is non-nil. +func (info LibraryInfo) Validate() error { + if info.LibName != nil && info.LibVer != nil { + return errors.New("both LibName and LibVer cannot be set at the same time") + } + if info.LibName == nil && info.LibVer == nil { + return errors.New("at least one of LibName and LibVer should be set") + } + return nil +} + +// Hello sets the resp protocol used. +func (c statefulCmdable) Hello(ctx context.Context, + ver int, username, password, clientName string, +) *MapStringInterfaceCmd { + args := make([]interface{}, 0, 7) + args = append(args, "hello", ver) + if password != "" { + if username != "" { + args = append(args, "auth", username, password) + } else { + args = append(args, "auth", "default", password) + } + } + if clientName != "" { + args = append(args, "setname", clientName) + } + cmd := NewMapStringInterfaceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +//------------------------------------------------------------------------------ + +func (c cmdable) Command(ctx context.Context) *CommandsInfoCmd { + cmd := NewCommandsInfoCmd(ctx, "command") + _ = c(ctx, cmd) + return cmd +} + +// FilterBy is used for the `CommandList` command parameter. +type FilterBy struct { + Module string + ACLCat string + Pattern string +} + +func (c cmdable) CommandList(ctx context.Context, filter *FilterBy) *StringSliceCmd { + args := make([]interface{}, 0, 5) + args = append(args, "command", "list") + if filter != nil { + if filter.Module != "" { + args = append(args, "filterby", "module", filter.Module) + } else if filter.ACLCat != "" { + args = append(args, "filterby", "aclcat", filter.ACLCat) + } else if filter.Pattern != "" { + args = append(args, "filterby", "pattern", filter.Pattern) + } + } + cmd := NewStringSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) CommandGetKeys(ctx context.Context, commands ...interface{}) *StringSliceCmd { + args := make([]interface{}, 2+len(commands)) + args[0] = "command" + args[1] = "getkeys" + copy(args[2:], commands) + cmd := NewStringSliceCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) CommandGetKeysAndFlags(ctx context.Context, commands ...interface{}) *KeyFlagsCmd { + args := make([]interface{}, 2+len(commands)) + args[0] = "command" + args[1] = "getkeysandflags" + copy(args[2:], commands) + cmd := NewKeyFlagsCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// ClientGetName returns the name of the connection. +func (c cmdable) ClientGetName(ctx context.Context) *StringCmd { + cmd := NewStringCmd(ctx, "client", "getname") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) Echo(ctx context.Context, message interface{}) *StringCmd { + cmd := NewStringCmd(ctx, "echo", message) + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) Ping(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "ping") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) Do(ctx context.Context, args ...interface{}) *Cmd { + cmd := NewCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// DoRaw executes a command and returns the raw RESP protocol bytes without parsing. +func (c cmdable) DoRaw(ctx context.Context, args ...interface{}) *RawCmd { + cmd := NewRawCmd(ctx, args...) + _ = c(ctx, cmd) + return cmd +} + +// DoRawWriteTo executes a command and streams raw RESP bytes directly to w without intermediate allocations. +func (c cmdable) DoRawWriteTo(ctx context.Context, w io.Writer, args ...interface{}) *RawWriteToCmd { + cmd := NewRawWriteToCmd(ctx, w, args...) + _ = c(ctx, cmd) + return cmd +} + +// Quit closes the connection. +// +// Deprecated: Just close the connection instead as of Redis 7.2.0. +func (c cmdable) Quit(_ context.Context) *StatusCmd { + panic("not implemented") +} + +//------------------------------------------------------------------------------ + +func (c cmdable) BgRewriteAOF(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "bgrewriteaof") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) BgSave(ctx context.Context) *StatusCmd { + cmd := NewStatusCmd(ctx, "bgsave") + _ = c(ctx, cmd) + return cmd +} + +func (c cmdable) ClientKill(ctx context.Context, ipPort string) *StatusCmd { + cmd := NewStatusCmd(ctx, "client", "kill", ipPort) + _ = c(ctx, cmd) + return cmd +} + +// ClientKillByFilter is new style syntax, while the ClientKill is old +// +// CLIENT KILL