hotkv 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/NOTICE +6 -0
  4. data/README.md +459 -0
  5. data/SECURITY.md +30 -0
  6. data/lib/hotkv/ai/call_result.rb +25 -0
  7. data/lib/hotkv/ai/prompt_cache.rb +135 -0
  8. data/lib/hotkv/ai/semantic_cache.rb +66 -0
  9. data/lib/hotkv/client.rb +201 -0
  10. data/lib/hotkv/cluster/crc16.rb +43 -0
  11. data/lib/hotkv/cluster.rb +184 -0
  12. data/lib/hotkv/commands/admin.rb +36 -0
  13. data/lib/hotkv/commands/all.rb +49 -0
  14. data/lib/hotkv/commands/bitmaps.rb +52 -0
  15. data/lib/hotkv/commands/geo.rb +146 -0
  16. data/lib/hotkv/commands/hashes.rb +169 -0
  17. data/lib/hotkv/commands/hyperloglog.rb +21 -0
  18. data/lib/hotkv/commands/keys.rb +170 -0
  19. data/lib/hotkv/commands/lists.rb +113 -0
  20. data/lib/hotkv/commands/probabilistic.rb +23 -0
  21. data/lib/hotkv/commands/publishing.rb +46 -0
  22. data/lib/hotkv/commands/scan.rb +82 -0
  23. data/lib/hotkv/commands/scripting.rb +128 -0
  24. data/lib/hotkv/commands/server.rb +175 -0
  25. data/lib/hotkv/commands/sets.rb +75 -0
  26. data/lib/hotkv/commands/sorted_sets.rb +258 -0
  27. data/lib/hotkv/commands/streams.rb +207 -0
  28. data/lib/hotkv/commands/strings.rb +178 -0
  29. data/lib/hotkv/commands/support.rb +69 -0
  30. data/lib/hotkv/connection.rb +221 -0
  31. data/lib/hotkv/errors.rb +145 -0
  32. data/lib/hotkv/extensions.rb +118 -0
  33. data/lib/hotkv/future.rb +55 -0
  34. data/lib/hotkv/llm_price.rb +53 -0
  35. data/lib/hotkv/namespaces/admin.rb +261 -0
  36. data/lib/hotkv/namespaces/amem.rb +172 -0
  37. data/lib/hotkv/namespaces/caching.rb +397 -0
  38. data/lib/hotkv/namespaces/core.rb +190 -0
  39. data/lib/hotkv/namespaces/ops.rb +166 -0
  40. data/lib/hotkv/namespaces/probabilistic.rb +180 -0
  41. data/lib/hotkv/namespaces/rag.rb +63 -0
  42. data/lib/hotkv/pipeline.rb +57 -0
  43. data/lib/hotkv/pool.rb +123 -0
  44. data/lib/hotkv/pubsub.rb +60 -0
  45. data/lib/hotkv/resp.rb +176 -0
  46. data/lib/hotkv/transaction.rb +115 -0
  47. data/lib/hotkv/url.rb +100 -0
  48. data/lib/hotkv/usage.rb +42 -0
  49. data/lib/hotkv/version.rb +7 -0
  50. data/lib/hotkv/wire.rb +145 -0
  51. data/lib/hotkv.rb +28 -0
  52. metadata +137 -0
