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/lib/hotkv/resp.rb
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
# A plain Ruby String subclass that remembers the three letter format tag
|
|
6
|
+
# ("txt" or "mkd") a RESP3 verbatim string (`=`) reply carried. Behaves as
|
|
7
|
+
# an ordinary String everywhere; only +format+ is extra.
|
|
8
|
+
class VerbatimString < String
|
|
9
|
+
attr_reader :format
|
|
10
|
+
|
|
11
|
+
def initialize(content, format)
|
|
12
|
+
super(content)
|
|
13
|
+
@format = format
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Low level RESP2/RESP3 request encoder and reply decoder. Every reply
|
|
18
|
+
# type in the protocol is handled: simple string, error, integer, bulk
|
|
19
|
+
# string, array, null, boolean, double, big number, blob error, verbatim
|
|
20
|
+
# string, map, set, attribute and push. Operates on any object that
|
|
21
|
+
# responds to #gets(separator) and #read(length) the way IO and StringIO
|
|
22
|
+
# do, so it needs no live socket to be unit tested.
|
|
23
|
+
module RESP
|
|
24
|
+
CRLF = "\r\n".b
|
|
25
|
+
private_constant :CRLF
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Encodes +args+ (Strings, Symbols, Integers, Floats, ...) as one RESP
|
|
30
|
+
# array of bulk strings, the wire form every RESP server
|
|
31
|
+
# expects a client request in, regardless of RESP2 or RESP3.
|
|
32
|
+
def encode_command(args)
|
|
33
|
+
out = String.new(+"*#{args.length}\r\n", encoding: Encoding::BINARY)
|
|
34
|
+
args.each do |a|
|
|
35
|
+
s = to_wire_string(a)
|
|
36
|
+
out << "$" << s.bytesize.to_s << CRLF << s << CRLF
|
|
37
|
+
end
|
|
38
|
+
out
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Strings go out byte for byte. Integers use their decimal text. Every
|
|
42
|
+
# other number (Float, BigDecimal, Rational) is written by Wire.float:
|
|
43
|
+
# plain decimal, never an exponent such as "1.0e-05", which the server's
|
|
44
|
+
# stricter numeric parsers refuse.
|
|
45
|
+
def to_wire_string(arg)
|
|
46
|
+
case arg
|
|
47
|
+
when String then arg.b
|
|
48
|
+
when Integer then arg.to_s.b
|
|
49
|
+
when Numeric then Wire.float(arg).b
|
|
50
|
+
else arg.to_s.b
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Reads exactly one reply from +io+.
|
|
55
|
+
#
|
|
56
|
+
# +raise_on_error+ governs only the value this call itself returns: when
|
|
57
|
+
# true (the default) a top level error reply is raised as a
|
|
58
|
+
# HotKV::Error::ServerError subclass; when false it is returned as that
|
|
59
|
+
# exception instance instead. Either way, an error found while decoding
|
|
60
|
+
# an element nested inside an array/map/set/push is always returned
|
|
61
|
+
# inline as the exception instance (never raised), so one failed command
|
|
62
|
+
# inside a MULTI/EXEC or pipeline reply never discards the rest of it.
|
|
63
|
+
def read_reply(io, raise_on_error: true)
|
|
64
|
+
value = read_value(io)
|
|
65
|
+
raise value if raise_on_error && value.is_a?(Error::ServerError)
|
|
66
|
+
|
|
67
|
+
value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def read_value(io)
|
|
71
|
+
line = read_line(io)
|
|
72
|
+
type = line.getbyte(0)
|
|
73
|
+
raise Error::ProtocolError, "malformed RESP reply: empty line where a type byte was expected" if type.nil?
|
|
74
|
+
|
|
75
|
+
body = line.byteslice(1..-1)
|
|
76
|
+
begin
|
|
77
|
+
decode(type, body, io)
|
|
78
|
+
rescue ArgumentError, RangeError => e
|
|
79
|
+
raise Error::ProtocolError, "malformed RESP reply near #{line.inspect}: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def decode(type, body, io)
|
|
84
|
+
case type
|
|
85
|
+
when 43 then body # '+' simple string
|
|
86
|
+
when 45 then Error::ServerError.from_message(body) # '-' error
|
|
87
|
+
when 58 then Integer(body) # ':' integer
|
|
88
|
+
when 36 then read_bulk(io, body) # '$' bulk string
|
|
89
|
+
when 42 then read_array(io, body) # '*' array
|
|
90
|
+
when 95 then nil # '_' null
|
|
91
|
+
when 35 then body == "t" # '#' boolean
|
|
92
|
+
when 44 then read_double(body) # ',' double
|
|
93
|
+
when 40 then Integer(body) # '(' big number
|
|
94
|
+
when 33 then Error::ServerError.from_message(read_bulk(io, body)) # '!' blob error
|
|
95
|
+
when 61 then read_verbatim(io, body) # '=' verbatim string
|
|
96
|
+
when 37 then read_map(io, body) # '%' map
|
|
97
|
+
when 126 then read_multi(io, body) # '~' set
|
|
98
|
+
when 62 then read_multi(io, body) # '>' push
|
|
99
|
+
when 124 then read_attribute(io, body) # '|' attribute (transparent)
|
|
100
|
+
else
|
|
101
|
+
raise Error::ProtocolError, "unknown RESP type byte #{type.chr.inspect}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def read_line(io)
|
|
106
|
+
line = io.gets(CRLF)
|
|
107
|
+
raise Error::ConnectionError, "connection closed by server" if line.nil?
|
|
108
|
+
raise Error::ConnectionError, "connection closed by server (truncated reply line)" unless line.chomp!(CRLF)
|
|
109
|
+
|
|
110
|
+
line
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def read_exact(io, n)
|
|
114
|
+
return "".b if n.zero?
|
|
115
|
+
|
|
116
|
+
data = io.read(n)
|
|
117
|
+
raise Error::ConnectionError, "connection closed by server (expected #{n} bytes)" if data.nil? || data.bytesize != n
|
|
118
|
+
|
|
119
|
+
data
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def read_bulk(io, len_str)
|
|
123
|
+
len = Integer(len_str)
|
|
124
|
+
return nil if len.negative?
|
|
125
|
+
|
|
126
|
+
data = read_exact(io, len)
|
|
127
|
+
terminator = read_exact(io, 2)
|
|
128
|
+
raise Error::ProtocolError, "malformed bulk string terminator" unless terminator == CRLF
|
|
129
|
+
|
|
130
|
+
data
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def read_verbatim(io, len_str)
|
|
134
|
+
payload = read_bulk(io, len_str)
|
|
135
|
+
return nil if payload.nil?
|
|
136
|
+
|
|
137
|
+
VerbatimString.new(payload.byteslice(4..-1) || "".b, payload.byteslice(0, 3))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def read_double(body)
|
|
141
|
+
case body
|
|
142
|
+
when "inf" then Float::INFINITY
|
|
143
|
+
when "-inf" then -Float::INFINITY
|
|
144
|
+
when "nan" then Float::NAN
|
|
145
|
+
else Float(body)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def read_array(io, len_str)
|
|
150
|
+
len = Integer(len_str)
|
|
151
|
+
return nil if len.negative? # RESP2 null array
|
|
152
|
+
|
|
153
|
+
Array.new(len) { read_value(io) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def read_multi(io, len_str)
|
|
157
|
+
Array.new(Integer(len_str)) { read_value(io) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def read_map(io, len_str)
|
|
161
|
+
count = Integer(len_str)
|
|
162
|
+
h = {}
|
|
163
|
+
count.times { h[read_value(io)] = read_value(io) }
|
|
164
|
+
h
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def read_attribute(io, len_str)
|
|
168
|
+
count = Integer(len_str)
|
|
169
|
+
count.times do
|
|
170
|
+
read_value(io)
|
|
171
|
+
read_value(io)
|
|
172
|
+
end
|
|
173
|
+
read_value(io) # the attribute precedes and annotates the value that follows it
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
# Server side MULTI/EXEC transaction. Every command queued inside the
|
|
6
|
+
# block given to HotKV::Client#multi (or HotKV::WatchSession#multi) is
|
|
7
|
+
# sent to the server immediately and queued there; #exec runs EXEC and
|
|
8
|
+
# resolves every queued command's Future from the resulting array.
|
|
9
|
+
#
|
|
10
|
+
# result = client.multi do |tx|
|
|
11
|
+
# tx.set("a", "1")
|
|
12
|
+
# tx.incr("a")
|
|
13
|
+
# end
|
|
14
|
+
# result # => ["OK", 2], or nil if a watched key changed first
|
|
15
|
+
class Transaction
|
|
16
|
+
include Commands::All
|
|
17
|
+
|
|
18
|
+
def initialize(connection)
|
|
19
|
+
@connection = connection
|
|
20
|
+
@queue = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def command(*args, &transform)
|
|
24
|
+
invoke(*args, &transform)
|
|
25
|
+
end
|
|
26
|
+
alias_method :call, :command
|
|
27
|
+
|
|
28
|
+
# Sends +args+ to the server right away (MULTI is already active), so a
|
|
29
|
+
# bad command is caught as soon as it is queued, exactly like the
|
|
30
|
+
# server itself does.
|
|
31
|
+
def invoke(*args, &transform)
|
|
32
|
+
@connection.write_command(args)
|
|
33
|
+
reply = @connection.read_reply(raise_on_error: false)
|
|
34
|
+
future = Future.new(&transform)
|
|
35
|
+
if reply.is_a?(Error::ServerError)
|
|
36
|
+
future.resolve_error(reply)
|
|
37
|
+
@queue << [true, future]
|
|
38
|
+
else
|
|
39
|
+
@queue << [false, future]
|
|
40
|
+
end
|
|
41
|
+
future
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def size
|
|
45
|
+
@queue.size
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Runs EXEC and resolves every queued Future. Returns a plain Array of
|
|
49
|
+
# resolved values (or per command Error instances) in queue order, or
|
|
50
|
+
# nil if the transaction was aborted because a watched key changed.
|
|
51
|
+
def exec
|
|
52
|
+
@connection.write_command(["EXEC"])
|
|
53
|
+
raw = @connection.read_reply(raise_on_error: false)
|
|
54
|
+
return nil if raw.nil?
|
|
55
|
+
raise raw if raw.is_a?(Error::ServerError)
|
|
56
|
+
|
|
57
|
+
idx = 0
|
|
58
|
+
@queue.each do |(already_resolved, future)|
|
|
59
|
+
next if already_resolved
|
|
60
|
+
|
|
61
|
+
future.resolve(raw[idx])
|
|
62
|
+
idx += 1
|
|
63
|
+
end
|
|
64
|
+
@queue.map { |(_resolved, future)| future.value }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Yielded by HotKV::Client#watch. Bound to a single connection for the
|
|
69
|
+
# life of the watch block so that reads used to decide whether to
|
|
70
|
+
# transact, and the eventual transaction itself, see a consistent view
|
|
71
|
+
# and share the same WATCH state. Supports every typed command method
|
|
72
|
+
# (run immediately against the watched connection) plus #multi to start
|
|
73
|
+
# the actual MULTI/EXEC transaction.
|
|
74
|
+
class WatchSession
|
|
75
|
+
include Commands::All
|
|
76
|
+
|
|
77
|
+
def initialize(connection)
|
|
78
|
+
@connection = connection
|
|
79
|
+
@multi_started = false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def multi_started?
|
|
83
|
+
@multi_started
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def invoke(*args)
|
|
87
|
+
raw = @connection.call(*args, raise_on_error: false)
|
|
88
|
+
raise raw if raw.is_a?(Error::ServerError)
|
|
89
|
+
|
|
90
|
+
block_given? ? yield(raw) : raw
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def command(*args)
|
|
94
|
+
invoke(*args) { |raw| raw }
|
|
95
|
+
end
|
|
96
|
+
alias_method :call, :command
|
|
97
|
+
|
|
98
|
+
# Starts MULTI on the watched connection, yields a Transaction to queue
|
|
99
|
+
# commands on, and runs EXEC. Returns whatever Transaction#exec returns.
|
|
100
|
+
def multi
|
|
101
|
+
raise Error::ArgumentError, "multi requires a block" unless block_given?
|
|
102
|
+
|
|
103
|
+
@connection.call("MULTI")
|
|
104
|
+
@multi_started = true
|
|
105
|
+
tx = Transaction.new(@connection)
|
|
106
|
+
begin
|
|
107
|
+
yield tx
|
|
108
|
+
rescue StandardError
|
|
109
|
+
@connection.call("DISCARD", raise_on_error: false)
|
|
110
|
+
raise
|
|
111
|
+
end
|
|
112
|
+
tx.exec
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/hotkv/url.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Copyright (c) 2026 HotKV Ltd
|
|
2
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
|
|
3
|
+
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module HotKV
|
|
7
|
+
# Parses HotKV connection URLs into the option hash HotKV::Client#initialize
|
|
8
|
+
# understands. Accepts the native `hotkv://` / `hotkvs://` (TLS) schemes and,
|
|
9
|
+
# for drop in migration from other clients, `redis://` / `rediss://` and the
|
|
10
|
+
# unix socket forms `hotkv+unix://` / `redis+unix://`.
|
|
11
|
+
module URL
|
|
12
|
+
TLS_SCHEMES = %w[hotkvs rediss].freeze
|
|
13
|
+
PLAIN_SCHEMES = %w[hotkv redis].freeze
|
|
14
|
+
UNIX_SCHEMES = %w[hotkv+unix redis+unix].freeze
|
|
15
|
+
DEFAULT_PORT = 6379
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Returns a Hash of connection options parsed out of +url+, using only
|
|
20
|
+
# the keys that were present in the URL. HotKV::Client merges this under
|
|
21
|
+
# its explicit keyword options, so an explicit keyword always wins.
|
|
22
|
+
def parse(url)
|
|
23
|
+
raise Error::ArgumentError, "url must be a String, got #{url.class}" unless url.is_a?(String)
|
|
24
|
+
|
|
25
|
+
uri = begin
|
|
26
|
+
URI.parse(url)
|
|
27
|
+
rescue URI::InvalidURIError => e
|
|
28
|
+
raise Error::ArgumentError, "invalid HotKV URL #{url.inspect}: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
scheme = uri.scheme&.downcase
|
|
31
|
+
opts = {}
|
|
32
|
+
|
|
33
|
+
if UNIX_SCHEMES.include?(scheme)
|
|
34
|
+
path = uri.path
|
|
35
|
+
path = uri.opaque if path.nil? || path.empty?
|
|
36
|
+
raise Error::ArgumentError, "#{scheme}:// URL must include a socket path" if path.nil? || path.empty?
|
|
37
|
+
|
|
38
|
+
opts[:path] = path
|
|
39
|
+
elsif TLS_SCHEMES.include?(scheme) || PLAIN_SCHEMES.include?(scheme)
|
|
40
|
+
opts[:ssl] = TLS_SCHEMES.include?(scheme)
|
|
41
|
+
opts[:host] = (uri.host && !uri.host.empty?) ? uri.host : "127.0.0.1"
|
|
42
|
+
opts[:port] = uri.port || DEFAULT_PORT
|
|
43
|
+
else
|
|
44
|
+
raise Error::ArgumentError,
|
|
45
|
+
"unsupported HotKV URL scheme #{uri.scheme.inspect}, expected hotkv://, hotkvs://, " \
|
|
46
|
+
"redis://, rediss:// or a +unix variant"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
apply_userinfo!(opts, uri.userinfo)
|
|
50
|
+
apply_path_db!(opts, uri.path) unless UNIX_SCHEMES.include?(scheme)
|
|
51
|
+
apply_query!(opts, uri.query)
|
|
52
|
+
opts
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def apply_userinfo!(opts, userinfo)
|
|
56
|
+
return unless userinfo
|
|
57
|
+
|
|
58
|
+
if userinfo.include?(":")
|
|
59
|
+
user, pass = userinfo.split(":", 2)
|
|
60
|
+
else
|
|
61
|
+
user, pass = userinfo, nil
|
|
62
|
+
end
|
|
63
|
+
opts[:username] = URI.decode_www_form_component(user) if user && !user.empty?
|
|
64
|
+
opts[:password] = URI.decode_www_form_component(pass) if pass
|
|
65
|
+
end
|
|
66
|
+
private_class_method :apply_userinfo!
|
|
67
|
+
|
|
68
|
+
def apply_path_db!(opts, path)
|
|
69
|
+
return if path.nil?
|
|
70
|
+
|
|
71
|
+
db = path.sub(%r{\A/}, "")
|
|
72
|
+
opts[:db] = Integer(db) unless db.empty?
|
|
73
|
+
rescue ArgumentError
|
|
74
|
+
raise Error::ArgumentError, "invalid database index in URL path #{path.inspect}"
|
|
75
|
+
end
|
|
76
|
+
private_class_method :apply_path_db!
|
|
77
|
+
|
|
78
|
+
QUERY_BOOLEANS = %w[insecure_skip_verify skip_verify].freeze
|
|
79
|
+
private_constant :QUERY_BOOLEANS
|
|
80
|
+
|
|
81
|
+
def apply_query!(opts, query)
|
|
82
|
+
return if query.nil? || query.empty?
|
|
83
|
+
|
|
84
|
+
URI.decode_www_form(query).each do |key, value|
|
|
85
|
+
case key
|
|
86
|
+
when "db", "database" then opts[:db] = Integer(value)
|
|
87
|
+
when "client_name", "clientname" then opts[:client_name] = value
|
|
88
|
+
when "connect_timeout" then opts[:connect_timeout] = Float(value)
|
|
89
|
+
when "timeout", "command_timeout", "read_timeout" then opts[:command_timeout] = Float(value)
|
|
90
|
+
when "ca_file", "sslcacert" then opts[:ca_file] = value
|
|
91
|
+
when "cert_file", "sslcert" then opts[:cert_file] = value
|
|
92
|
+
when "key_file", "sslkey" then opts[:key_file] = value
|
|
93
|
+
when "server_name", "sni" then opts[:server_name] = value
|
|
94
|
+
when *QUERY_BOOLEANS then opts[:insecure_skip_verify] = %w[1 true yes].include?(value.to_s.downcase)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
private_class_method :apply_query!
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/hotkv/usage.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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 model call's token usage: the token usage group shared by
|
|
6
|
+
# PCACHE.SET, SCACHE.SET, every keyed cache SET (TOOL.CACHE/NCACHE/
|
|
7
|
+
# GUARD/RERANK) and LLMBUDGET.CONSUME.
|
|
8
|
+
#
|
|
9
|
+
# Recording a TokenUsage with a cache entry lets a later cache hit
|
|
10
|
+
# attribute its tokens, and once the model is priced its exact cost, to
|
|
11
|
+
# the namespace's usage_saved_* stats fields.
|
|
12
|
+
#
|
|
13
|
+
# +model+ is required; every count defaults to 0 and is left off the
|
|
14
|
+
# wire when zero (MODEL itself is always sent once a TokenUsage is
|
|
15
|
+
# given):
|
|
16
|
+
#
|
|
17
|
+
# usage = HotKV::TokenUsage.new(model: "claude-sonnet-5", input_tokens: 120, output_tokens: 40)
|
|
18
|
+
# client.pcache.set(ns, prompt, response, usage: usage)
|
|
19
|
+
TokenUsage = Struct.new(
|
|
20
|
+
:model, :input_tokens, :output_tokens, :cache_read_tokens, :cache_write_5m_tokens,
|
|
21
|
+
:cache_write_1h_tokens,
|
|
22
|
+
keyword_init: true
|
|
23
|
+
) do
|
|
24
|
+
def initialize(model:, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0,
|
|
25
|
+
cache_write_5m_tokens: 0, cache_write_1h_tokens: 0)
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The `MODEL <model> [INPUT n] [OUTPUT n] [CACHEREAD n] [CACHEWRITE5M
|
|
30
|
+
# n] [CACHEWRITE1H n]` KEY VALUE pairs this usage adds to a command's
|
|
31
|
+
# trailing options.
|
|
32
|
+
def wire_args
|
|
33
|
+
args = ["MODEL", model]
|
|
34
|
+
args.push("INPUT", input_tokens) unless input_tokens.zero?
|
|
35
|
+
args.push("OUTPUT", output_tokens) unless output_tokens.zero?
|
|
36
|
+
args.push("CACHEREAD", cache_read_tokens) unless cache_read_tokens.zero?
|
|
37
|
+
args.push("CACHEWRITE5M", cache_write_5m_tokens) unless cache_write_5m_tokens.zero?
|
|
38
|
+
args.push("CACHEWRITE1H", cache_write_1h_tokens) unless cache_write_1h_tokens.zero?
|
|
39
|
+
args
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/hotkv/wire.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
|
+
# Formatting of numeric command arguments, in one place.
|
|
6
|
+
#
|
|
7
|
+
# Ruby's Float#to_s switches to exponent form for small and large values
|
|
8
|
+
# ("1.0e-05", "1.0e+16"). Several server parsers refuse that form: the
|
|
9
|
+
# fixed point USD parser behind LLMBUDGET.SET and LLMSTATS.PRICE SET accepts
|
|
10
|
+
# plain digits only, with no exponent. Every Float, BigDecimal or Rational a
|
|
11
|
+
# caller hands to a command is therefore written by #float: plain decimal,
|
|
12
|
+
# the shortest digits that round trip, never an exponent, never locale
|
|
13
|
+
# dependent (no String#% or Kernel#format involved).
|
|
14
|
+
module Wire
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
FLOAT_PARTS = /\A(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?\z/.freeze
|
|
18
|
+
private_constant :FLOAT_PARTS
|
|
19
|
+
|
|
20
|
+
# Plain decimal text for +value+ (Integer, Float, BigDecimal, Rational).
|
|
21
|
+
# Infinity becomes "inf"/"-inf" (what the server's score parsers accept);
|
|
22
|
+
# NaN raises HotKV::Error::ArgumentError.
|
|
23
|
+
def float(value)
|
|
24
|
+
case value
|
|
25
|
+
when Integer then value.to_s
|
|
26
|
+
when Float then float_text(value)
|
|
27
|
+
when Rational then float_text(value.to_f)
|
|
28
|
+
when Numeric
|
|
29
|
+
raise Error::ArgumentError, "cannot format #{value.class} as a number" if value.respond_to?(:real?) && !value.real?
|
|
30
|
+
|
|
31
|
+
decimal_text(value)
|
|
32
|
+
else
|
|
33
|
+
raise Error::ArgumentError, "expected a number, got #{value.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Parses a float out of a server reply. Numbers pass through; Strings
|
|
38
|
+
# follow strtod, which is what the server prints with: "inf", "+inf",
|
|
39
|
+
# "-inf", "infinity" and "nan" (any case) as well as ordinary decimals and
|
|
40
|
+
# exponent forms. Kernel#Float alone refuses "inf", which a RESP2 reply
|
|
41
|
+
# gives for an infinite sorted set score.
|
|
42
|
+
def parse_float(value)
|
|
43
|
+
return value.to_f if value.is_a?(Numeric)
|
|
44
|
+
raise TypeError, "can't convert #{value.class} into Float" unless value.is_a?(String)
|
|
45
|
+
|
|
46
|
+
case value.downcase
|
|
47
|
+
when "inf", "+inf", "infinity", "+infinity" then Float::INFINITY
|
|
48
|
+
when "-inf", "-infinity" then -Float::INFINITY
|
|
49
|
+
when "nan", "+nan", "-nan" then Float::NAN
|
|
50
|
+
else Float(value)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns +value+ unchanged after refusing NaN and +/-Infinity, for the
|
|
55
|
+
# arguments where the server accepts finite numbers only (INCRBYFLOAT,
|
|
56
|
+
# coordinates, similarity thresholds, vector components, ...). Non numeric
|
|
57
|
+
# values (Strings, nil) are passed through for the server to judge.
|
|
58
|
+
def finite(value)
|
|
59
|
+
if value.is_a?(Numeric) && !value.is_a?(Integer) && !value.is_a?(Rational)
|
|
60
|
+
raise Error::ArgumentError, "expected a finite number, got #{value}" if value.nan? || value.infinite?
|
|
61
|
+
end
|
|
62
|
+
value
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Applies #finite to every component of +vector+ and returns an Array.
|
|
66
|
+
def finite_vector(vector)
|
|
67
|
+
Array(vector).map { |component| finite(component) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Comma joined plain decimal components, the form RAG.INGEST VECTORS and
|
|
71
|
+
# RAG.SEARCH VECTOR take.
|
|
72
|
+
def csv_vector(vector)
|
|
73
|
+
Array(vector).map { |component| float(finite(component)) }.join(",")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A US dollar amount in the fixed point form the server's money parser
|
|
77
|
+
# takes: digits, an optional dot and at most 6 decimals (micro dollars),
|
|
78
|
+
# no sign, no exponent, never ".5" or "5.". Numbers are rounded to the
|
|
79
|
+
# nearest micro dollar; negative, NaN and infinite values raise, and so
|
|
80
|
+
# does a non zero amount too small to survive the rounding (it would
|
|
81
|
+
# otherwise silently become 0, which means "no limit" to LLMBUDGET.SET).
|
|
82
|
+
# Strings pass through untouched for the server to validate.
|
|
83
|
+
def usd(value)
|
|
84
|
+
return value if value.is_a?(String)
|
|
85
|
+
raise Error::ArgumentError, "expected a USD amount, got #{value.inspect}" unless value.is_a?(Numeric)
|
|
86
|
+
|
|
87
|
+
text = float(value)
|
|
88
|
+
raise Error::ArgumentError, "USD amount must be finite, got #{value}" if text.end_with?("inf")
|
|
89
|
+
raise Error::ArgumentError, "USD amount must not be negative, got #{value}" if text.start_with?("-") && text != "-0"
|
|
90
|
+
|
|
91
|
+
micros = (Rational(text) * 1_000_000).round
|
|
92
|
+
raise Error::ArgumentError, "USD amount #{text} is below one micro dollar" if micros.zero? && Rational(text) != 0
|
|
93
|
+
|
|
94
|
+
whole, frac = micros.divmod(1_000_000)
|
|
95
|
+
return whole.to_s if frac.zero?
|
|
96
|
+
|
|
97
|
+
"#{whole}.#{frac.to_s.rjust(6, '0').sub(/0+\z/, '')}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def float_text(value)
|
|
101
|
+
raise Error::ArgumentError, "NaN is not a valid number" if value.nan?
|
|
102
|
+
return(value.positive? ? "inf" : "-inf") if value.infinite?
|
|
103
|
+
|
|
104
|
+
expand(value.to_s)
|
|
105
|
+
end
|
|
106
|
+
private_class_method :float_text
|
|
107
|
+
|
|
108
|
+
# BigDecimal goes through its own decimal string (BigDecimal#to_s("F"))
|
|
109
|
+
# so it is exact and needs no require of the bigdecimal library; any other
|
|
110
|
+
# Numeric goes through Float.
|
|
111
|
+
def decimal_text(value)
|
|
112
|
+
if value.class.name == "BigDecimal"
|
|
113
|
+
return float_text(value.to_f) if value.nan? || value.infinite?
|
|
114
|
+
|
|
115
|
+
return expand(value.to_s("F"))
|
|
116
|
+
end
|
|
117
|
+
float_text(Float(value))
|
|
118
|
+
end
|
|
119
|
+
private_class_method :decimal_text
|
|
120
|
+
|
|
121
|
+
# Rewrites "1.0e-05", "-2.5E+3", "0.1e-4", "123.456", "1e22" as plain
|
|
122
|
+
# decimal text with no exponent and no needless trailing zeros.
|
|
123
|
+
def expand(text)
|
|
124
|
+
m = FLOAT_PARTS.match(text) or raise Error::ArgumentError, "cannot format #{text.inspect} as a decimal"
|
|
125
|
+
sign, int, frac, exp = m.captures
|
|
126
|
+
digits = int + (frac || "")
|
|
127
|
+
point = int.length + exp.to_i # digits[0, point] is the integer part
|
|
128
|
+
if point <= 0
|
|
129
|
+
int_part = "0"
|
|
130
|
+
frac_part = ("0" * -point) + digits
|
|
131
|
+
elsif point >= digits.length
|
|
132
|
+
int_part = digits + ("0" * (point - digits.length))
|
|
133
|
+
frac_part = ""
|
|
134
|
+
else
|
|
135
|
+
int_part = digits[0, point]
|
|
136
|
+
frac_part = digits[point..]
|
|
137
|
+
end
|
|
138
|
+
int_part = int_part.sub(/\A0+(?=\d)/, "")
|
|
139
|
+
frac_part = frac_part.sub(/0+\z/, "")
|
|
140
|
+
out = frac_part.empty? ? int_part : "#{int_part}.#{frac_part}"
|
|
141
|
+
sign == "-" && out != "0" ? "-#{out}" : out
|
|
142
|
+
end
|
|
143
|
+
private_class_method :expand
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/hotkv.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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 "hotkv/version"
|
|
5
|
+
require_relative "hotkv/errors"
|
|
6
|
+
require_relative "hotkv/url"
|
|
7
|
+
require_relative "hotkv/wire"
|
|
8
|
+
require_relative "hotkv/resp"
|
|
9
|
+
require_relative "hotkv/connection"
|
|
10
|
+
require_relative "hotkv/pool"
|
|
11
|
+
require_relative "hotkv/future"
|
|
12
|
+
require_relative "hotkv/usage"
|
|
13
|
+
require_relative "hotkv/llm_price"
|
|
14
|
+
require_relative "hotkv/commands/all"
|
|
15
|
+
require_relative "hotkv/pipeline"
|
|
16
|
+
require_relative "hotkv/transaction"
|
|
17
|
+
require_relative "hotkv/pubsub"
|
|
18
|
+
require_relative "hotkv/cluster/crc16"
|
|
19
|
+
require_relative "hotkv/cluster"
|
|
20
|
+
require_relative "hotkv/client"
|
|
21
|
+
require_relative "hotkv/ai/call_result"
|
|
22
|
+
require_relative "hotkv/ai/prompt_cache"
|
|
23
|
+
require_relative "hotkv/ai/semantic_cache"
|
|
24
|
+
|
|
25
|
+
# Official HotKV Ruby SDK. Start with HotKV::Client for a single server,
|
|
26
|
+
# or HotKV::Cluster for a HotKV Cluster deployment.
|
|
27
|
+
module HotKV
|
|
28
|
+
end
|