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
@@ -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
@@ -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
@@ -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