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
|
@@ -0,0 +1,69 @@
|
|
|
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 Commands
|
|
6
|
+
# Small reply-shaping helpers shared by more than one command module.
|
|
7
|
+
# Plain module functions (not mixed in) so every command module can
|
|
8
|
+
# call them without fighting over method resolution order.
|
|
9
|
+
module Support
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# RESP3 sends a native boolean for some replies; RESP2 sends the
|
|
13
|
+
# integer 0/1 for the same semantics. Normalizes either to a Ruby bool.
|
|
14
|
+
def truthy(reply)
|
|
15
|
+
reply == 1 || reply == true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A member/score (or field/value, ...) reply comes back as a flat
|
|
19
|
+
# array [m1, s1, m2, s2, ...] in RESP2, or already paired up as
|
|
20
|
+
# [[m1, s1], [m2, s2], ...] in RESP3. Normalizes to the paired form,
|
|
21
|
+
# coercing each pair's second element with +value+ (Float() by
|
|
22
|
+
# default, since scores are the common case).
|
|
23
|
+
def pairify(reply, &value)
|
|
24
|
+
value ||= ->(v) { Wire.parse_float(v) }
|
|
25
|
+
return [] if reply.nil?
|
|
26
|
+
|
|
27
|
+
if !reply.empty? && reply.first.is_a?(Array)
|
|
28
|
+
reply.map { |(k, v)| [k, value.call(v)] }
|
|
29
|
+
else
|
|
30
|
+
reply.each_slice(2).map { |(k, v)| [k, value.call(v)] }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds a Hash out of a HotKV fixed schema STATS/INFO reply, parsed
|
|
35
|
+
# BY FIELD NAME rather than positionally:
|
|
36
|
+
# a flat array of N pairs (name, value, name, value, ...) under
|
|
37
|
+
# RESP2, or a native map (already a Hash, string keyed) under RESP3
|
|
38
|
+
# (HELLO 3), which HotKV sends for these replies either way.
|
|
39
|
+
# +float_keys+ names the symbol keys whose value should be coerced
|
|
40
|
+
# with Float() instead of left as the server sent it (already an
|
|
41
|
+
# Integer for RESP `:` replies).
|
|
42
|
+
#
|
|
43
|
+
# A missing field is simply absent from the returned Hash (callers
|
|
44
|
+
# building a typed Struct fall back to its own defaults); an
|
|
45
|
+
# unrecognized field whose value cannot be parsed as a number (a
|
|
46
|
+
# server addition this SDK does not know how to coerce) keeps its
|
|
47
|
+
# raw value instead of raising. This is what keeps every STATS/INFO
|
|
48
|
+
# reply forward compatible with a server that adds fields.
|
|
49
|
+
def pairs_to_h(reply, float_keys: [])
|
|
50
|
+
h = {}
|
|
51
|
+
if reply.is_a?(Hash)
|
|
52
|
+
reply.each { |k, v| h[k.to_sym] = v }
|
|
53
|
+
else
|
|
54
|
+
(reply || []).each_slice(2) { |(k, v)| h[k.to_sym] = v }
|
|
55
|
+
end
|
|
56
|
+
float_keys.each do |k|
|
|
57
|
+
next unless h.key?(k)
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
h[k] = Wire.parse_float(h[k])
|
|
61
|
+
rescue ArgumentError, TypeError
|
|
62
|
+
nil # keep the raw value; an unknown/malformed field must never break decoding
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
h
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Copyright (c) 2026 HotKV Ltd
|
|
2
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
|
|
3
|
+
|
|
4
|
+
require "socket"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
module HotKV
|
|
9
|
+
# A single RESP2/RESP3 socket connection to one HotKV server. Handles TCP
|
|
10
|
+
# and unix socket transport, TLS, HELLO 3 negotiation with RESP2 fallback,
|
|
11
|
+
# authentication, database selection and per operation timeouts. Not safe
|
|
12
|
+
# for concurrent use by multiple threads; HotKV::Pool is what makes the
|
|
13
|
+
# client thread safe.
|
|
14
|
+
class Connection
|
|
15
|
+
attr_reader :resp3
|
|
16
|
+
attr_accessor :last_used_at
|
|
17
|
+
|
|
18
|
+
def initialize(options)
|
|
19
|
+
@options = options
|
|
20
|
+
@socket = nil
|
|
21
|
+
@resp3 = false
|
|
22
|
+
@last_used_at = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def connected?
|
|
26
|
+
!@socket.nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Opens the socket, negotiates the protocol and authenticates. Returns
|
|
30
|
+
# self so callers can chain `Connection.new(opts).connect`.
|
|
31
|
+
def connect
|
|
32
|
+
@socket = open_socket
|
|
33
|
+
negotiate_protocol
|
|
34
|
+
select_db
|
|
35
|
+
@last_used_at = now
|
|
36
|
+
self
|
|
37
|
+
rescue StandardError
|
|
38
|
+
close
|
|
39
|
+
raise
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def close
|
|
43
|
+
@socket&.close
|
|
44
|
+
rescue IOError, SystemCallError
|
|
45
|
+
nil
|
|
46
|
+
ensure
|
|
47
|
+
@socket = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Sends one command and returns its decoded reply.
|
|
51
|
+
def call(*args, raise_on_error: true)
|
|
52
|
+
write_command(args)
|
|
53
|
+
read_reply(raise_on_error: raise_on_error)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def write_command(args)
|
|
57
|
+
with_timeout(@options[:command_timeout]) { @socket.write(RESP.encode_command(args)) }
|
|
58
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
59
|
+
close
|
|
60
|
+
raise Error::ConnectionError, "write failed: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def read_reply(raise_on_error: true)
|
|
64
|
+
value = with_timeout(@options[:command_timeout]) { RESP.read_reply(@socket, raise_on_error: raise_on_error) }
|
|
65
|
+
@last_used_at = now
|
|
66
|
+
value
|
|
67
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
68
|
+
close
|
|
69
|
+
raise Error::ConnectionError, "read failed: #{e.message}"
|
|
70
|
+
rescue Error::ProtocolError, Error::ConnectionError
|
|
71
|
+
close
|
|
72
|
+
raise
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Raw IO access for pipelines/pub-sub, which read several replies for
|
|
76
|
+
# a batch of commands they wrote themselves.
|
|
77
|
+
def io
|
|
78
|
+
@socket
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ping
|
|
82
|
+
call("PING")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def idle_seconds
|
|
86
|
+
return 0 if @last_used_at.nil?
|
|
87
|
+
|
|
88
|
+
now - @last_used_at
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def now
|
|
94
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def with_timeout(seconds)
|
|
98
|
+
return yield if seconds.nil? || seconds <= 0
|
|
99
|
+
|
|
100
|
+
Timeout.timeout(seconds, Error::TimeoutError, "operation timed out after #{seconds}s") { yield }
|
|
101
|
+
rescue Error::TimeoutError
|
|
102
|
+
close
|
|
103
|
+
raise
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def open_socket
|
|
107
|
+
raw = with_timeout(@options[:connect_timeout]) { open_raw_socket }
|
|
108
|
+
raw.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if raw.is_a?(TCPSocket)
|
|
109
|
+
@options[:ssl] ? wrap_tls(raw) : raw
|
|
110
|
+
rescue Error::TimeoutError
|
|
111
|
+
raise
|
|
112
|
+
rescue SystemCallError, IOError, SocketError => e
|
|
113
|
+
raise Error::ConnectionError, "connect failed: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def open_raw_socket
|
|
117
|
+
if @options[:path]
|
|
118
|
+
UNIXSocket.new(@options[:path])
|
|
119
|
+
else
|
|
120
|
+
TCPSocket.new(@options[:host], @options[:port])
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Wraps +raw+ in TLS. Unless :insecure_skip_verify is set the server's
|
|
125
|
+
# certificate chain is verified (against :ca_file when given, else the
|
|
126
|
+
# system trust store) and the certificate must match the host name, or the
|
|
127
|
+
# IP address, being connected to (:server_name overrides the name checked).
|
|
128
|
+
# The handshake counts against :connect_timeout.
|
|
129
|
+
def wrap_tls(raw)
|
|
130
|
+
ssl = nil
|
|
131
|
+
host = @options[:server_name] || @options[:host]
|
|
132
|
+
ssl = OpenSSL::SSL::SSLSocket.new(raw, tls_context)
|
|
133
|
+
ssl.hostname = host
|
|
134
|
+
ssl.sync_close = true
|
|
135
|
+
with_timeout(@options[:connect_timeout]) do
|
|
136
|
+
ssl.connect
|
|
137
|
+
ssl.post_connection_check(host) unless @options[:insecure_skip_verify]
|
|
138
|
+
end
|
|
139
|
+
ssl
|
|
140
|
+
rescue OpenSSL::SSL::SSLError, SystemCallError, IOError => e
|
|
141
|
+
close_quietly(ssl || raw)
|
|
142
|
+
raise Error::ConnectionError, "TLS handshake failed: #{e.message}"
|
|
143
|
+
rescue Error::TimeoutError
|
|
144
|
+
close_quietly(ssl || raw)
|
|
145
|
+
raise
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def tls_context
|
|
149
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
150
|
+
if @options[:insecure_skip_verify]
|
|
151
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
152
|
+
else
|
|
153
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
154
|
+
if @options[:ca_file]
|
|
155
|
+
ctx.ca_file = @options[:ca_file]
|
|
156
|
+
else
|
|
157
|
+
ctx.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
ctx.cert = OpenSSL::X509::Certificate.new(File.read(@options[:cert_file])) if @options[:cert_file]
|
|
161
|
+
ctx.key = OpenSSL::PKey.read(File.read(@options[:key_file])) if @options[:key_file]
|
|
162
|
+
ctx
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def close_quietly(io)
|
|
166
|
+
io&.close
|
|
167
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def negotiate_protocol
|
|
172
|
+
# :resp3 defaults to true (HELLO 3, falling back to RESP2 only when
|
|
173
|
+
# the server rejects it); set to false to use RESP2 even against a
|
|
174
|
+
# server that does support RESP3.
|
|
175
|
+
if @options.fetch(:resp3, true) == false
|
|
176
|
+
@resp3 = false
|
|
177
|
+
legacy_handshake
|
|
178
|
+
return
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
write_command(hello_args)
|
|
182
|
+
reply = read_reply(raise_on_error: false)
|
|
183
|
+
if reply.is_a?(Error::ServerError)
|
|
184
|
+
raise reply unless hello_unsupported?(reply)
|
|
185
|
+
|
|
186
|
+
@resp3 = false
|
|
187
|
+
legacy_handshake
|
|
188
|
+
else
|
|
189
|
+
@resp3 = true
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def hello_args
|
|
194
|
+
args = ["HELLO", "3"]
|
|
195
|
+
args.push("AUTH", @options[:username] || "default", @options[:password]) if @options[:password]
|
|
196
|
+
args.push("SETNAME", @options[:client_name]) if @options[:client_name]
|
|
197
|
+
args
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def hello_unsupported?(err)
|
|
201
|
+
err.code == "NOPROTO" || (err.code == "ERR" && err.server_message =~ /unknown command/i)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def legacy_handshake
|
|
205
|
+
if @options[:password]
|
|
206
|
+
if @options[:username]
|
|
207
|
+
call("AUTH", @options[:username], @options[:password])
|
|
208
|
+
else
|
|
209
|
+
call("AUTH", @options[:password])
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
call("CLIENT", "SETNAME", @options[:client_name]) if @options[:client_name]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def select_db
|
|
216
|
+
return unless @options[:db] && !@options[:db].zero?
|
|
217
|
+
|
|
218
|
+
call("SELECT", @options[:db])
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
data/lib/hotkv/errors.rb
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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 Error
|
|
6
|
+
# Root of every error the SDK raises.
|
|
7
|
+
class Base < StandardError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Could not establish, or lost, a connection to a server.
|
|
11
|
+
class ConnectionError < Base
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A connect, read, write or command timeout elapsed.
|
|
15
|
+
class TimeoutError < Base
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The server sent bytes that do not form a valid RESP2/RESP3 reply.
|
|
19
|
+
class ProtocolError < Base
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The URL, option hash or command arguments given to the client are invalid.
|
|
23
|
+
class ArgumentError < Base
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Base class for every `-ERR`/`-<CODE> ...` and RESP3 blob error (`!`)
|
|
27
|
+
# reply the server sends back for a command. `code` is the leading
|
|
28
|
+
# error token (e.g. "WRONGTYPE", "NOLICENSE", "ERR"); `server_message`
|
|
29
|
+
# is the full text the server sent.
|
|
30
|
+
class ServerError < Base
|
|
31
|
+
attr_reader :code, :server_message
|
|
32
|
+
|
|
33
|
+
def initialize(code, server_message)
|
|
34
|
+
@code = code
|
|
35
|
+
@server_message = server_message
|
|
36
|
+
super(server_message)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parses a raw error line (without the leading "-"/"!" type byte and
|
|
40
|
+
# without the trailing CRLF) into the most specific ServerError
|
|
41
|
+
# subclass known for its leading code word.
|
|
42
|
+
def self.from_message(message)
|
|
43
|
+
code, = message.split(" ", 2)
|
|
44
|
+
code ||= ""
|
|
45
|
+
klass = CODES[code] || CommandError
|
|
46
|
+
if klass <= ClusterRedirectError
|
|
47
|
+
klass.parse(code, message)
|
|
48
|
+
else
|
|
49
|
+
klass.new(code, message)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Generic `-ERR ...` (and any other unrecognized code) reply.
|
|
55
|
+
class CommandError < ServerError
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# `WRONGTYPE Operation against a key holding the wrong kind of value`
|
|
59
|
+
class WrongTypeError < ServerError
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# `NOAUTH Authentication required.`
|
|
63
|
+
class NoAuthError < ServerError
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# `WRONGPASS invalid username-password pair`
|
|
67
|
+
class WrongPassError < ServerError
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# An enterprise/HotKV command whose license feature is not included
|
|
71
|
+
# in the currently loaded license. Distinct from every other server
|
|
72
|
+
# error so callers (and tests) can detect and skip on it.
|
|
73
|
+
class NotLicensedError < ServerError
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# `READONLY You can't write against a read only replica.`
|
|
77
|
+
class ReadOnlyError < ServerError
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# `EXECABORT Transaction discarded because of a previous error.`
|
|
81
|
+
class ExecAbortError < ServerError
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `NOSCRIPT No matching script.` Used internally to trigger EVAL fallback.
|
|
85
|
+
class NoScriptError < ServerError
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# `CLUSTERDOWN The cluster is down.`
|
|
89
|
+
class ClusterDownError < ServerError
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# `TRYAGAIN` transient cluster error, safe to retry shortly.
|
|
93
|
+
class TryAgainError < ServerError
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `CROSSSLOT Keys in request don't hash to the same slot.`
|
|
97
|
+
class CrossSlotError < ServerError
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Base for the two cluster redirection replies, MOVED and ASK. Carries
|
|
101
|
+
# the parsed hash slot and destination address.
|
|
102
|
+
class ClusterRedirectError < ServerError
|
|
103
|
+
attr_reader :slot, :host, :port
|
|
104
|
+
|
|
105
|
+
def initialize(code, server_message, slot, host, port)
|
|
106
|
+
@slot = slot
|
|
107
|
+
@host = host
|
|
108
|
+
@port = port
|
|
109
|
+
super(code, server_message)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.parse(code, message)
|
|
113
|
+
# "<CODE> <slot> <host>:<port>"
|
|
114
|
+
_, slot, hostport = message.split(" ")
|
|
115
|
+
host, port = hostport.to_s.rpartition(":").values_at(0, 2)
|
|
116
|
+
new(code, message, slot.to_i, host, port.to_i)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# `MOVED <slot> <host>:<port>`: the slot now permanently lives elsewhere.
|
|
121
|
+
class MovedError < ClusterRedirectError
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# `ASK <slot> <host>:<port>`: the slot is mid migration, retry once
|
|
125
|
+
# against the target node after sending it ASKING.
|
|
126
|
+
class AskError < ClusterRedirectError
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
CODES = {
|
|
130
|
+
"WRONGTYPE" => WrongTypeError,
|
|
131
|
+
"NOAUTH" => NoAuthError,
|
|
132
|
+
"WRONGPASS" => WrongPassError,
|
|
133
|
+
"NOLICENSE" => NotLicensedError,
|
|
134
|
+
"READONLY" => ReadOnlyError,
|
|
135
|
+
"EXECABORT" => ExecAbortError,
|
|
136
|
+
"NOSCRIPT" => NoScriptError,
|
|
137
|
+
"CLUSTERDOWN" => ClusterDownError,
|
|
138
|
+
"TRYAGAIN" => TryAgainError,
|
|
139
|
+
"CROSSSLOT" => CrossSlotError,
|
|
140
|
+
"MOVED" => MovedError,
|
|
141
|
+
"ASK" => AskError
|
|
142
|
+
}.freeze
|
|
143
|
+
private_constant :CODES
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Copyright (c) 2026 HotKV Ltd
|
|
2
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
|
|
3
|
+
|
|
4
|
+
require_relative "namespaces/core"
|
|
5
|
+
require_relative "namespaces/caching"
|
|
6
|
+
require_relative "namespaces/amem"
|
|
7
|
+
require_relative "namespaces/rag"
|
|
8
|
+
require_relative "namespaces/ops"
|
|
9
|
+
|
|
10
|
+
module HotKV
|
|
11
|
+
module Commands
|
|
12
|
+
# HotKV exclusive and AI command groups, exposed as
|
|
13
|
+
# namespaced helpers, one memoized accessor per family, e.g.
|
|
14
|
+
# `client.pcache.get(ns, prompt)` or `client.amem.conv_append(...)`.
|
|
15
|
+
# Also NEXPIRE/NTTL/NEXPIREAT, the three standalone nanosecond
|
|
16
|
+
# resolution TTL commands, as plain methods alongside EXPIRE/TTL.
|
|
17
|
+
module Extensions
|
|
18
|
+
# Nanosecond resolution relative TTL. -> 1 if set, 0 if key does not exist.
|
|
19
|
+
def nexpire(key, nanoseconds)
|
|
20
|
+
invoke("NEXPIRE", key, nanoseconds) { |r| r == 1 }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# -> remaining TTL in nanoseconds, -2 if key does not exist, -1 if no expiry.
|
|
24
|
+
def nttl(key)
|
|
25
|
+
invoke("NTTL", key)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Nanosecond resolution absolute TTL (unix nanosecond timestamp).
|
|
29
|
+
def nexpireat(key, unix_nanoseconds)
|
|
30
|
+
invoke("NEXPIREAT", key, unix_nanoseconds) { |r| r == 1 }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def tag
|
|
34
|
+
@hotkv_ns_tag ||= Namespaces::Tag.new(self)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def gcra
|
|
38
|
+
@hotkv_ns_gcra ||= Namespaces::Gcra.new(self)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def vsim
|
|
42
|
+
@hotkv_ns_vsim ||= Namespaces::VSim.new(self)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def observe
|
|
46
|
+
@hotkv_ns_observe ||= Namespaces::Observe.new(self)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def alerts
|
|
50
|
+
@hotkv_ns_alerts ||= Namespaces::Alerts.new(self)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def hotkeys
|
|
54
|
+
@hotkv_ns_hotkeys ||= Namespaces::Hotkeys.new(self)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def scache
|
|
58
|
+
@hotkv_ns_scache ||= Namespaces::SCache.new(self)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def pcache
|
|
62
|
+
@hotkv_ns_pcache ||= Namespaces::PCache.new(self)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tool_cache
|
|
66
|
+
@hotkv_ns_tool_cache ||= Namespaces::KeyedCache.new(self, "TOOL.CACHE")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ncache
|
|
70
|
+
@hotkv_ns_ncache ||= Namespaces::KeyedCache.new(self, "NCACHE")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def guard
|
|
74
|
+
@hotkv_ns_guard ||= Namespaces::KeyedCache.new(self, "GUARD")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rerank
|
|
78
|
+
@hotkv_ns_rerank ||= Namespaces::KeyedCache.new(self, "RERANK")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def llmstats
|
|
82
|
+
@hotkv_ns_llmstats ||= Namespaces::LLMStats.new(self)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def llmbudget
|
|
86
|
+
@hotkv_ns_llmbudget ||= Namespaces::LLMBudget.new(self)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def prompt
|
|
90
|
+
@hotkv_ns_prompt ||= Namespaces::Prompt.new(self)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def embed_dedup
|
|
94
|
+
@hotkv_ns_embed_dedup ||= Namespaces::EmbedDedup.new(self)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def amem
|
|
98
|
+
@hotkv_ns_amem ||= Namespaces::AMem.new(self)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def rag
|
|
102
|
+
@hotkv_ns_rag ||= Namespaces::RAG.new(self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def ratelimit
|
|
106
|
+
@hotkv_ns_ratelimit ||= Namespaces::RateLimit.new(self)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ts
|
|
110
|
+
@hotkv_ns_ts ||= Namespaces::TS.new(self)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def feature
|
|
114
|
+
@hotkv_ns_feature ||= Namespaces::Feature.new(self)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/hotkv/future.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
# Placeholder for the typed result of a command queued inside a pipeline
|
|
6
|
+
# or transaction. Every typed command method (get, hgetall, zrange, ...)
|
|
7
|
+
# is written once, in terms of #invoke, and works unchanged whether
|
|
8
|
+
# #invoke runs the command immediately (HotKV::Client) or queues it and
|
|
9
|
+
# hands back a Future to be resolved once the batch runs
|
|
10
|
+
# (HotKV::Pipeline, HotKV::Transaction). Not part of the public API:
|
|
11
|
+
# Pipeline#execute and Transaction#exec always resolve every Future
|
|
12
|
+
# before returning, so callers only ever see plain values or, for a
|
|
13
|
+
# command that itself failed, the HotKV::Error instance in its place.
|
|
14
|
+
class Future
|
|
15
|
+
def initialize(&transform)
|
|
16
|
+
@transform = transform
|
|
17
|
+
@resolved = false
|
|
18
|
+
@value = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# +raw+ is the decoded reply for this command, or a HotKV::Error::ServerError
|
|
22
|
+
# instance if the command itself failed. The transform block (the same
|
|
23
|
+
# block a typed command method passes to #invoke) never runs on an
|
|
24
|
+
# error reply, so it can assume a well formed raw value.
|
|
25
|
+
def resolve(raw)
|
|
26
|
+
@value = if raw.is_a?(Error::ServerError)
|
|
27
|
+
raw
|
|
28
|
+
else
|
|
29
|
+
@transform ? @transform.call(raw) : raw
|
|
30
|
+
end
|
|
31
|
+
@resolved = true
|
|
32
|
+
self
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
@value = e
|
|
35
|
+
@resolved = true
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_error(err)
|
|
40
|
+
@value = err
|
|
41
|
+
@resolved = true
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolved?
|
|
46
|
+
@resolved
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def value
|
|
50
|
+
raise Error::Base, "future has not been resolved yet" unless @resolved
|
|
51
|
+
|
|
52
|
+
@value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
# One LLMSTATS.PRICE table entry (the reply of
|
|
6
|
+
# Namespaces::LLMStats#price_get, and each element of #price_list).
|
|
7
|
+
# Prices are USD per 1,000,000 tokens. +source+ is "default" (built
|
|
8
|
+
# in), "custom" (set with #price_set) or "unpriced" (an unknown model:
|
|
9
|
+
# every price 0.0, not an error).
|
|
10
|
+
LLMPrice = Struct.new(
|
|
11
|
+
:model, :source, :priced, :retired, :input_usd_per_mtok, :output_usd_per_mtok,
|
|
12
|
+
:cache_read_usd_per_mtok, :cache_write_5m_usd_per_mtok, :cache_write_1h_usd_per_mtok,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
) do
|
|
15
|
+
def initialize(model: "", source: "", priced: false, retired: false, input_usd_per_mtok: 0.0,
|
|
16
|
+
output_usd_per_mtok: 0.0, cache_read_usd_per_mtok: 0.0,
|
|
17
|
+
cache_write_5m_usd_per_mtok: 0.0, cache_write_1h_usd_per_mtok: 0.0)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Builds an LLMPrice from a {field_name (Symbol) => value} Hash
|
|
22
|
+
# already parsed by field name (see Commands::Support.pairs_to_h, with
|
|
23
|
+
# +float_keys: HotKV::LLMPrice::FLOAT_FIELDS+); unknown keys are
|
|
24
|
+
# ignored, missing ones fall back to this Struct's own defaults.
|
|
25
|
+
# +priced+/+retired+ arrive as the server's integer 0/1 and are
|
|
26
|
+
# normalized to true/false.
|
|
27
|
+
def self.from_fields(fields)
|
|
28
|
+
h = fields.select { |k, _| members.include?(k) }
|
|
29
|
+
h[:priced] = Commands::Support.truthy(h[:priced]) if h.key?(:priced)
|
|
30
|
+
h[:retired] = Commands::Support.truthy(h[:retired]) if h.key?(:retired)
|
|
31
|
+
new(**h)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Formats +value+ as the plain decimal string LLMSTATS.PRICE SET
|
|
35
|
+
# requires: digits, optional ".", 1-6 fractional digits, no sign, no
|
|
36
|
+
# exponent (see Wire.usd). A String is passed through unchanged (the
|
|
37
|
+
# caller's own decimal string).
|
|
38
|
+
def self.format_price(value)
|
|
39
|
+
Wire.usd(value)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The 5 price fields' names, i.e. the bulk string decimal fields a
|
|
44
|
+
# LLMSTATS.PRICE GET/LIST reply needs Float() coerced by
|
|
45
|
+
# Commands::Support.pairs_to_h. Assigned here, rather than inside the
|
|
46
|
+
# Struct.new block above, because a plain constant assignment inside a
|
|
47
|
+
# Struct.new block target's the block's own lexical scope (this file's
|
|
48
|
+
# top level), not the Struct subclass itself.
|
|
49
|
+
LLMPrice::FLOAT_FIELDS = %i[
|
|
50
|
+
input_usd_per_mtok output_usd_per_mtok cache_read_usd_per_mtok cache_write_5m_usd_per_mtok
|
|
51
|
+
cache_write_1h_usd_per_mtok
|
|
52
|
+
].freeze
|
|
53
|
+
end
|