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,397 @@
|
|
|
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 Namespaces
|
|
6
|
+
# SCACHE.* (semantic cache; needs a license that includes the
|
|
7
|
+
# SemanticCache feature). See also HotKV::AI::SemanticCache for the higher
|
|
8
|
+
# level, provider neutral helper built on this.
|
|
9
|
+
class SCache
|
|
10
|
+
def initialize(client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create(ns, dims, metric: nil, threshold: nil, ttl: nil)
|
|
15
|
+
args = ["SCACHE.CREATE", ns, dims]
|
|
16
|
+
args.push("METRIC", metric) if metric
|
|
17
|
+
args.push("THRESHOLD", Wire.finite(threshold)) if threshold
|
|
18
|
+
args.push("TTL", ttl) if ttl
|
|
19
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# +usage+ (a HotKV::TokenUsage), if given, records the original
|
|
23
|
+
# (uncached) call's token usage so a later hit attributes it (and
|
|
24
|
+
# its cost, once the model is priced) to #stats's usage_saved_*
|
|
25
|
+
# counters.
|
|
26
|
+
def set(ns, query_text, response, vector, ttl: nil, usage: nil)
|
|
27
|
+
args = ["SCACHE.SET", ns, query_text, response, *Wire.finite_vector(vector)]
|
|
28
|
+
args.push("TTL", ttl) if ttl
|
|
29
|
+
args.concat(usage.wire_args) if usage
|
|
30
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(ns, vector, threshold: nil)
|
|
34
|
+
args = ["SCACHE.GET", ns, *Wire.finite_vector(vector)]
|
|
35
|
+
args.push("THRESHOLD", Wire.finite(threshold)) if threshold
|
|
36
|
+
@client.invoke(*args) { |r| r.nil? ? nil : { response: r[0], similarity: Wire.parse_float(r[1]) } }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# entries, hits, misses, hit_rate, p50_us and the usage_saved_*
|
|
40
|
+
# savings counters for +ns+.
|
|
41
|
+
def stats(ns)
|
|
42
|
+
@client.invoke("SCACHE.STATS", ns) do |r|
|
|
43
|
+
Commands::Support.pairs_to_h(r, float_keys: %i[hit_rate usage_saved_cost_usd])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def flush(ns)
|
|
48
|
+
@client.invoke("SCACHE.FLUSH", ns) { |r| r == 1 }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def drop(ns)
|
|
52
|
+
@client.invoke("SCACHE.DROP", ns) { |r| r == 1 }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def list
|
|
56
|
+
@client.invoke("SCACHE.LIST")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def threshold(ns, value)
|
|
60
|
+
@client.invoke("SCACHE.THRESHOLD", ns, Wire.finite(value)) { |r| r == "OK" }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# PCACHE.* (prompt/LLM response cache; needs the PromptCache license
|
|
65
|
+
# feature). See also HotKV::AI::PromptCache for the higher level helper.
|
|
66
|
+
class PCache
|
|
67
|
+
def initialize(client)
|
|
68
|
+
@client = client
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def create(ns, max_entries: nil, ttl: nil, cost_per_hit: nil)
|
|
72
|
+
args = ["PCACHE.CREATE", ns]
|
|
73
|
+
args.push("MAX_ENTRIES", max_entries) if max_entries
|
|
74
|
+
args.push("TTL", ttl) if ttl
|
|
75
|
+
args.push("COST_PER_HIT", Wire.finite(cost_per_hit)) if cost_per_hit
|
|
76
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# +usage+ (a HotKV::TokenUsage), if given, records the original
|
|
80
|
+
# (uncached) call's token usage so a later hit attributes it (and
|
|
81
|
+
# its cost, once the model is priced) to #stats's usage_saved_*
|
|
82
|
+
# counters.
|
|
83
|
+
def set(ns, prompt, response, ttl: nil, meta: nil, usage: nil)
|
|
84
|
+
args = ["PCACHE.SET", ns, prompt, response]
|
|
85
|
+
args.push("TTL", ttl) if ttl
|
|
86
|
+
args.push("META", meta) if meta
|
|
87
|
+
args.concat(usage.wire_args) if usage
|
|
88
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def get(ns, prompt)
|
|
92
|
+
@client.invoke("PCACHE.GET", ns, prompt)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def getex(ns, prompt)
|
|
96
|
+
@client.invoke("PCACHE.GETEX", ns, prompt) do |r|
|
|
97
|
+
r.nil? ? nil : { response: r[0], metadata: r[1], hit_count: r[2] }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def del(ns, prompt)
|
|
102
|
+
@client.invoke("PCACHE.DEL", ns, prompt) { |r| r == 1 }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# entry/hit/miss/eviction/latency/cost statistics for +ns+, including
|
|
106
|
+
# the usage_saved_* savings counters.
|
|
107
|
+
def stats(ns)
|
|
108
|
+
@client.invoke("PCACHE.STATS", ns) do |r|
|
|
109
|
+
Commands::Support.pairs_to_h(r, float_keys: %i[hit_rate cost_saved_usd usage_saved_cost_usd])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def flush(ns)
|
|
114
|
+
@client.invoke("PCACHE.FLUSH", ns)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def drop(ns)
|
|
118
|
+
@client.invoke("PCACHE.DROP", ns) { |r| r == 1 }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def list
|
|
122
|
+
@client.invoke("PCACHE.LIST")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def config_set(ns, key, value)
|
|
126
|
+
@client.invoke("PCACHE.CONFIG", ns, "SET", key, value) { |r| r == "OK" }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Shared engine behind TOOL.CACHE.*, NCACHE.*, GUARD.* and RERANK.*:
|
|
131
|
+
# four identical key/value caches, one per HotKV::Client#tool_cache,
|
|
132
|
+
# #ncache, #guard and #rerank accessor.
|
|
133
|
+
class KeyedCache
|
|
134
|
+
def initialize(client, wire_prefix)
|
|
135
|
+
@client = client
|
|
136
|
+
@prefix = wire_prefix
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def create(ns, max_entries: nil, ttl: nil)
|
|
140
|
+
args = ["#{@prefix}.CREATE", ns]
|
|
141
|
+
args.push("MAX_ENTRIES", max_entries) if max_entries
|
|
142
|
+
args.push("TTL", ttl) if ttl
|
|
143
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# +usage+ (a HotKV::TokenUsage), if given, records the original
|
|
147
|
+
# (uncached) call's token usage so a later hit attributes it (and
|
|
148
|
+
# its cost, once the model is priced) to #stats's usage_saved_*
|
|
149
|
+
# counters.
|
|
150
|
+
def set(ns, key, value, ttl: nil, meta: nil, usage: nil)
|
|
151
|
+
args = ["#{@prefix}.SET", ns, key, value]
|
|
152
|
+
args.push("TTL", ttl) if ttl
|
|
153
|
+
args.push("META", meta) if meta
|
|
154
|
+
args.concat(usage.wire_args) if usage
|
|
155
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def get(ns, key)
|
|
159
|
+
@client.invoke("#{@prefix}.GET", ns, key)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def del(ns, key)
|
|
163
|
+
@client.invoke("#{@prefix}.DEL", ns, key) { |r| r == 1 }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# entries, hits, misses, evictions, memory_bytes and the
|
|
167
|
+
# usage_saved_* savings counters for +ns+.
|
|
168
|
+
def stats(ns)
|
|
169
|
+
@client.invoke("#{@prefix}.STATS", ns) do |r|
|
|
170
|
+
Commands::Support.pairs_to_h(r, float_keys: %i[usage_saved_cost_usd])
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def flush(ns)
|
|
175
|
+
@client.invoke("#{@prefix}.FLUSH", ns)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def drop(ns)
|
|
179
|
+
@client.invoke("#{@prefix}.DROP", ns) { |r| r == 1 }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def list
|
|
183
|
+
@client.invoke("#{@prefix}.LIST")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# LLMSTATS.* (needs the LLMStats license feature).
|
|
188
|
+
class LLMStats
|
|
189
|
+
def initialize(client)
|
|
190
|
+
@client = client
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def bucket(name)
|
|
194
|
+
@client.invoke("LLMSTATS.BUCKET", name) { |r| r == "OK" }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# +cached_tokens+ is cache-READ tokens. With +model+, the server
|
|
198
|
+
# prices the call itself from its LLMSTATS.PRICE table and ignores
|
|
199
|
+
# +cost_usd+ (pass 0.0); an unpriced model still records tokens,
|
|
200
|
+
# costs 0 and counts toward +unpriced_requests+. For OpenAI, pass
|
|
201
|
+
# <tt>prompt_tokens - cached_tokens</tt> as +input_tokens+ (OpenAI
|
|
202
|
+
# reports cache hits as part of prompt_tokens, unlike Anthropic).
|
|
203
|
+
def record(bucket, input_tokens, output_tokens, cached_tokens, cost_usd, latency_ms,
|
|
204
|
+
error: nil, model: nil, cache_write_5m_tokens: nil, cache_write_1h_tokens: nil)
|
|
205
|
+
args = ["LLMSTATS.RECORD", bucket, input_tokens, output_tokens, cached_tokens, Wire.finite(cost_usd), latency_ms]
|
|
206
|
+
args.push("ERROR", error ? 1 : 0) unless error.nil?
|
|
207
|
+
args.push("MODEL", model) if model
|
|
208
|
+
args.push("CACHEWRITE5M", cache_write_5m_tokens) if cache_write_5m_tokens
|
|
209
|
+
args.push("CACHEWRITE1H", cache_write_1h_tokens) if cache_write_1h_tokens
|
|
210
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# requests/errors/token/cost/latency stats for +bucket+, including a
|
|
214
|
+
# trailing 1-minute rolling window and unpriced_requests.
|
|
215
|
+
def get(bucket)
|
|
216
|
+
@client.invoke("LLMSTATS.GET", bucket) do |r|
|
|
217
|
+
Commands::Support.pairs_to_h(r, float_keys: %i[cost_usd avg_latency_ms cost_usd_1m])
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def reset(bucket)
|
|
222
|
+
@client.invoke("LLMSTATS.RESET", bucket) { |r| r == 1 }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def drop(bucket)
|
|
226
|
+
@client.invoke("LLMSTATS.DROP", bucket) { |r| r == 1 }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def list
|
|
230
|
+
@client.invoke("LLMSTATS.LIST")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# -- LLMSTATS.PRICE (per model price table, used by #record and
|
|
234
|
+
# Namespaces::LLMBudget#consume when given +model+/+usage+) --------
|
|
235
|
+
|
|
236
|
+
# Sets a custom USD-per-1,000,000-token price for +model+. Prices
|
|
237
|
+
# may be a Numeric or a decimal String; an omitted cache price
|
|
238
|
+
# defaults to 0.1x/1.25x/2x +input_usd_per_mtok+.
|
|
239
|
+
def price_set(model, input_usd_per_mtok, output_usd_per_mtok, cache_read_usd_per_mtok: nil,
|
|
240
|
+
cache_write_5m_usd_per_mtok: nil, cache_write_1h_usd_per_mtok: nil)
|
|
241
|
+
args = [
|
|
242
|
+
"LLMSTATS.PRICE", "SET", model,
|
|
243
|
+
"INPUT", LLMPrice.format_price(input_usd_per_mtok),
|
|
244
|
+
"OUTPUT", LLMPrice.format_price(output_usd_per_mtok)
|
|
245
|
+
]
|
|
246
|
+
args.push("CACHEREAD", LLMPrice.format_price(cache_read_usd_per_mtok)) if cache_read_usd_per_mtok
|
|
247
|
+
args.push("CACHEWRITE5M", LLMPrice.format_price(cache_write_5m_usd_per_mtok)) if cache_write_5m_usd_per_mtok
|
|
248
|
+
args.push("CACHEWRITE1H", LLMPrice.format_price(cache_write_1h_usd_per_mtok)) if cache_write_1h_usd_per_mtok
|
|
249
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# +model+'s price table entry. An unknown model comes back with
|
|
253
|
+
# <tt>source == "unpriced"</tt> and every price 0.0, not an error.
|
|
254
|
+
def price_get(model)
|
|
255
|
+
@client.invoke("LLMSTATS.PRICE", "GET", model) { |r| price_from_reply(r) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Every known model's price table entry (built in and custom).
|
|
259
|
+
def price_list
|
|
260
|
+
@client.invoke("LLMSTATS.PRICE", "LIST") { |r| r.map { |item| price_from_reply(item) } }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Removes +model+'s custom price override, if any (false if there
|
|
264
|
+
# was none; the model then falls back to its built-in price, if any).
|
|
265
|
+
def price_del(model)
|
|
266
|
+
@client.invoke("LLMSTATS.PRICE", "DEL", model) { |r| r == 1 }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
def price_from_reply(reply)
|
|
272
|
+
LLMPrice.from_fields(Commands::Support.pairs_to_h(reply, float_keys: LLMPrice::FLOAT_FIELDS))
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# LLMBUDGET.* (needs the LLMBudget license feature).
|
|
277
|
+
class LLMBudget
|
|
278
|
+
def initialize(client)
|
|
279
|
+
@client = client
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def set(name, token_limit, cost_limit_usd, window_seconds)
|
|
283
|
+
@client.invoke("LLMBUDGET.SET", name, token_limit, Wire.usd(cost_limit_usd), window_seconds) { |r| r == "OK" }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Tries to consume +tokens+/+cost_usd+ from budget +name+; true if
|
|
287
|
+
# allowed, false if it would exceed the cap. Pass +usage+ (a
|
|
288
|
+
# HotKV::TokenUsage) instead to have the server compute both from
|
|
289
|
+
# its LLMSTATS.PRICE table (this sends tokens/cost_usd as 0/0.0
|
|
290
|
+
# regardless of what is passed, since the server ignores them).
|
|
291
|
+
def consume(name, tokens = 0, cost_usd = 0.0, usage: nil)
|
|
292
|
+
args = ["LLMBUDGET.CONSUME", name, tokens, Wire.finite(cost_usd)]
|
|
293
|
+
args.concat(usage.wire_args) if usage
|
|
294
|
+
@client.invoke(*args) { |r| r == 1 }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# usage, limits, the current window's remaining time and
|
|
298
|
+
# unpriced_requests for +name+.
|
|
299
|
+
def status(name)
|
|
300
|
+
@client.invoke("LLMBUDGET.STATUS", name) do |r|
|
|
301
|
+
Commands::Support.pairs_to_h(r, float_keys: %i[cost_used_usd cost_limit_usd])
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def reset(name)
|
|
306
|
+
@client.invoke("LLMBUDGET.RESET", name) { |r| r == 1 }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def drop(name)
|
|
310
|
+
@client.invoke("LLMBUDGET.DROP", name) { |r| r == 1 }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def list
|
|
314
|
+
@client.invoke("LLMBUDGET.LIST")
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# PROMPT.* (versioned prompt/template registry; needs the PromptRegistry license feature).
|
|
319
|
+
class Prompt
|
|
320
|
+
def initialize(client)
|
|
321
|
+
@client = client
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def put(name, content, meta: nil)
|
|
325
|
+
args = ["PROMPT.PUT", name, content]
|
|
326
|
+
args.push("META", meta) if meta
|
|
327
|
+
@client.invoke(*args)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def get(name, version = nil)
|
|
331
|
+
args = version ? ["PROMPT.GET", name, version] : ["PROMPT.GET", name]
|
|
332
|
+
@client.invoke(*args) { |r| prompt_version(r) }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def latest(name)
|
|
336
|
+
@client.invoke("PROMPT.LATEST", name) { |r| prompt_version(r) }
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def setactive(name, version)
|
|
340
|
+
@client.invoke("PROMPT.SETACTIVE", name, version) { |r| r == "OK" }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def versions(name)
|
|
344
|
+
@client.invoke("PROMPT.VERSIONS", name)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def drop(name)
|
|
348
|
+
@client.invoke("PROMPT.DROP", name) { |r| r == 1 }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def list
|
|
352
|
+
@client.invoke("PROMPT.LIST")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
private
|
|
356
|
+
|
|
357
|
+
def prompt_version(r)
|
|
358
|
+
r.nil? ? nil : { version: r[0], content: r[1], metadata: r[2], created_unix: r[3] }
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# EMBED.DEDUP.* (needs the EmbedDedup license feature).
|
|
363
|
+
class EmbedDedup
|
|
364
|
+
def initialize(client)
|
|
365
|
+
@client = client
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def put(ns, hash, model, embedding)
|
|
369
|
+
@client.invoke("EMBED.DEDUP.PUT", ns, hash, model, embedding) { |r| r == 1 }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def get(ns, hash)
|
|
373
|
+
@client.invoke("EMBED.DEDUP.GET", ns, hash)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def release(ns, hash)
|
|
377
|
+
@client.invoke("EMBED.DEDUP.RELEASE", ns, hash) { |r| r == 1 }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def stats(ns)
|
|
381
|
+
@client.invoke("EMBED.DEDUP.STATS", ns) { |r| Commands::Support.pairs_to_h(r) }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def drop(ns)
|
|
385
|
+
@client.invoke("EMBED.DEDUP.DROP", ns) { |r| r == 1 }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def purge(ns)
|
|
389
|
+
@client.invoke("EMBED.DEDUP.PURGE", ns) { |r| r == 1 }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def list
|
|
393
|
+
@client.invoke("EMBED.DEDUP.LIST")
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
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 Namespaces
|
|
6
|
+
# TAG.* (core): freeform tags on any key.
|
|
7
|
+
class Tag
|
|
8
|
+
def initialize(client)
|
|
9
|
+
@client = client
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def set(key, *tags)
|
|
13
|
+
@client.invoke("TAG.SET", key, *tags.flatten)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def rem(key, *tags)
|
|
17
|
+
@client.invoke("TAG.REM", key, *tags.flatten)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get(key)
|
|
21
|
+
@client.invoke("TAG.GET", key)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def members(*tags)
|
|
25
|
+
@client.invoke("TAG.MEMBERS", *tags.flatten)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def inter(*tags)
|
|
29
|
+
@client.invoke("TAG.INTER", *tags.flatten)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def count(key)
|
|
33
|
+
@client.invoke("TAG.COUNT", key)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def exists(key, tag)
|
|
37
|
+
@client.invoke("TAG.EXISTS", key, tag) { |r| r == 1 }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# GCRA.* (core): the generic cell rate algorithm limiter primitive.
|
|
42
|
+
class Gcra
|
|
43
|
+
def initialize(client)
|
|
44
|
+
@client = client
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def limit(key, period_ms, burst, count = nil)
|
|
48
|
+
args = ["GCRA.LIMIT", key, period_ms, burst]
|
|
49
|
+
args.push(count) if count
|
|
50
|
+
@client.invoke(*args) do |r|
|
|
51
|
+
{ denied: r[0] == 1, limit: r[1], remaining: r[2], retry_after_ms: r[3], reset_after_ms: r[4] }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reset(*keys)
|
|
56
|
+
@client.invoke("GCRA.RESET", *keys.flatten)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# VSIM.* (core, except SAVE/LOAD/LIST/DROP persistence which need a
|
|
61
|
+
# license that includes the VSIMPersist feature): a vector similarity
|
|
62
|
+
# index.
|
|
63
|
+
class VSim
|
|
64
|
+
def initialize(client)
|
|
65
|
+
@client = client
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def createindex(name, dims, metric: nil, m: nil, ef_construction: nil, ef_search: nil)
|
|
69
|
+
args = ["VSIM.CREATEINDEX", name, dims]
|
|
70
|
+
args.push("METRIC", metric) if metric
|
|
71
|
+
args.push("M", m) if m
|
|
72
|
+
args.push("EF_CONSTRUCTION", ef_construction) if ef_construction
|
|
73
|
+
args.push("EF_SEARCH", ef_search) if ef_search
|
|
74
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dropindex(name)
|
|
78
|
+
@client.invoke("VSIM.DROPINDEX", name) { |r| r == "OK" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def set(index, key, vector)
|
|
82
|
+
@client.invoke("VSIM.SET", index, key, *Wire.finite_vector(vector))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def get(index, key)
|
|
86
|
+
@client.invoke("VSIM.GET", index, key) { |r| r&.map { |v| Wire.parse_float(v) } }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def del(index, key)
|
|
90
|
+
@client.invoke("VSIM.DEL", index, key)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def search(index, k, vector, filter: nil, ef: nil)
|
|
94
|
+
args = ["VSIM.SEARCH", index, k, *Wire.finite_vector(vector)]
|
|
95
|
+
args.push("FILTER", *Array(filter)) if filter
|
|
96
|
+
args.push("EF", ef) if ef
|
|
97
|
+
@client.invoke(*args) { |r| r.each_slice(2).map { |(key, dist)| [key, Wire.parse_float(dist)] } }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def info(index = nil)
|
|
101
|
+
return @client.invoke("VSIM.INFO") if index.nil?
|
|
102
|
+
|
|
103
|
+
@client.invoke("VSIM.INFO", index) { |r| Commands::Support.pairs_to_h(r) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Persistence: needs the VSIMPersist license feature.
|
|
107
|
+
def save(snapshot_name, blob)
|
|
108
|
+
@client.invoke("VSIM.SAVE", snapshot_name, blob)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def load(snapshot_name)
|
|
112
|
+
@client.invoke("VSIM.LOAD", snapshot_name)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def list
|
|
116
|
+
@client.invoke("VSIM.LIST")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def drop(snapshot_name)
|
|
120
|
+
@client.invoke("VSIM.DROP", snapshot_name) { |r| r == 1 }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# OBSERVE (needs the Observability license feature; admin).
|
|
125
|
+
class Observe
|
|
126
|
+
def initialize(client)
|
|
127
|
+
@client = client
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def info
|
|
131
|
+
@client.invoke("OBSERVE", "INFO") { |r| Commands::Support.pairs_to_h(r) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def metrics
|
|
135
|
+
@client.invoke("OBSERVE", "METRICS") { |r| Commands::Support.pairs_to_h(r) }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# ALERTS (needs the Observability license feature; admin).
|
|
140
|
+
class Alerts
|
|
141
|
+
def initialize(client)
|
|
142
|
+
@client = client
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def info
|
|
146
|
+
@client.invoke("ALERTS", "INFO") { |r| Commands::Support.pairs_to_h(r) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def test(severity = nil, *message)
|
|
150
|
+
args = ["ALERTS", "TEST"]
|
|
151
|
+
args.push(severity) if severity
|
|
152
|
+
flat_message = message.flatten
|
|
153
|
+
args.concat(flat_message) unless flat_message.empty?
|
|
154
|
+
@client.invoke(*args)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def seturl(url)
|
|
158
|
+
@client.invoke("ALERTS", "SETURL", url)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def setseverity(severity)
|
|
162
|
+
@client.invoke("ALERTS", "SETSEVERITY", severity)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def on
|
|
166
|
+
@client.invoke("ALERTS", "ON")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def off
|
|
170
|
+
@client.invoke("ALERTS", "OFF")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# HOTKEYS (core, admin).
|
|
175
|
+
class Hotkeys
|
|
176
|
+
def initialize(client)
|
|
177
|
+
@client = client
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def top(count = nil)
|
|
181
|
+
args = count ? ["HOTKEYS", "TOP", count] : ["HOTKEYS", "TOP"]
|
|
182
|
+
@client.invoke(*args)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def reset
|
|
186
|
+
@client.invoke("HOTKEYS", "RESET") { |r| r == "OK" }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|