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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/NOTICE +6 -0
- data/README.md +459 -0
- data/SECURITY.md +30 -0
- data/lib/hotkv/ai/call_result.rb +25 -0
- data/lib/hotkv/ai/prompt_cache.rb +135 -0
- data/lib/hotkv/ai/semantic_cache.rb +66 -0
- data/lib/hotkv/client.rb +201 -0
- data/lib/hotkv/cluster/crc16.rb +43 -0
- data/lib/hotkv/cluster.rb +184 -0
- data/lib/hotkv/commands/admin.rb +36 -0
- data/lib/hotkv/commands/all.rb +49 -0
- data/lib/hotkv/commands/bitmaps.rb +52 -0
- data/lib/hotkv/commands/geo.rb +146 -0
- data/lib/hotkv/commands/hashes.rb +169 -0
- data/lib/hotkv/commands/hyperloglog.rb +21 -0
- data/lib/hotkv/commands/keys.rb +170 -0
- data/lib/hotkv/commands/lists.rb +113 -0
- data/lib/hotkv/commands/probabilistic.rb +23 -0
- data/lib/hotkv/commands/publishing.rb +46 -0
- data/lib/hotkv/commands/scan.rb +82 -0
- data/lib/hotkv/commands/scripting.rb +128 -0
- data/lib/hotkv/commands/server.rb +175 -0
- data/lib/hotkv/commands/sets.rb +75 -0
- data/lib/hotkv/commands/sorted_sets.rb +258 -0
- data/lib/hotkv/commands/streams.rb +207 -0
- data/lib/hotkv/commands/strings.rb +178 -0
- data/lib/hotkv/commands/support.rb +69 -0
- data/lib/hotkv/connection.rb +221 -0
- data/lib/hotkv/errors.rb +145 -0
- data/lib/hotkv/extensions.rb +118 -0
- data/lib/hotkv/future.rb +55 -0
- data/lib/hotkv/llm_price.rb +53 -0
- data/lib/hotkv/namespaces/admin.rb +261 -0
- data/lib/hotkv/namespaces/amem.rb +172 -0
- data/lib/hotkv/namespaces/caching.rb +397 -0
- data/lib/hotkv/namespaces/core.rb +190 -0
- data/lib/hotkv/namespaces/ops.rb +166 -0
- data/lib/hotkv/namespaces/probabilistic.rb +180 -0
- data/lib/hotkv/namespaces/rag.rb +63 -0
- data/lib/hotkv/pipeline.rb +57 -0
- data/lib/hotkv/pool.rb +123 -0
- data/lib/hotkv/pubsub.rb +60 -0
- data/lib/hotkv/resp.rb +176 -0
- data/lib/hotkv/transaction.rb +115 -0
- data/lib/hotkv/url.rb +100 -0
- data/lib/hotkv/usage.rb +42 -0
- data/lib/hotkv/version.rb +7 -0
- data/lib/hotkv/wire.rb +145 -0
- data/lib/hotkv.rb +28 -0
- 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
|
data/lib/hotkv/client.rb
ADDED
|
@@ -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
|