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,166 @@
|
|
|
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
|
+
# RATELIMIT.* (needs the RateLimiting license feature).
|
|
7
|
+
class RateLimit
|
|
8
|
+
def initialize(client)
|
|
9
|
+
@client = client
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create(name, limit, window_ms, algo: nil, refill: nil, drain: nil)
|
|
13
|
+
args = ["RATELIMIT.CREATE", name, limit, window_ms]
|
|
14
|
+
args.push("ALGO", algo) if algo
|
|
15
|
+
args.push("REFILL", Wire.finite(refill)) if refill
|
|
16
|
+
args.push("DRAIN", Wire.finite(drain)) if drain
|
|
17
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def check(name, key, cost: nil)
|
|
21
|
+
args = ["RATELIMIT.CHECK", name, key]
|
|
22
|
+
args.push("COST", cost) if cost
|
|
23
|
+
@client.invoke(*args) { |r| result(r) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def peek(name, key)
|
|
27
|
+
@client.invoke("RATELIMIT.PEEK", name, key) { |r| result(r) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reset(name, key)
|
|
31
|
+
@client.invoke("RATELIMIT.RESET", name, key) { |r| r == "OK" }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stats(name)
|
|
35
|
+
@client.invoke("RATELIMIT.STATS", name) { |r| Commands::Support.pairs_to_h(r) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def drop(name)
|
|
39
|
+
@client.invoke("RATELIMIT.DROP", name) { |r| r == "OK" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def list
|
|
43
|
+
@client.invoke("RATELIMIT.LIST")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def result(r)
|
|
49
|
+
{ allowed: r[0] == 1, remaining: r[1], retry_after_ms: r[2], limit: r[3], current: r[4] }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# TS.* (time series; needs the TimeSeries license feature).
|
|
54
|
+
class TS
|
|
55
|
+
def initialize(client)
|
|
56
|
+
@client = client
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def create(key, retention: nil, labels: nil)
|
|
60
|
+
args = ["TS.CREATE", key]
|
|
61
|
+
args.push("RETENTION", retention) if retention
|
|
62
|
+
args.push("LABELS", *labels.flat_map { |k, v| [k, v] }) if labels && !labels.empty?
|
|
63
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def add(key, value, timestamp: "*")
|
|
67
|
+
@client.invoke("TS.ADD", key, timestamp, Wire.finite(value))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def range(key, from_ms, to_ms, count: nil)
|
|
71
|
+
args = ["TS.RANGE", key, from_ms, to_ms]
|
|
72
|
+
args.push("COUNT", count) if count
|
|
73
|
+
@client.invoke(*args) { |r| r.map { |(ts, v)| [ts, Wire.parse_float(v)] } }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def agg(key, from_ms, to_ms, bucket_ms, agg_type)
|
|
77
|
+
@client.invoke("TS.AGG", key, from_ms, to_ms, bucket_ms, agg_type) { |r| r.map { |(ts, v)| [ts, Wire.parse_float(v)] } }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def info(key)
|
|
81
|
+
@client.invoke("TS.INFO", key) { |r| Commands::Support.pairs_to_h(r, float_keys: %i[lastValue]) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def delete(key)
|
|
85
|
+
@client.invoke("TS.DELETE", key) { |r| r == "OK" }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def retention(key, retention_ms)
|
|
89
|
+
@client.invoke("TS.RETENTION", key, retention_ms) { |r| r == "OK" }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def downsample(src_key, dest_key, agg_type, bucket_ms)
|
|
93
|
+
@client.invoke("TS.DOWNSAMPLE", src_key, dest_key, agg_type, bucket_ms) { |r| r == "OK" }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def list(label: nil, value: nil)
|
|
97
|
+
args = ["TS.LIST"]
|
|
98
|
+
args.push("LABEL", label, value) if label
|
|
99
|
+
@client.invoke(*args)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# FEATURE.* (feature store; needs the FeatureStore license feature).
|
|
104
|
+
class Feature
|
|
105
|
+
def initialize(client)
|
|
106
|
+
@client = client
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# +schema+: a Hash {name => type} or a flat [name, type, ...] list.
|
|
110
|
+
def create(group, description, schema)
|
|
111
|
+
@client.invoke("FEATURE.CREATE", group, description, "SCHEMA", *flatten_kv(schema)) { |r| r == "OK" }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def set(group, entity, values)
|
|
115
|
+
@client.invoke("FEATURE.SET", group, entity, *flatten_kv(values)) { |r| r == "OK" }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# FEATURE.SET with an explicit stamp: +unix_ms+ is the time (Unix
|
|
119
|
+
# milliseconds, within 100 years of now) the entity is stamped with.
|
|
120
|
+
def setat(group, entity, unix_ms, values)
|
|
121
|
+
@client.invoke("FEATURE.SETAT", group, entity, unix_ms, *flatten_kv(values)) { |r| r == "OK" }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def get(group, entity, features = nil)
|
|
125
|
+
args = ["FEATURE.GET", group, entity]
|
|
126
|
+
args.concat(Array(features)) if features
|
|
127
|
+
@client.invoke(*args) { |r| result(r) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def batch(group, entities, features: nil)
|
|
131
|
+
args = ["FEATURE.BATCH", group, *entities]
|
|
132
|
+
args.push("FEATURES", *features) if features
|
|
133
|
+
@client.invoke(*args) { |r| r.map { |row| result(row) } }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def schema(group)
|
|
137
|
+
@client.invoke("FEATURE.SCHEMA", group)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def list
|
|
141
|
+
@client.invoke("FEATURE.LIST")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def drop(group)
|
|
145
|
+
@client.invoke("FEATURE.DROP", group) { |r| r == "OK" }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def stats(group)
|
|
149
|
+
@client.invoke("FEATURE.STATS", group) { |r| Commands::Support.pairs_to_h(r) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def flatten_kv(hash_or_pairs)
|
|
155
|
+
hash_or_pairs.is_a?(Hash) ? hash_or_pairs.flatten(1) : hash_or_pairs.flatten
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def result(row)
|
|
159
|
+
return nil if row.nil?
|
|
160
|
+
|
|
161
|
+
flat, updated_at_ms, freshness_ms = row
|
|
162
|
+
{ values: flat.each_slice(2).to_h, updated_at_ms: updated_at_ms, freshness_ms: freshness_ms }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
# Bloom filter (BF.*). Replies that are booleans on RESP3 and 0/1 on
|
|
7
|
+
# RESP2 are normalized to true/false.
|
|
8
|
+
class BF
|
|
9
|
+
INFO_LABELS = {
|
|
10
|
+
capacity: "Capacity", size: "Size", filters: "Number of filters",
|
|
11
|
+
items: "Number of items inserted", expansion: "Expansion rate"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(client)
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Creates a filter. +error_rate+ is the target false positive rate
|
|
19
|
+
# (0 < rate < 1), +capacity+ the items it is sized for before it grows.
|
|
20
|
+
def reserve(key, error_rate, capacity, expansion: nil, nonscaling: false)
|
|
21
|
+
args = ["BF.RESERVE", key, error_rate, capacity]
|
|
22
|
+
args.push("EXPANSION", expansion) if expansion
|
|
23
|
+
args.push("NONSCALING") if nonscaling
|
|
24
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Adds +item+; true when it was new, false when it may already exist.
|
|
28
|
+
def add(key, item)
|
|
29
|
+
@client.invoke("BF.ADD", key, item) { |r| Commands::Support.truthy(r) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def madd(key, *items)
|
|
33
|
+
@client.invoke("BF.MADD", key, *items.flatten) { |r| r.map { |v| Commands::Support.truthy(v) } }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Adds items, creating the filter with the options when missing.
|
|
37
|
+
def insert(key, items, capacity: nil, error: nil, expansion: nil, nocreate: false, nonscaling: false)
|
|
38
|
+
args = ["BF.INSERT", key]
|
|
39
|
+
args.push("CAPACITY", capacity) if capacity
|
|
40
|
+
args.push("ERROR", error) if error
|
|
41
|
+
args.push("EXPANSION", expansion) if expansion
|
|
42
|
+
args.push("NOCREATE") if nocreate
|
|
43
|
+
args.push("NONSCALING") if nonscaling
|
|
44
|
+
args.push("ITEMS", *Array(items))
|
|
45
|
+
@client.invoke(*args) { |r| r.map { |v| Commands::Support.truthy(v) } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# True when +item+ may be in the filter (false: definitely not).
|
|
49
|
+
def exists(key, item)
|
|
50
|
+
@client.invoke("BF.EXISTS", key, item) { |r| Commands::Support.truthy(r) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def mexists(key, *items)
|
|
54
|
+
@client.invoke("BF.MEXISTS", key, *items.flatten) { |r| r.map { |v| Commands::Support.truthy(v) } }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Number of items added.
|
|
58
|
+
def card(key)
|
|
59
|
+
@client.invoke("BF.CARD", key)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Hash of "Capacity", "Size", "Number of filters", "Number of items
|
|
63
|
+
# inserted", "Expansion rate"; with +field+ (:capacity, :size, :filters,
|
|
64
|
+
# :items, :expansion) a Hash of just that one.
|
|
65
|
+
def info(key, field = nil)
|
|
66
|
+
args = ["BF.INFO", key]
|
|
67
|
+
args.push(field.to_s.upcase) if field
|
|
68
|
+
@client.invoke(*args) do |r|
|
|
69
|
+
if r.is_a?(Hash) then r
|
|
70
|
+
elsif field then { INFO_LABELS.fetch(field.to_s.downcase.to_sym, field.to_s) => r.first } # RESP2: just the value
|
|
71
|
+
else r.each_slice(2).to_h
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# One chunk of the filter: [next_iterator, data]. Start with iterator 0;
|
|
77
|
+
# a returned iterator of 0 ends the dump.
|
|
78
|
+
def scandump(key, iterator)
|
|
79
|
+
@client.invoke("BF.SCANDUMP", key, iterator)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def loadchunk(key, iterator, data)
|
|
83
|
+
@client.invoke("BF.LOADCHUNK", key, iterator, data) { |r| r == "OK" }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Count-Min Sketch (CMS.*).
|
|
88
|
+
class CMS
|
|
89
|
+
def initialize(client)
|
|
90
|
+
@client = client
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def initbydim(key, width, depth)
|
|
94
|
+
@client.invoke("CMS.INITBYDIM", key, width, depth) { |r| r == "OK" }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def initbyprob(key, error, probability)
|
|
98
|
+
@client.invoke("CMS.INITBYPROB", key, error, probability) { |r| r == "OK" }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# +increments+ is a Hash or a flat [item, increment, ...] list. Returns
|
|
102
|
+
# the new estimated counts in order.
|
|
103
|
+
def incrby(key, increments)
|
|
104
|
+
flat = increments.is_a?(Hash) ? increments.flatten(1) : increments.flatten
|
|
105
|
+
@client.invoke("CMS.INCRBY", key, *flat)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def query(key, *items)
|
|
109
|
+
@client.invoke("CMS.QUERY", key, *items.flatten)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Hash with "width", "depth" and "count".
|
|
113
|
+
def info(key)
|
|
114
|
+
@client.invoke("CMS.INFO", key) { |r| r.is_a?(Hash) ? r : r.each_slice(2).to_h }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Replaces +destination+ with the (optionally weighted) sum of +sources+.
|
|
118
|
+
def merge(destination, sources, weights: nil)
|
|
119
|
+
args = ["CMS.MERGE", destination, sources.length, *sources]
|
|
120
|
+
args.push("WEIGHTS", *weights) if weights
|
|
121
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Top-K (TOPK.*).
|
|
126
|
+
class TopK
|
|
127
|
+
def initialize(client)
|
|
128
|
+
@client = client
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def reserve(key, topk, width: nil, depth: nil, decay: nil)
|
|
132
|
+
args = ["TOPK.RESERVE", key, topk]
|
|
133
|
+
given = [width, depth, decay].count { |v| !v.nil? }
|
|
134
|
+
raise Error::ArgumentError, "width, depth and decay go together: give all three or none" unless [0, 3].include?(given)
|
|
135
|
+
|
|
136
|
+
args.push(width, depth, decay) if given == 3
|
|
137
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Counts the items; each reply element is the item pushed out of the
|
|
141
|
+
# list, or nil.
|
|
142
|
+
def add(key, *items)
|
|
143
|
+
@client.invoke("TOPK.ADD", key, *items.flatten)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# +increments+ is a Hash or a flat [item, increment, ...] list.
|
|
147
|
+
def incrby(key, increments)
|
|
148
|
+
flat = increments.is_a?(Hash) ? increments.flatten(1) : increments.flatten
|
|
149
|
+
@client.invoke("TOPK.INCRBY", key, *flat)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def query(key, *items)
|
|
153
|
+
@client.invoke("TOPK.QUERY", key, *items.flatten) { |r| r.map { |v| Commands::Support.truthy(v) } }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def count(key, *items)
|
|
157
|
+
@client.invoke("TOPK.COUNT", key, *items.flatten)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The list, highest count first; with :withcount an Array of
|
|
161
|
+
# [item, count] pairs.
|
|
162
|
+
def list(key, withcount: false)
|
|
163
|
+
return @client.invoke("TOPK.LIST", key) unless withcount
|
|
164
|
+
|
|
165
|
+
@client.invoke("TOPK.LIST", key, "WITHCOUNT") do |r|
|
|
166
|
+
r.first.is_a?(Array) ? r : r.each_slice(2).to_a
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Hash with "k", "width", "depth" and "decay" (Float).
|
|
171
|
+
def info(key)
|
|
172
|
+
@client.invoke("TOPK.INFO", key) do |r|
|
|
173
|
+
h = r.is_a?(Hash) ? r : r.each_slice(2).to_h
|
|
174
|
+
h["decay"] = Wire.parse_float(h["decay"]) if h.key?("decay")
|
|
175
|
+
h
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
# RAG.* (needs the RAGPipeline license feature): a
|
|
7
|
+
# retrieval pipeline with hybrid vector + BM25 search.
|
|
8
|
+
class RAG
|
|
9
|
+
def initialize(client)
|
|
10
|
+
@client = client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def create(name, dimensions, strategy: nil, chunk_size: nil, chunk_overlap: nil)
|
|
14
|
+
args = ["RAG.CREATE", name, dimensions]
|
|
15
|
+
args.push("STRATEGY", strategy) if strategy
|
|
16
|
+
args.push("CHUNK_SIZE", chunk_size) if chunk_size
|
|
17
|
+
args.push("CHUNK_OVERLAP", chunk_overlap) if chunk_overlap
|
|
18
|
+
@client.invoke(*args) { |r| r == "OK" }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# +meta+: a Hash of metadata key/values to attach to +doc_id+.
|
|
22
|
+
# +vectors+: an Array of embedding vectors (each an Array of Floats),
|
|
23
|
+
# one per chunk the server will split +text+ into.
|
|
24
|
+
def ingest(pipeline, doc_id, text, meta: nil, vectors: nil)
|
|
25
|
+
args = ["RAG.INGEST", pipeline, doc_id, text]
|
|
26
|
+
args.push("META", *meta.flat_map { |k, v| [k, v] }) if meta && !meta.empty?
|
|
27
|
+
args.push("VECTORS", *vectors.map { |v| Wire.csv_vector(v) }) if vectors && !vectors.empty?
|
|
28
|
+
@client.invoke(*args)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def search(pipeline, query_text, k: nil, alpha: nil, vector: nil, filter: nil)
|
|
32
|
+
args = ["RAG.SEARCH", pipeline, query_text]
|
|
33
|
+
args.push("K", k) if k
|
|
34
|
+
args.push("ALPHA", Wire.finite(alpha)) if alpha
|
|
35
|
+
args.push("VECTOR", Wire.csv_vector(vector)) if vector
|
|
36
|
+
args.push("FILTER", *filter.flat_map { |fk, fv| [fk, fv] }) if filter && !filter.empty?
|
|
37
|
+
@client.invoke(*args) { |r| r.map { |row| search_row(row) } }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stats(pipeline)
|
|
41
|
+
@client.invoke("RAG.STATS", pipeline) { |r| Commands::Support.pairs_to_h(r) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def drop(pipeline)
|
|
45
|
+
@client.invoke("RAG.DROP", pipeline) { |r| r == "OK" }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def list
|
|
49
|
+
@client.invoke("RAG.LIST")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def search_row(row)
|
|
55
|
+
chunk_id, doc_id, text, score, vector_score, bm25_score, *rest = row
|
|
56
|
+
meta = {}
|
|
57
|
+
rest.each_slice(2) { |(k, v)| meta[k] = v }
|
|
58
|
+
{ chunk_id: chunk_id, doc_id: doc_id, text: text, score: Wire.parse_float(score),
|
|
59
|
+
vector_score: Wire.parse_float(vector_score), bm25_score: Wire.parse_float(bm25_score), meta: meta }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
# Client side command batching: every command queued inside the block
|
|
6
|
+
# given to HotKV::Client#pipelined is written to the server in one burst
|
|
7
|
+
# and all replies are read back in one pass, cutting round trips to one.
|
|
8
|
+
# Supports every typed command method the client does (Pipeline includes
|
|
9
|
+
# the same command modules), plus the raw #command escape hatch.
|
|
10
|
+
#
|
|
11
|
+
# results = client.pipelined do |p|
|
|
12
|
+
# p.set("a", "1")
|
|
13
|
+
# p.incr("a")
|
|
14
|
+
# end
|
|
15
|
+
# results # => ["OK", 2]
|
|
16
|
+
#
|
|
17
|
+
# A command that itself fails does not abort the batch: its slot in the
|
|
18
|
+
# returned array holds the HotKV::Error instance instead of a value.
|
|
19
|
+
class Pipeline
|
|
20
|
+
include Commands::All
|
|
21
|
+
|
|
22
|
+
def initialize(connection)
|
|
23
|
+
@connection = connection
|
|
24
|
+
@queue = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Queues a raw command, returning a Future resolved to its decoded
|
|
28
|
+
# reply (or the error) once #execute runs.
|
|
29
|
+
def command(*args, &transform)
|
|
30
|
+
invoke(*args, &transform)
|
|
31
|
+
end
|
|
32
|
+
alias_method :call, :command
|
|
33
|
+
|
|
34
|
+
def invoke(*args, &transform)
|
|
35
|
+
future = Future.new(&transform)
|
|
36
|
+
@queue << [args, future]
|
|
37
|
+
future
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def size
|
|
41
|
+
@queue.size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Writes every queued command and reads back every reply, in order.
|
|
45
|
+
# Returns a plain Array of resolved values (or per command Error
|
|
46
|
+
# instances); never raises for an individual command's failure.
|
|
47
|
+
def execute
|
|
48
|
+
return [] if @queue.empty?
|
|
49
|
+
|
|
50
|
+
@queue.each { |(args, _future)| @connection.write_command(args) }
|
|
51
|
+
@queue.each { |(_args, future)| future.resolve(@connection.read_reply(raise_on_error: false)) }
|
|
52
|
+
@queue.map { |(_args, future)| future.value }
|
|
53
|
+
ensure
|
|
54
|
+
@queue = []
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/hotkv/pool.rb
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Copyright (c) 2026 HotKV Ltd
|
|
2
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
|
|
3
|
+
|
|
4
|
+
require "monitor"
|
|
5
|
+
|
|
6
|
+
module HotKV
|
|
7
|
+
# A bounded pool of HotKV::Connection objects, safe for concurrent use by
|
|
8
|
+
# multiple threads. Connections idle for longer than +idle_timeout+
|
|
9
|
+
# seconds are health checked with PING before being handed out again, and
|
|
10
|
+
# discarded (not returned to the caller) if the check fails.
|
|
11
|
+
class Pool
|
|
12
|
+
def initialize(options, size:, idle_timeout:, pool_timeout:)
|
|
13
|
+
@options = options
|
|
14
|
+
@size = size
|
|
15
|
+
@idle_timeout = idle_timeout
|
|
16
|
+
@pool_timeout = pool_timeout
|
|
17
|
+
@available = []
|
|
18
|
+
@created = 0
|
|
19
|
+
@monitor = Monitor.new
|
|
20
|
+
@resource = @monitor.new_cond
|
|
21
|
+
@shutdown = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Checks out a connection, yields it, and always returns it to the pool
|
|
25
|
+
# (or discards it, if it turned out to be broken).
|
|
26
|
+
def with_connection
|
|
27
|
+
conn = checkout
|
|
28
|
+
healthy = true
|
|
29
|
+
begin
|
|
30
|
+
yield conn
|
|
31
|
+
rescue Error::ConnectionError, Error::TimeoutError
|
|
32
|
+
healthy = false
|
|
33
|
+
raise
|
|
34
|
+
ensure
|
|
35
|
+
if healthy
|
|
36
|
+
checkin(conn)
|
|
37
|
+
else
|
|
38
|
+
conn.close
|
|
39
|
+
@monitor.synchronize do
|
|
40
|
+
@created -= 1
|
|
41
|
+
@resource.signal
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def checkout(timeout: @pool_timeout)
|
|
48
|
+
deadline = timeout ? monotonic_now + timeout : nil
|
|
49
|
+
@monitor.synchronize do
|
|
50
|
+
loop do
|
|
51
|
+
raise Error::ConnectionError, "connection pool has been shut down" if @shutdown
|
|
52
|
+
|
|
53
|
+
while (conn = @available.pop)
|
|
54
|
+
if healthy?(conn)
|
|
55
|
+
return conn
|
|
56
|
+
else
|
|
57
|
+
conn.close
|
|
58
|
+
@created -= 1
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
if @created < @size
|
|
62
|
+
@created += 1
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
wait_time = deadline ? deadline - monotonic_now : nil
|
|
66
|
+
raise Error::ConnectionError, "timed out waiting for a pooled connection" if deadline && wait_time <= 0
|
|
67
|
+
|
|
68
|
+
@resource.wait(wait_time)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
begin
|
|
72
|
+
Connection.new(@options).connect
|
|
73
|
+
rescue StandardError
|
|
74
|
+
@monitor.synchronize do
|
|
75
|
+
@created -= 1
|
|
76
|
+
@resource.signal
|
|
77
|
+
end
|
|
78
|
+
raise
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def checkin(conn)
|
|
83
|
+
@monitor.synchronize do
|
|
84
|
+
if @shutdown || !conn.connected?
|
|
85
|
+
conn.close
|
|
86
|
+
@created -= 1
|
|
87
|
+
else
|
|
88
|
+
@available.push(conn)
|
|
89
|
+
end
|
|
90
|
+
@resource.signal
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def shutdown
|
|
95
|
+
@monitor.synchronize do
|
|
96
|
+
@shutdown = true
|
|
97
|
+
@available.each(&:close)
|
|
98
|
+
@available.clear
|
|
99
|
+
@created = 0
|
|
100
|
+
@resource.broadcast
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def size
|
|
105
|
+
@monitor.synchronize { @created }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def healthy?(conn)
|
|
111
|
+
return false unless conn.connected?
|
|
112
|
+
|
|
113
|
+
conn.ping if @idle_timeout && conn.idle_seconds > @idle_timeout
|
|
114
|
+
true
|
|
115
|
+
rescue Error::ConnectionError, Error::TimeoutError
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def monotonic_now
|
|
120
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/hotkv/pubsub.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
# Yielded by HotKV::Client#subscribe, #psubscribe and #ssubscribe. Register
|
|
6
|
+
# callbacks for the frames a subscription connection receives; the
|
|
7
|
+
# subscribe family methods also double as a way to change the
|
|
8
|
+
# subscription while the loop is running, when called with channel/pattern
|
|
9
|
+
# arguments and no block:
|
|
10
|
+
#
|
|
11
|
+
# client.subscribe("news") do |on|
|
|
12
|
+
# on.message { |channel, payload| puts "#{channel}: #{payload}" }
|
|
13
|
+
# on.subscribe { |channel, total| puts "listening on #{channel}" }
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# client.subscribe("news") do |on|
|
|
17
|
+
# on.message do |channel, payload|
|
|
18
|
+
# on.unsubscribe if payload == "stop" # ends the loop once confirmed
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
class PubSub
|
|
22
|
+
CALLBACK_KINDS = %i[message pmessage smessage subscribe unsubscribe psubscribe punsubscribe ssubscribe
|
|
23
|
+
sunsubscribe].freeze
|
|
24
|
+
COMMAND_KINDS = %i[subscribe unsubscribe psubscribe punsubscribe ssubscribe sunsubscribe].freeze
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@callbacks = Hash.new { |h, k| h[k] = [] }
|
|
28
|
+
@connection = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bind(connection)
|
|
32
|
+
@connection = connection
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dispatch(kind, *args)
|
|
37
|
+
@callbacks[kind].each { |cb| cb.call(*args) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
CALLBACK_KINDS.each do |kind|
|
|
41
|
+
define_method(kind) do |*args, &block|
|
|
42
|
+
if block
|
|
43
|
+
@callbacks[kind] << block
|
|
44
|
+
self
|
|
45
|
+
elsif COMMAND_KINDS.include?(kind)
|
|
46
|
+
send_wire(kind, args)
|
|
47
|
+
else
|
|
48
|
+
raise Error::ArgumentError, "#{kind} needs a block to register a callback"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def send_wire(kind, targets)
|
|
56
|
+
@connection.write_command(targets.empty? ? [kind.to_s.upcase] : [kind.to_s.upcase, *targets])
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|