data/SECURITY.md ADDED
@@ -0,0 +1,30 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ If you believe you have found a security vulnerability in this SDK, please
6
+ report it privately by emailing **security@hotkv.com** rather than opening
7
+ a public issue.
8
+
9
+ Please include, where possible:
10
+
11
+ - A description of the vulnerability and its potential impact.
12
+ - Steps to reproduce it, including the SDK version and Ruby version.
13
+ - Any proof-of-concept code or logs that help demonstrate the issue.
14
+
15
+ We will acknowledge your report, investigate, and follow up with next
16
+ steps and, once a fix is available, a coordinated disclosure timeline.
17
+ Please give us a reasonable amount of time to address the issue before any
18
+ public disclosure.
19
+
20
+ ## Supported Versions
21
+
22
+ Security fixes are provided for the latest 1.x release of this package.
23
+ Older versions may not receive fixes; please upgrade to the latest release
24
+ before reporting an issue that might already be resolved.
25
+
26
+ ## Scope
27
+
28
+ This policy covers the `hotkv` Ruby gem itself (this repository).
29
+ For vulnerabilities in the HotKV server or HotKV Cloud, please also use
30
+ security@hotkv.com so the report reaches the right team.
@@ -0,0 +1,25 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ module AI
6
+ # Shared by PromptCache/SemanticCache: the block given to #get_or_call
7
+ # may return either just the response, or a [response, usage] pair, so
8
+ # a real model call can report its actual token usage only once it has
9
+ # run.
10
+ module CallResult
11
+ module_function
12
+
13
+ # Detected by the second element's type alone (never just "is a
14
+ # 2 element Array"), so it stays unambiguous no matter what the
15
+ # response itself looks like.
16
+ def split_usage(result)
17
+ if result.is_a?(Array) && result.length == 2 && result[1].is_a?(TokenUsage)
18
+ result
19
+ else
20
+ [result, nil]
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,135 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ require "json"
5
+ require "digest"
6
+
7
+ module HotKV
8
+ module AI
9
+ # Deterministic LLM prompt/response cache. Builds a cache key from
10
+ # provider, model, the normalized messages/system prompt and
11
+ # generation parameters, and wraps #get_or_call so the wrapped LLM
12
+ # call only ever runs on a cache miss. Built on HotKV::Client#pcache.
13
+ #
14
+ # The key is the lowercase hex SHA-256 digest of a canonical, compact
15
+ # JSON encoding of {provider, model, system, messages, params}. This
16
+ # exact encoding (key order, compact separators, UTF-8 kept literal,
17
+ # params sorted by byte value) is shared byte for byte by every
18
+ # official HotKV SDK, so two different SDKs given the same logical
19
+ # call always agree on the key.
20
+ class PromptCache
21
+ DEFAULT_NAMESPACE = "default"
22
+
23
+ attr_reader :namespace, :stats_bucket
24
+
25
+ # +stats_bucket+, if given, is used by #record_usage to push real
26
+ # token/cost/latency numbers (which only the caller's LLM
27
+ # integration knows) into LLMSTATS.
28
+ def initialize(client, namespace: DEFAULT_NAMESPACE, stats_bucket: nil)
29
+ @client = client
30
+ @namespace = namespace
31
+ @stats_bucket = stats_bucket
32
+ @namespace_ready = false
33
+ @bucket_ready = false
34
+ end
35
+
36
+ # Computes the canonical cache key without touching the server.
37
+ def self.cache_key(provider:, model:, messages:, system: "", params: {})
38
+ Digest::SHA256.hexdigest(canonical_json(provider: provider, model: model, messages: messages,
39
+ system: system, params: params))
40
+ end
41
+
42
+ def self.canonical_json(provider:, model:, messages:, system: "", params: {})
43
+ JSON.generate(
44
+ "provider" => provider.to_s,
45
+ "model" => model.to_s,
46
+ "system" => system.to_s,
47
+ "messages" => messages.map { |m| canonical_message(m) },
48
+ "params" => canonical_params(params)
49
+ )
50
+ end
51
+
52
+ def self.canonical_message(message)
53
+ { "role" => fetch_field(message, :role).to_s, "content" => fetch_field(message, :content).to_s }
54
+ end
55
+ private_class_method :canonical_message
56
+
57
+ def self.fetch_field(message, key)
58
+ message[key] || message[key.to_s]
59
+ end
60
+ private_class_method :fetch_field
61
+
62
+ # Params must already be string valued (callers format numbers
63
+ # themselves, e.g. "0.7"), sorted ascending by byte value.
64
+ def self.canonical_params(params)
65
+ sorted = params.map { |k, v| [k.to_s, v] }.sort_by { |k, _v| k }.to_h
66
+ sorted.each_value do |v|
67
+ next if v.is_a?(String)
68
+
69
+ raise Error::ArgumentError,
70
+ "PromptCache params must be string valued (got #{v.class}); format numbers yourself so " \
71
+ "the encoding never differs between SDKs"
72
+ end
73
+ sorted
74
+ end
75
+ private_class_method :canonical_params
76
+
77
+ # Looks up the cache for this exact call. On a hit, returns the
78
+ # cached response and never calls the block. On a miss, calls the
79
+ # block (which should perform the real LLM call and return the text
80
+ # to cache), stores it, and returns it. Always returns
81
+ # {response:, cache_hit:, cache_key:}.
82
+ #
83
+ # The block may return either just the response, or a
84
+ # [response, usage] pair (a HotKV::TokenUsage); when it does,
85
+ # +usage+ is stored with the cache entry (see HotKV::Namespaces::PCache#set)
86
+ # so a later hit attributes its savings through #stats.
87
+ def get_or_call(provider:, model:, messages:, system: "", params: {}, ttl: nil, meta: nil)
88
+ ensure_namespace!
89
+ key = self.class.cache_key(provider: provider, model: model, messages: messages, system: system,
90
+ params: params)
91
+ cached = @client.pcache.get(@namespace, key)
92
+ return { response: cached, cache_hit: true, cache_key: key } unless cached.nil?
93
+
94
+ response, usage = CallResult.split_usage(yield)
95
+ @client.pcache.set(@namespace, key, response, ttl: ttl, meta: meta, usage: usage)
96
+ { response: response, cache_hit: false, cache_key: key }
97
+ end
98
+
99
+ # Hit/miss counts and cost saved are already tracked server side;
100
+ # see #stats. This pushes real per call token/cost/latency numbers
101
+ # (which only the caller knows) into LLMSTATS, optionally priced
102
+ # from the LLMSTATS.PRICE table via +model+. A no-op unless
103
+ # :stats_bucket was configured or +bucket+ is given here.
104
+ def record_usage(input_tokens:, output_tokens:, cached_tokens: 0, cost_usd: 0.0, latency_ms: 0, error: false,
105
+ model: nil, cache_write_5m_tokens: nil, cache_write_1h_tokens: nil, bucket: @stats_bucket)
106
+ return unless bucket
107
+
108
+ @client.llmstats.bucket(bucket) unless @bucket_ready
109
+ @bucket_ready = true
110
+ @client.llmstats.record(bucket, input_tokens, output_tokens, cached_tokens, cost_usd, latency_ms,
111
+ error: error, model: model, cache_write_5m_tokens: cache_write_5m_tokens,
112
+ cache_write_1h_tokens: cache_write_1h_tokens)
113
+ end
114
+
115
+ # entries, hits, misses, evictions, memory_bytes, hit_rate, p50_ns,
116
+ # p95_ns, p99_ns, cost_saved_usd and the usage_saved_* savings
117
+ # counters (attributed from any +usage+ stored with an entry) for
118
+ # this namespace.
119
+ def stats
120
+ @client.pcache.stats(@namespace)
121
+ end
122
+
123
+ private
124
+
125
+ def ensure_namespace!
126
+ return if @namespace_ready
127
+
128
+ @client.pcache.create(@namespace)
129
+ @namespace_ready = true
130
+ rescue Error::ServerError
131
+ @namespace_ready = true
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,66 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ module AI
6
+ # Embedding based answer cache: looks up the nearest cached answer
7
+ # above a similarity threshold for a caller supplied embedding vector,
8
+ # and stores new answers. Provider neutral: the SDK never computes
9
+ # embeddings itself; +vector+ is whatever a caller's own embedding
10
+ # model produced. Built on HotKV::Client#scache.
11
+ class SemanticCache
12
+ DEFAULT_NAMESPACE = "default"
13
+
14
+ attr_reader :namespace
15
+
16
+ def initialize(client, dims:, namespace: DEFAULT_NAMESPACE, metric: nil, threshold: nil, ttl: nil)
17
+ @client = client
18
+ @namespace = namespace
19
+ @dims = dims
20
+ @metric = metric
21
+ @threshold = threshold
22
+ @ttl = ttl
23
+ @namespace_ready = false
24
+ end
25
+
26
+ # Looks up +vector+ in the cache. On a hit (similarity above the
27
+ # configured, or overridden, threshold) returns
28
+ # {response:, similarity:, cache_hit: true} without calling the
29
+ # block. On a miss, calls the block (which should compute the real
30
+ # answer and return it), stores it under +query_text+/+vector+, and
31
+ # returns {response:, cache_hit: false}.
32
+ #
33
+ # The block may return either just the response, or a
34
+ # [response, usage] pair (a HotKV::TokenUsage); when it does,
35
+ # +usage+ is stored with the cache entry (see HotKV::Namespaces::SCache#set)
36
+ # so a later hit attributes its savings through #stats.
37
+ def get_or_call(query_text, vector, threshold: nil)
38
+ ensure_namespace!
39
+ hit = @client.scache.get(@namespace, vector, threshold: threshold)
40
+ return hit.merge(cache_hit: true) if hit
41
+
42
+ response, usage = CallResult.split_usage(yield)
43
+ @client.scache.set(@namespace, query_text, response, vector, ttl: @ttl, usage: usage)
44
+ { response: response, cache_hit: false }
45
+ end
46
+
47
+ # entries, hits, misses, hit_rate, p50_us and the usage_saved_*
48
+ # savings counters (attributed from any +usage+ stored with an
49
+ # entry) for this namespace.
50
+ def stats
51
+ @client.scache.stats(@namespace)
52
+ end
53
+
54
+ private
55
+
56
+ def ensure_namespace!
57
+ return if @namespace_ready
58
+
59
+ @client.scache.create(@namespace, @dims, metric: @metric, threshold: @threshold, ttl: @ttl)
60
+ @namespace_ready = true
61
+ rescue Error::ServerError
62
+ @namespace_ready = true
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,201 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ # Thread safe HotKV client: a bounded connection pool in front of the
6
+ # typed command methods (every family in Commands::All), pipelines,
7
+ # transactions, pub/sub and the raw #command/#execute escape hatch.
8
+ #
9
+ # client = HotKV::Client.new(url: "hotkv://localhost:6379")
10
+ # client.set("k", "v")
11
+ # client.get("k") # => "v"
12
+ # client.close
13
+ class Client
14
+ include Commands::All
15
+
16
+ DEFAULTS = {
17
+ host: "127.0.0.1",
18
+ port: 6379,
19
+ path: nil,
20
+ username: nil,
21
+ password: nil,
22
+ db: 0,
23
+ client_name: nil,
24
+ connect_timeout: 5.0,
25
+ command_timeout: nil,
26
+ pool_size: 5,
27
+ idle_timeout: 300,
28
+ pool_timeout: 5.0,
29
+ ssl: false,
30
+ insecure_skip_verify: false,
31
+ ca_file: nil,
32
+ cert_file: nil,
33
+ key_file: nil,
34
+ server_name: nil
35
+ }.freeze
36
+
37
+ # Best effort allowlist of read only commands safe to retry once after
38
+ # a connection loss. Never includes a write (the container
39
+ # commands CONFIG, CLIENT and SCRIPT are included only because their
40
+ # state changing subcommands are idempotent).
41
+ READONLY_COMMANDS = %w[
42
+ GET MGET STRLEN EXISTS TYPE TTL PTTL EXPIRETIME PEXPIRETIME GETRANGE GETBIT
43
+ BITCOUNT BITPOS LLEN LRANGE LINDEX LPOS SMEMBERS SISMEMBER SMISMEMBER SCARD SINTER SUNION
44
+ SDIFF SINTERCARD SRANDMEMBER HGET HMGET HGETALL HKEYS HVALS HLEN HSTRLEN HEXISTS HRANDFIELD
45
+ HTTL HPTTL HEXPIRETIME HPEXPIRETIME ZSCORE ZMSCORE ZCARD ZRANK ZREVRANK ZCOUNT ZLEXCOUNT
46
+ ZRANGE ZRANGEBYSCORE ZRANGEBYLEX ZREVRANGE ZREVRANGEBYSCORE ZREVRANGEBYLEX ZRANDMEMBER
47
+ ZUNION ZINTER ZDIFF ZINTERCARD KEYS SCAN HSCAN SSCAN ZSCAN RANDOMKEY DUMP OBJECT DBSIZE
48
+ XRANGE XREVRANGE XLEN XREAD XINFO XPENDING GEOPOS GEODIST GEOHASH GEOSEARCH PFCOUNT PING
49
+ ECHO TIME INFO CONFIG LASTSAVE CLIENT SCRIPT COMMAND
50
+ LCS DIGEST SUBSTR SORT_RO GEORADIUS_RO GEORADIUSBYMEMBER_RO EVAL_RO EVALSHA_RO FCALL_RO ROLE
51
+ BF.EXISTS BF.MEXISTS BF.CARD BF.INFO CMS.QUERY CMS.INFO TOPK.QUERY TOPK.COUNT TOPK.LIST TOPK.INFO
52
+ VSIM.GET VSIM.SEARCH VSIM.INFO
53
+ TAG.GET TAG.MEMBERS TAG.INTER TAG.COUNT TAG.EXISTS NTTL HOTKEYS
54
+ OBSERVE
55
+ ].freeze
56
+ private_constant :READONLY_COMMANDS
57
+
58
+ # +url+ ("hotkv://", "hotkvs://", or a redis:// equivalent) supplies
59
+ # any options it encodes; explicit keyword options always win over
60
+ # whatever the URL carried for the same setting.
61
+ def initialize(url: nil, **options)
62
+ merged = url ? URL.parse(url).merge(options) : options
63
+ @options = DEFAULTS.merge(merged)
64
+ @pool = Pool.new(@options, size: @options[:pool_size], idle_timeout: @options[:idle_timeout],
65
+ pool_timeout: @options[:pool_timeout])
66
+ end
67
+
68
+ # Runs a command immediately and returns its decoded reply.
69
+ def invoke(*args)
70
+ raw = send_command(args)
71
+ block_given? ? yield(raw) : raw
72
+ end
73
+
74
+ # Generic escape hatch: sends any command as given.
75
+ def command(*args)
76
+ invoke(*args)
77
+ end
78
+ alias_method :execute, :command
79
+
80
+ # Batches every command queued inside the block into one round trip.
81
+ # See HotKV::Pipeline.
82
+ def pipelined
83
+ raise Error::ArgumentError, "pipelined requires a block" unless block_given?
84
+
85
+ @pool.with_connection do |conn|
86
+ pipeline = Pipeline.new(conn)
87
+ yield pipeline
88
+ pipeline.execute
89
+ end
90
+ end
91
+
92
+ # Runs a MULTI/EXEC transaction. See HotKV::Transaction. Returns the
93
+ # Array of results, or nil if the transaction was aborted.
94
+ def multi
95
+ raise Error::ArgumentError, "multi requires a block" unless block_given?
96
+
97
+ @pool.with_connection do |conn|
98
+ conn.call("MULTI")
99
+ tx = Transaction.new(conn)
100
+ begin
101
+ yield tx
102
+ rescue StandardError
103
+ conn.call("DISCARD", raise_on_error: false)
104
+ raise
105
+ end
106
+ tx.exec
107
+ end
108
+ end
109
+
110
+ # Watches +keys+ for changes, on one connection held for the whole
111
+ # block. The yielded HotKV::WatchSession runs ordinary commands
112
+ # immediately (to decide what to do) and offers #multi to start the
113
+ # actual transaction once you are ready. UNWATCH is sent automatically
114
+ # if the block never calls #multi.
115
+ def watch(*keys)
116
+ raise Error::ArgumentError, "watch requires a block" unless block_given?
117
+
118
+ flat_keys = keys.flatten
119
+ @pool.with_connection do |conn|
120
+ conn.call("WATCH", *flat_keys) unless flat_keys.empty?
121
+ session = WatchSession.new(conn)
122
+ begin
123
+ yield session
124
+ ensure
125
+ conn.call("UNWATCH", raise_on_error: false) unless session.multi_started?
126
+ end
127
+ end
128
+ end
129
+
130
+ # Subscribes to +channels+ on a dedicated (non pooled) connection and
131
+ # blocks the calling thread, dispatching frames to the callbacks
132
+ # registered on the HotKV::PubSub yielded to the block, until every
133
+ # subscription ends. See HotKV::PubSub.
134
+ def subscribe(*channels, &block)
135
+ pubsub_loop("SUBSCRIBE", channels.flatten, &block)
136
+ end
137
+
138
+ def psubscribe(*patterns, &block)
139
+ pubsub_loop("PSUBSCRIBE", patterns.flatten, &block)
140
+ end
141
+
142
+ def ssubscribe(*channels, &block)
143
+ pubsub_loop("SSUBSCRIBE", channels.flatten, &block)
144
+ end
145
+
146
+ # Closes every pooled connection. The client can keep being used
147
+ # afterward; new connections are opened on demand.
148
+ def close
149
+ @pool.shutdown
150
+ end
151
+
152
+ private
153
+
154
+ def send_command(args)
155
+ attempts = 0
156
+ begin
157
+ @pool.with_connection { |conn| conn.call(*args) }
158
+ rescue Error::ConnectionError, Error::TimeoutError
159
+ attempts += 1
160
+ retry if attempts == 1 && READONLY_COMMANDS.include?(args[0].to_s.upcase)
161
+ raise
162
+ end
163
+ end
164
+
165
+ CONFIRM_KINDS = %w[subscribe unsubscribe psubscribe punsubscribe ssubscribe sunsubscribe].freeze
166
+ private_constant :CONFIRM_KINDS
167
+
168
+ def pubsub_loop(wire_cmd, targets)
169
+ raise Error::ArgumentError, "#{wire_cmd} requires a block" unless block_given?
170
+ raise Error::ArgumentError, "#{wire_cmd} needs at least one channel or pattern" if targets.empty?
171
+
172
+ conn = Connection.new(@options).connect
173
+ on = PubSub.new.bind(conn)
174
+ yield on
175
+ conn.write_command([wire_cmd, *targets])
176
+ count = -1
177
+ begin
178
+ count = read_pubsub_frame(conn, on, count) while count != 0
179
+ ensure
180
+ conn.close
181
+ end
182
+ nil
183
+ end
184
+
185
+ def read_pubsub_frame(conn, on, count)
186
+ frame = RESP.read_reply(conn.io, raise_on_error: false)
187
+ kind = frame[0].to_s
188
+ case kind
189
+ when "message" then on.dispatch(:message, frame[1], frame[2])
190
+ when "pmessage" then on.dispatch(:pmessage, frame[1], frame[2], frame[3])
191
+ when "smessage" then on.dispatch(:smessage, frame[1], frame[2])
192
+ else
193
+ if CONFIRM_KINDS.include?(kind)
194
+ on.dispatch(kind.to_sym, frame[1], frame[2])
195
+ count = frame[2]
196
+ end
197
+ end
198
+ count
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,43 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ class Cluster
6
+ # CRC16/XMODEM (poly 0x1021, init 0, no reflection, no final xor) and the
7
+ # cluster key slot algorithm built on it: hash only the part of a
8
+ # key between the first "{" and the next non-empty "}", when present.
9
+ module CRC16
10
+ SLOT_COUNT = 16_384
11
+
12
+ module_function
13
+
14
+ def crc16(bytes)
15
+ crc = 0
16
+ bytes.b.each_byte do |byte|
17
+ crc = (crc ^ (byte << 8)) & 0xFFFF
18
+ 8.times do
19
+ crc = ((crc & 0x8000) != 0) ? ((crc << 1) ^ 0x1021) & 0xFFFF : (crc << 1) & 0xFFFF
20
+ end
21
+ end
22
+ crc
23
+ end
24
+
25
+ def key_slot(key)
26
+ crc16(hash_tag(key)) % SLOT_COUNT
27
+ end
28
+
29
+ # Returns the substring of +key+ that participates in hashing: the
30
+ # text strictly between the first "{" and the next "}" after it, or
31
+ # the whole key when there is no such non-empty tag.
32
+ def hash_tag(key)
33
+ s = key.index("{")
34
+ return key unless s
35
+
36
+ e = key.index("}", s + 1)
37
+ return key if e.nil? || e == s + 1
38
+
39
+ key[(s + 1)...e]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,184 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ require "monitor"
5
+
6
+ module HotKV
7
+ # Cluster aware HotKV client. Discovers the slot map with CLUSTER SLOTS,
8
+ # routes each command to the node that owns its key's slot (CRC16 with
9
+ # {hash tag} support), refreshes the map and retries on MOVED, and
10
+ # follows ASK (sending ASKING first) without updating the map for the
11
+ # one off redirect. Exposes the same typed command methods as
12
+ # HotKV::Client, plus the #command/#execute escape hatch.
13
+ #
14
+ # Against a server that is not running in cluster mode (CLUSTER SLOTS
15
+ # comes back empty), every command is simply routed to the configured
16
+ # seed nodes in round robin, so the same client also works against a
17
+ # single standalone server.
18
+ class Cluster
19
+ include Commands::All
20
+
21
+ DEFAULT_MAX_REDIRECTS = 8
22
+
23
+ # +urls+: one or more "hotkv://host:port" seed addresses (or option
24
+ # Hashes with :host/:port). Remaining keyword options are the same
25
+ # connection options HotKV::Client accepts and apply to every node.
26
+ def initialize(urls:, **options)
27
+ seeds = Array(urls)
28
+ raise Error::ArgumentError, "Cluster.new requires at least one seed url" if seeds.empty?
29
+
30
+ @base_options = options
31
+ @seed_addrs = seeds.map { |u| seed_addr(u) }
32
+ @max_redirects = options[:max_redirects] || DEFAULT_MAX_REDIRECTS
33
+ # Reading from replicas (READONLY) is not supported: every command goes
34
+ # to the node that owns the key's slot. Refuse the option rather than
35
+ # silently ignore it.
36
+ if options.key?(:readonly) || options.key?(:read_from)
37
+ raise Error::ArgumentError, "HotKV::Cluster does not support reading from replicas (:readonly / :read_from)"
38
+ end
39
+
40
+ @lock = Monitor.new
41
+ @pools = {}
42
+ @slots = {}
43
+ @rr = 0
44
+ refresh_slots
45
+ end
46
+
47
+ def invoke(*args)
48
+ raw = dispatch(args)
49
+ block_given? ? yield(raw) : raw
50
+ end
51
+
52
+ # Generic escape hatch: sends any command, routed by its first argument.
53
+ def command(*args)
54
+ invoke(*args)
55
+ end
56
+ alias_method :execute, :command
57
+
58
+ # Forces a slot map refresh from CLUSTER SLOTS.
59
+ def refresh!
60
+ refresh_slots
61
+ end
62
+
63
+ def known_nodes
64
+ @lock.synchronize { @slots.values.uniq }
65
+ end
66
+
67
+ def close
68
+ @lock.synchronize { @pools.each_value(&:shutdown) }
69
+ end
70
+
71
+ private
72
+
73
+ KEYLESS_COMMANDS = %w[
74
+ PING ECHO INFO TIME DBSIZE FLUSHDB FLUSHALL CONFIG CLIENT CLUSTER COMMAND SELECT HELLO AUTH
75
+ SCAN SCRIPT EVAL EVALSHA FCALL FCALL_RO MULTI EXEC DISCARD WATCH UNWATCH SUBSCRIBE PSUBSCRIBE
76
+ SSUBSCRIBE UNSUBSCRIBE PSUNSUBSCRIBE SUNSUBSCRIBE PUBLISH PUBSUB WAIT SAVE BGSAVE BGREWRITEAOF
77
+ SHUTDOWN DEBUG MEMORY SLOWLOG LATENCY ACL
78
+ OBSERVE ALERTS HOTKEYS ASKING
79
+ ].freeze
80
+
81
+ def seed_addr(u)
82
+ opts = u.is_a?(String) ? URL.parse(u) : u
83
+ "#{opts[:host]}:#{opts[:port]}"
84
+ end
85
+
86
+ def pool_for(addr)
87
+ @lock.synchronize do
88
+ @pools[addr] ||= begin
89
+ host, _, port = addr.rpartition(":")
90
+ Pool.new(@base_options.merge(host: host, port: port.to_i),
91
+ size: @base_options[:pool_size] || 5,
92
+ idle_timeout: @base_options[:idle_timeout] || 300,
93
+ pool_timeout: @base_options[:pool_timeout])
94
+ end
95
+ end
96
+ end
97
+
98
+ def next_seed
99
+ @lock.synchronize do
100
+ addr = @seed_addrs[@rr % @seed_addrs.size]
101
+ @rr += 1
102
+ addr
103
+ end
104
+ end
105
+
106
+ # Reloads the slot table from CLUSTER SLOTS, asking the nodes already
107
+ # known first and the seed urls after them; the first one that answers
108
+ # wins, so a dead node never blocks a refresh while another is up.
109
+ def refresh_slots
110
+ candidates = (@lock.synchronize { @slots.values.uniq } + @seed_addrs).uniq
111
+ last_error = nil
112
+ candidates.each do |addr|
113
+ begin
114
+ raw = pool_for(addr).with_connection { |c| c.call("CLUSTER", "SLOTS", raise_on_error: false) }
115
+ rescue Error::ConnectionError, Error::TimeoutError => e
116
+ last_error = e
117
+ next
118
+ end
119
+ return store_slots(raw, addr)
120
+ end
121
+ raise last_error if last_error
122
+ end
123
+
124
+ def store_slots(raw, answering_addr)
125
+ new_slots = {}
126
+ unless raw.is_a?(Error::ServerError) || raw.nil? || raw.empty?
127
+ default_host = answering_addr.rpartition(":").first
128
+ raw.each do |range|
129
+ start_slot, end_slot, master = range[0], range[1], range[2]
130
+ host = master[0].to_s.empty? ? default_host : master[0]
131
+ node_addr = "#{host}:#{master[1]}"
132
+ (start_slot..end_slot).each { |s| new_slots[s] = node_addr }
133
+ end
134
+ end
135
+ @lock.synchronize { @slots = new_slots }
136
+ end
137
+
138
+ def target_addr(args)
139
+ cmd = args[0].to_s.upcase
140
+ key = args[1]
141
+ return next_seed if key.nil? || KEYLESS_COMMANDS.include?(cmd)
142
+
143
+ slot = CRC16.key_slot(key.to_s)
144
+ @lock.synchronize { @slots[slot] } || next_seed
145
+ end
146
+
147
+ # "host:port" a MOVED/ASK points at. An empty host means "the node that
148
+ # answered", as in cluster mode.
149
+ def redirect_addr(reply, answering_addr)
150
+ host = reply.host.to_s.empty? ? answering_addr.rpartition(":").first : reply.host
151
+ "#{host}:#{reply.port}"
152
+ end
153
+
154
+ def dispatch(args, redirects: 0, asking_addr: nil)
155
+ raise Error::ClusterDownError.new("CLUSTERDOWN", "too many redirects for this command") if redirects > @max_redirects
156
+
157
+ addr = asking_addr || target_addr(args)
158
+ reply =
159
+ begin
160
+ pool_for(addr).with_connection do |conn|
161
+ conn.call("ASKING", raise_on_error: false) if asking_addr
162
+ conn.call(*args, raise_on_error: false)
163
+ end
164
+ rescue Error::ConnectionError, Error::TimeoutError
165
+ refresh_slots
166
+ return dispatch(args, redirects: redirects + 1)
167
+ end
168
+
169
+ case reply
170
+ when Error::MovedError
171
+ refresh_slots
172
+ moved_to = redirect_addr(reply, addr)
173
+ @lock.synchronize { @slots[reply.slot] = moved_to } # MOVED is authoritative for its slot
174
+ dispatch(args, redirects: redirects + 1)
175
+ when Error::AskError
176
+ dispatch(args, redirects: redirects + 1, asking_addr: redirect_addr(reply, addr))
177
+ when Error::ServerError
178
+ raise reply
179
+ else
180
+ reply
181
+ end
182
+ end
183
+ end
184
+ end