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,258 @@
|
|
|
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
|
+
# Sorted set commands, including the BYSCORE/BYLEX/REV/LIMIT ZRANGE
|
|
7
|
+
# family and the RESP3 array-of-pairs WITHSCORES shape.
|
|
8
|
+
module SortedSets
|
|
9
|
+
# +score_members+ is a Hash {member => score}, or a flat/paired list
|
|
10
|
+
# of scores and members: zadd("k", 1, "a", 2, "b") or
|
|
11
|
+
# zadd("k", [1, "a"], [2, "b"]).
|
|
12
|
+
def zadd(key, *score_members, nx: false, xx: false, gt: false, lt: false, ch: false, incr: false)
|
|
13
|
+
args = ["ZADD", key]
|
|
14
|
+
args.push("NX") if nx
|
|
15
|
+
args.push("XX") if xx
|
|
16
|
+
args.push("GT") if gt
|
|
17
|
+
args.push("LT") if lt
|
|
18
|
+
args.push("CH") if ch
|
|
19
|
+
args.push("INCR") if incr
|
|
20
|
+
args.concat(flatten_score_members(score_members))
|
|
21
|
+
invoke(*args) { |r| incr ? (r.nil? ? nil : Wire.parse_float(r)) : r }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def zrem(key, *members)
|
|
25
|
+
invoke("ZREM", key, *members.flatten)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def zscore(key, member)
|
|
29
|
+
invoke("ZSCORE", key, member) { |r| r.nil? ? nil : Wire.parse_float(r) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def zmscore(key, *members)
|
|
33
|
+
invoke("ZMSCORE", key, *members.flatten) { |r| r.map { |v| v.nil? ? nil : Wire.parse_float(v) } }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def zcard(key)
|
|
37
|
+
invoke("ZCARD", key)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def zcount(key, min, max)
|
|
41
|
+
invoke("ZCOUNT", key, min, max)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def zincrby(key, increment, member)
|
|
45
|
+
invoke("ZINCRBY", key, increment, member) { |r| Wire.parse_float(r) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Modern ZRANGE, covering plain index range, BYSCORE, BYLEX, REV and
|
|
49
|
+
# LIMIT (an [offset, count] pair, only valid with BYSCORE/BYLEX).
|
|
50
|
+
# With :with_scores, returns an Array of [member, score] pairs
|
|
51
|
+
# (paired up uniformly regardless of the RESP2/RESP3 wire shape).
|
|
52
|
+
def zrange(key, start, stop, byscore: false, bylex: false, rev: false, limit: nil, with_scores: false)
|
|
53
|
+
args = ["ZRANGE", key, start, stop]
|
|
54
|
+
args.push("BYSCORE") if byscore
|
|
55
|
+
args.push("BYLEX") if bylex
|
|
56
|
+
args.push("REV") if rev
|
|
57
|
+
args.push("LIMIT", limit[0], limit[1]) if limit
|
|
58
|
+
args.push("WITHSCORES") if with_scores
|
|
59
|
+
invoke(*args) { |r| with_scores ? Support.pairify(r) : r }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def zrangestore(destination, source, start, stop, byscore: false, bylex: false, rev: false, limit: nil)
|
|
63
|
+
args = ["ZRANGESTORE", destination, source, start, stop]
|
|
64
|
+
args.push("BYSCORE") if byscore
|
|
65
|
+
args.push("BYLEX") if bylex
|
|
66
|
+
args.push("REV") if rev
|
|
67
|
+
args.push("LIMIT", limit[0], limit[1]) if limit
|
|
68
|
+
invoke(*args)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def zrevrange(key, start, stop, with_scores: false)
|
|
72
|
+
args = ["ZREVRANGE", key, start, stop]
|
|
73
|
+
args.push("WITHSCORES") if with_scores
|
|
74
|
+
invoke(*args) { |r| with_scores ? Support.pairify(r) : r }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def zrangebyscore(key, min, max, with_scores: false, limit: nil)
|
|
78
|
+
args = ["ZRANGEBYSCORE", key, min, max]
|
|
79
|
+
args.push("LIMIT", limit[0], limit[1]) if limit
|
|
80
|
+
args.push("WITHSCORES") if with_scores
|
|
81
|
+
invoke(*args) { |r| with_scores ? Support.pairify(r) : r }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def zrevrangebyscore(key, max, min, with_scores: false, limit: nil)
|
|
85
|
+
args = ["ZREVRANGEBYSCORE", key, max, min]
|
|
86
|
+
args.push("LIMIT", limit[0], limit[1]) if limit
|
|
87
|
+
args.push("WITHSCORES") if with_scores
|
|
88
|
+
invoke(*args) { |r| with_scores ? Support.pairify(r) : r }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def zrangebylex(key, min, max, limit: nil)
|
|
92
|
+
args = ["ZRANGEBYLEX", key, min, max]
|
|
93
|
+
args.push("LIMIT", limit[0], limit[1]) if limit
|
|
94
|
+
invoke(*args)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def zrevrangebylex(key, max, min, limit: nil)
|
|
98
|
+
args = ["ZREVRANGEBYLEX", key, max, min]
|
|
99
|
+
args.push("LIMIT", limit[0], limit[1]) if limit
|
|
100
|
+
invoke(*args)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def zlexcount(key, min, max)
|
|
104
|
+
invoke("ZLEXCOUNT", key, min, max)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def zrank(key, member, with_score: false)
|
|
108
|
+
args = ["ZRANK", key, member]
|
|
109
|
+
args.push("WITHSCORE") if with_score
|
|
110
|
+
invoke(*args) { |r| with_score ? (r.nil? ? nil : [r[0], Wire.parse_float(r[1])]) : r }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def zrevrank(key, member, with_score: false)
|
|
114
|
+
args = ["ZREVRANK", key, member]
|
|
115
|
+
args.push("WITHSCORE") if with_score
|
|
116
|
+
invoke(*args) { |r| with_score ? (r.nil? ? nil : [r[0], Wire.parse_float(r[1])]) : r }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# No :count -> a single [member, score] Array, or nil if empty.
|
|
120
|
+
# With :count -> an Array of [member, score] pairs (possibly empty).
|
|
121
|
+
def zpopmin(key, count = nil)
|
|
122
|
+
pop("ZPOPMIN", key, count)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def zpopmax(key, count = nil)
|
|
126
|
+
pop("ZPOPMAX", key, count)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def zrandmember(key, count = nil, withscores: false)
|
|
130
|
+
return invoke("ZRANDMEMBER", key) if count.nil?
|
|
131
|
+
|
|
132
|
+
args = ["ZRANDMEMBER", key, count]
|
|
133
|
+
args.push("WITHSCORES") if withscores
|
|
134
|
+
invoke(*args) { |r| withscores ? Support.pairify(r) : r }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def zremrangebyrank(key, start, stop)
|
|
138
|
+
invoke("ZREMRANGEBYRANK", key, start, stop)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def zremrangebyscore(key, min, max)
|
|
142
|
+
invoke("ZREMRANGEBYSCORE", key, min, max)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def zremrangebylex(key, min, max)
|
|
146
|
+
invoke("ZREMRANGEBYLEX", key, min, max)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def zunionstore(destination, keys, weights: nil, aggregate: nil)
|
|
150
|
+
invoke(*zstore_args("ZUNIONSTORE", destination, keys, weights, aggregate))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def zinterstore(destination, keys, weights: nil, aggregate: nil)
|
|
154
|
+
invoke(*zstore_args("ZINTERSTORE", destination, keys, weights, aggregate))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def zdiffstore(destination, keys)
|
|
158
|
+
invoke("ZDIFFSTORE", destination, keys.length, *keys)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def zunion(keys, weights: nil, aggregate: nil, with_scores: false)
|
|
162
|
+
invoke(*zset_op_args("ZUNION", keys, weights, aggregate, with_scores)) do |r|
|
|
163
|
+
with_scores ? Support.pairify(r) : r
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def zinter(keys, weights: nil, aggregate: nil, with_scores: false)
|
|
168
|
+
invoke(*zset_op_args("ZINTER", keys, weights, aggregate, with_scores)) do |r|
|
|
169
|
+
with_scores ? Support.pairify(r) : r
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def zdiff(keys, with_scores: false)
|
|
174
|
+
args = ["ZDIFF", keys.length, *keys]
|
|
175
|
+
args.push("WITHSCORES") if with_scores
|
|
176
|
+
invoke(*args) { |r| with_scores ? Support.pairify(r) : r }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def zintercard(keys, limit: nil)
|
|
180
|
+
args = ["ZINTERCARD", keys.length, *keys]
|
|
181
|
+
args.push("LIMIT", limit) if limit
|
|
182
|
+
invoke(*args)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Pops from the first non-empty sorted set of +keys+ (ZMPOP). +where+ is
|
|
186
|
+
# "MIN" or "MAX". Returns [key, [[member, score], ...]] or nil.
|
|
187
|
+
def zmpop(keys, where, count: nil)
|
|
188
|
+
args = ["ZMPOP", keys.length, *keys, where]
|
|
189
|
+
args.push("COUNT", count) if count
|
|
190
|
+
invoke(*args) { |r| zmpop_reply(r) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Blocking ZMPOP: waits up to +timeout+ seconds. Same reply as #zmpop.
|
|
194
|
+
def bzmpop(keys, where, timeout: 0, count: nil)
|
|
195
|
+
args = ["BZMPOP", timeout, keys.length, *keys, where]
|
|
196
|
+
args.push("COUNT", count) if count
|
|
197
|
+
invoke(*args) { |r| zmpop_reply(r) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Blocking ZPOPMIN across +keys+. Returns [key, member, score] or nil.
|
|
201
|
+
def bzpopmin(*keys, timeout: 0)
|
|
202
|
+
invoke("BZPOPMIN", *keys.flatten, timeout) { |r| bzpop_reply(r) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Blocking ZPOPMAX across +keys+. Returns [key, member, score] or nil.
|
|
206
|
+
def bzpopmax(*keys, timeout: 0)
|
|
207
|
+
invoke("BZPOPMAX", *keys.flatten, timeout) { |r| bzpop_reply(r) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def zmpop_reply(reply)
|
|
213
|
+
return nil if reply.nil?
|
|
214
|
+
|
|
215
|
+
key, pairs = reply
|
|
216
|
+
[key, pairs.map { |(member, score)| [member, Wire.parse_float(score)] }]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def bzpop_reply(reply)
|
|
220
|
+
return nil if reply.nil?
|
|
221
|
+
|
|
222
|
+
key, member, score = reply
|
|
223
|
+
[key, member, Wire.parse_float(score)]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def pop(cmd, key, count)
|
|
227
|
+
if count
|
|
228
|
+
invoke(cmd, key, count) { |r| Support.pairify(r) }
|
|
229
|
+
else
|
|
230
|
+
invoke(cmd, key) { |r| (r.nil? || r.empty?) ? nil : [r[0], Wire.parse_float(r[1])] }
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def flatten_score_members(args)
|
|
235
|
+
if args.length == 1 && args.first.is_a?(Hash)
|
|
236
|
+
args.first.flat_map { |member, score| [score, member] }
|
|
237
|
+
else
|
|
238
|
+
args.flatten
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def zstore_args(cmd, destination, keys, weights, aggregate)
|
|
243
|
+
args = [cmd, destination, keys.length, *keys]
|
|
244
|
+
args.push("WEIGHTS", *weights) if weights
|
|
245
|
+
args.push("AGGREGATE", aggregate) if aggregate
|
|
246
|
+
args
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def zset_op_args(cmd, keys, weights, aggregate, with_scores)
|
|
250
|
+
args = [cmd, keys.length, *keys]
|
|
251
|
+
args.push("WEIGHTS", *weights) if weights
|
|
252
|
+
args.push("AGGREGATE", aggregate) if aggregate
|
|
253
|
+
args.push("WITHSCORES") if with_scores
|
|
254
|
+
args
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
# Stream commands (XADD, consumer groups, claiming, trimming, XINFO).
|
|
7
|
+
module Streams
|
|
8
|
+
# +fields+ is a Hash or a flat [field, value, ...] list.
|
|
9
|
+
#
|
|
10
|
+
# Idempotent append: :idmp is a [producer, iid] pair, and the
|
|
11
|
+
# same pair within the stream's IDMP-DURATION adds nothing and returns
|
|
12
|
+
# the original entry id; :idmpauto is a producer name whose id is derived
|
|
13
|
+
# from the fields. Both need id "*".
|
|
14
|
+
def xadd(key, fields, id: "*", maxlen: nil, minid: nil, approx: true, nomkstream: false, limit: nil,
|
|
15
|
+
idmp: nil, idmpauto: nil)
|
|
16
|
+
args = ["XADD", key]
|
|
17
|
+
args.push("NOMKSTREAM") if nomkstream
|
|
18
|
+
args.push("IDMP", idmp[0], idmp[1]) if idmp
|
|
19
|
+
args.push("IDMPAUTO", idmpauto) if idmpauto
|
|
20
|
+
args.concat(trim_clause(maxlen, minid, approx, limit))
|
|
21
|
+
args.push(id)
|
|
22
|
+
args.concat(fields.is_a?(Hash) ? fields.flatten(1) : fields.flatten)
|
|
23
|
+
invoke(*args)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# -> Array of [id, {field => value}] entries, oldest first.
|
|
27
|
+
def xrange(key, start = "-", stop = "+", count: nil)
|
|
28
|
+
args = ["XRANGE", key, start, stop]
|
|
29
|
+
args.push("COUNT", count) if count
|
|
30
|
+
invoke(*args) { |r| entries_to_pairs(r) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def xrevrange(key, stop = "+", start = "-", count: nil)
|
|
34
|
+
args = ["XREVRANGE", key, stop, start]
|
|
35
|
+
args.push("COUNT", count) if count
|
|
36
|
+
invoke(*args) { |r| entries_to_pairs(r) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def xlen(key)
|
|
40
|
+
invoke("XLEN", key)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def xdel(key, *ids)
|
|
44
|
+
invoke("XDEL", key, *ids.flatten)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def xtrim(key, maxlen: nil, minid: nil, approx: true, limit: nil)
|
|
48
|
+
invoke("XTRIM", key, *trim_clause(maxlen, minid, approx, limit))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# +keys+/+ids+ are parallel Arrays. -> Hash {stream_key => entries},
|
|
52
|
+
# entries shaped like #xrange's result, or nil on timeout/no data.
|
|
53
|
+
def xread(keys:, ids:, count: nil, block: nil)
|
|
54
|
+
args = ["XREAD"]
|
|
55
|
+
args.push("COUNT", count) if count
|
|
56
|
+
args.push("BLOCK", block) if block
|
|
57
|
+
args.push("STREAMS", *keys, *ids)
|
|
58
|
+
invoke(*args) { |r| streams_to_h(r) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def xreadgroup(group, consumer, keys:, ids:, count: nil, block: nil, noack: false)
|
|
62
|
+
args = ["XREADGROUP", "GROUP", group, consumer]
|
|
63
|
+
args.push("COUNT", count) if count
|
|
64
|
+
args.push("BLOCK", block) if block
|
|
65
|
+
args.push("NOACK") if noack
|
|
66
|
+
args.push("STREAMS", *keys, *ids)
|
|
67
|
+
invoke(*args) { |r| streams_to_h(r) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def xack(key, group, *ids)
|
|
71
|
+
invoke("XACK", key, group, *ids.flatten)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def xgroup_create(key, group, id = "$", mkstream: false)
|
|
75
|
+
args = ["XGROUP", "CREATE", key, group, id]
|
|
76
|
+
args.push("MKSTREAM") if mkstream
|
|
77
|
+
invoke(*args) { |r| r == "OK" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def xgroup_destroy(key, group)
|
|
81
|
+
invoke("XGROUP", "DESTROY", key, group) { |r| r == 1 }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def xgroup_createconsumer(key, group, consumer)
|
|
85
|
+
invoke("XGROUP", "CREATECONSUMER", key, group, consumer) { |r| r == 1 }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def xgroup_delconsumer(key, group, consumer)
|
|
89
|
+
invoke("XGROUP", "DELCONSUMER", key, group, consumer)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def xgroup_setid(key, group, id = "$")
|
|
93
|
+
invoke("XGROUP", "SETID", key, group, id) { |r| r == "OK" }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# No :start/:stop/:count -> summary Hash {count:, min_id:, max_id:,
|
|
97
|
+
# consumers: [[name, pending_count], ...]}. With them -> an Array of
|
|
98
|
+
# {id:, consumer:, idle_ms:, deliveries:} Hashes (the extended form).
|
|
99
|
+
def xpending(key, group, start: nil, stop: nil, count: nil, consumer: nil, idle: nil)
|
|
100
|
+
if start.nil? && stop.nil? && count.nil?
|
|
101
|
+
invoke("XPENDING", key, group) { |r| pending_summary(r) }
|
|
102
|
+
else
|
|
103
|
+
args = ["XPENDING", key, group]
|
|
104
|
+
args.push("IDLE", idle) if idle
|
|
105
|
+
args.push(start || "-", stop || "+", count || 10)
|
|
106
|
+
args.push(consumer) if consumer
|
|
107
|
+
invoke(*args) { |r| pending_detail(r) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def xclaim(key, group, consumer, min_idle_time, *ids, idle: nil, time: nil, retrycount: nil, force: false,
|
|
112
|
+
justid: false)
|
|
113
|
+
args = ["XCLAIM", key, group, consumer, min_idle_time, *ids.flatten]
|
|
114
|
+
args.push("IDLE", idle) if idle
|
|
115
|
+
args.push("TIME", time) if time
|
|
116
|
+
args.push("RETRYCOUNT", retrycount) if retrycount
|
|
117
|
+
args.push("FORCE") if force
|
|
118
|
+
args.push("JUSTID") if justid
|
|
119
|
+
invoke(*args) { |r| justid ? r : entries_to_pairs(r) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# -> {cursor:, entries:, deleted:}. +entries+ is shaped like #xrange's
|
|
123
|
+
# result unless :justid, in which case it is a plain Array of ids.
|
|
124
|
+
def xautoclaim(key, group, consumer, min_idle_time, start = "0", count: nil, justid: false)
|
|
125
|
+
args = ["XAUTOCLAIM", key, group, consumer, min_idle_time, start]
|
|
126
|
+
args.push("COUNT", count) if count
|
|
127
|
+
args.push("JUSTID") if justid
|
|
128
|
+
invoke(*args) do |r|
|
|
129
|
+
cursor, claimed, deleted = r
|
|
130
|
+
{ cursor: cursor, entries: justid ? claimed : entries_to_pairs(claimed), deleted: deleted || [] }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def xinfo_stream(key)
|
|
135
|
+
invoke("XINFO", "STREAM", key) { |r| to_h_any(r) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def xinfo_groups(key)
|
|
139
|
+
invoke("XINFO", "GROUPS", key) { |r| r.map { |g| to_h_any(g) } }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def xinfo_consumers(key, group)
|
|
143
|
+
invoke("XINFO", "CONSUMERS", key, group) { |r| r.map { |c| to_h_any(c) } }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Sets the last delivered id of +key+ (and optionally the entries added /
|
|
147
|
+
# max deleted id counters).
|
|
148
|
+
def xsetid(key, last_id, entriesadded: nil, maxdeletedid: nil)
|
|
149
|
+
args = ["XSETID", key, last_id]
|
|
150
|
+
args.push("ENTRIESADDED", entriesadded) if entriesadded
|
|
151
|
+
args.push("MAXDELETEDID", maxdeletedid) if maxdeletedid
|
|
152
|
+
invoke(*args) { |r| r == "OK" }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Sets how long (:idmp_duration seconds) and how many (:idmp_maxsize)
|
|
156
|
+
# idempotent ids a stream keeps per producer (XCFGSET).
|
|
157
|
+
def xcfgset(key, idmp_duration: nil, idmp_maxsize: nil)
|
|
158
|
+
args = ["XCFGSET", key]
|
|
159
|
+
args.push("IDMP-DURATION", idmp_duration) if idmp_duration
|
|
160
|
+
args.push("IDMP-MAXSIZE", idmp_maxsize) if idmp_maxsize
|
|
161
|
+
invoke(*args) { |r| r == "OK" }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def trim_clause(maxlen, minid, approx, limit)
|
|
167
|
+
args = []
|
|
168
|
+
if maxlen
|
|
169
|
+
args.push("MAXLEN", approx ? "~" : "=", maxlen)
|
|
170
|
+
elsif minid
|
|
171
|
+
args.push("MINID", approx ? "~" : "=", minid)
|
|
172
|
+
end
|
|
173
|
+
args.push("LIMIT", limit) if limit && !args.empty?
|
|
174
|
+
args
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def entries_to_pairs(entries)
|
|
178
|
+
return [] if entries.nil?
|
|
179
|
+
|
|
180
|
+
entries.map { |(id, flat)| [id, flat.nil? ? nil : to_h_any(flat)] }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def streams_to_h(reply)
|
|
184
|
+
return nil if reply.nil?
|
|
185
|
+
|
|
186
|
+
pairs = reply.is_a?(Hash) ? reply.to_a : reply
|
|
187
|
+
pairs.to_h { |(name, entries)| [name, entries_to_pairs(entries)] }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def to_h_any(r)
|
|
191
|
+
r.is_a?(Hash) ? r : r.each_slice(2).to_h
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def pending_summary(r)
|
|
195
|
+
return { count: 0, min_id: nil, max_id: nil, consumers: [] } if r.nil? || r[0] == 0
|
|
196
|
+
|
|
197
|
+
count, min_id, max_id, consumers = r
|
|
198
|
+
{ count: count, min_id: min_id, max_id: max_id,
|
|
199
|
+
consumers: (consumers || []).map { |(name, pending)| [name, pending.to_i] } }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def pending_detail(r)
|
|
203
|
+
(r || []).map { |(id, consumer, idle_ms, deliveries)| { id: id, consumer: consumer, idle_ms: idle_ms, deliveries: deliveries } }
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
# String commands: GET/SET and friends.
|
|
7
|
+
module Strings
|
|
8
|
+
# Fetches the value of +key+, or nil when it does not exist.
|
|
9
|
+
def get(key)
|
|
10
|
+
invoke("GET", key)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Sets +key+ to +value+. Options mirror the SET command: :ex/:px
|
|
14
|
+
# (relative TTL, seconds/milliseconds), :exat/:pxat (absolute TTL,
|
|
15
|
+
# unix seconds/milliseconds), :keepttl (keep the current TTL),
|
|
16
|
+
# :nx/:xx (only set if absent/present), :get (return the previous
|
|
17
|
+
# value instead of "OK"). :ifeq/:ifne compare the current string value
|
|
18
|
+
# and :ifdeq/:ifdne its digest (see #digest); the write is skipped when
|
|
19
|
+
# the condition fails. Returns "OK", the previous value (with :get), or
|
|
20
|
+
# nil when :nx/:xx/:if* prevented the write.
|
|
21
|
+
def set(key, value, ex: nil, px: nil, exat: nil, pxat: nil, keepttl: false, nx: false, xx: false, get: false,
|
|
22
|
+
ifeq: nil, ifne: nil, ifdeq: nil, ifdne: nil)
|
|
23
|
+
args = ["SET", key, value]
|
|
24
|
+
args.push("EX", ex) if ex
|
|
25
|
+
args.push("PX", px) if px
|
|
26
|
+
args.push("EXAT", exat) if exat
|
|
27
|
+
args.push("PXAT", pxat) if pxat
|
|
28
|
+
args.push("KEEPTTL") if keepttl
|
|
29
|
+
args.push("NX") if nx
|
|
30
|
+
args.push("XX") if xx
|
|
31
|
+
args.push("IFEQ", ifeq) unless ifeq.nil?
|
|
32
|
+
args.push("IFNE", ifne) unless ifne.nil?
|
|
33
|
+
args.push("IFDEQ", ifdeq) unless ifdeq.nil?
|
|
34
|
+
args.push("IFDNE", ifdne) unless ifdne.nil?
|
|
35
|
+
args.push("GET") if get
|
|
36
|
+
invoke(*args)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def setnx(key, value)
|
|
40
|
+
invoke("SETNX", key, value) { |r| r == 1 }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def setex(key, seconds, value)
|
|
44
|
+
invoke("SETEX", key, seconds, value)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def psetex(key, milliseconds, value)
|
|
48
|
+
invoke("PSETEX", key, milliseconds, value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def getset(key, value)
|
|
52
|
+
invoke("GETSET", key, value)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def getdel(key)
|
|
56
|
+
invoke("GETDEL", key)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def getex(key, ex: nil, px: nil, exat: nil, pxat: nil, persist: false)
|
|
60
|
+
args = ["GETEX", key]
|
|
61
|
+
args.push("EX", ex) if ex
|
|
62
|
+
args.push("PX", px) if px
|
|
63
|
+
args.push("EXAT", exat) if exat
|
|
64
|
+
args.push("PXAT", pxat) if pxat
|
|
65
|
+
args.push("PERSIST") if persist
|
|
66
|
+
invoke(*args)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def append(key, value)
|
|
70
|
+
invoke("APPEND", key, value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def strlen(key)
|
|
74
|
+
invoke("STRLEN", key)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def incr(key)
|
|
78
|
+
invoke("INCR", key)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def decr(key)
|
|
82
|
+
invoke("DECR", key)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def incrby(key, amount)
|
|
86
|
+
invoke("INCRBY", key, amount)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def decrby(key, amount)
|
|
90
|
+
invoke("DECRBY", key, amount)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def incrbyfloat(key, amount)
|
|
94
|
+
invoke("INCRBYFLOAT", key, Wire.finite(amount)) { |r| Wire.parse_float(r) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def mget(*keys)
|
|
98
|
+
invoke("MGET", *keys.flatten)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Accepts either a Hash or an already flat [key, value, key, value...]
|
|
102
|
+
# list.
|
|
103
|
+
def mset(hash_or_pairs)
|
|
104
|
+
invoke("MSET", *flatten_pairs(hash_or_pairs)) { |r| r == "OK" }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def msetnx(hash_or_pairs)
|
|
108
|
+
invoke("MSETNX", *flatten_pairs(hash_or_pairs)) { |r| r == 1 }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def getrange(key, start, stop)
|
|
112
|
+
invoke("GETRANGE", key, start, stop)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def setrange(key, offset, value)
|
|
116
|
+
invoke("SETRANGE", key, offset, value)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Deprecated alias of #getrange.
|
|
120
|
+
def substr(key, start, stop)
|
|
121
|
+
invoke("SUBSTR", key, start, stop)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Longest common subsequence of two string keys. Without options returns
|
|
125
|
+
# the subsequence; :len returns its length; :idx returns a Hash
|
|
126
|
+
# {"matches" => [[[a_start, a_end], [b_start, b_end], (len)], ...],
|
|
127
|
+
# "len" => n} (the match positions, with :withmatchlen the lengths;
|
|
128
|
+
# :minmatchlen drops shorter matches).
|
|
129
|
+
def lcs(key1, key2, len: false, idx: false, minmatchlen: nil, withmatchlen: false)
|
|
130
|
+
args = ["LCS", key1, key2]
|
|
131
|
+
args.push("LEN") if len
|
|
132
|
+
args.push("IDX") if idx
|
|
133
|
+
args.push("MINMATCHLEN", minmatchlen) if minmatchlen
|
|
134
|
+
args.push("WITHMATCHLEN") if withmatchlen
|
|
135
|
+
invoke(*args) { |r| idx && r.is_a?(Array) ? r.each_slice(2).to_h : r }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Sets several keys in one step with one TTL (MSETEX). +pairs+
|
|
139
|
+
# is a Hash or a flat [key, value, ...] list. Options: :nx/:xx (all or
|
|
140
|
+
# none), and one of :ex/:px/:exat/:pxat/:keepttl. Returns true when the
|
|
141
|
+
# keys were set, false when :nx/:xx prevented it.
|
|
142
|
+
def msetex(pairs, nx: false, xx: false, ex: nil, px: nil, exat: nil, pxat: nil, keepttl: false)
|
|
143
|
+
flat = flatten_pairs(pairs)
|
|
144
|
+
args = ["MSETEX", flat.length / 2, *flat]
|
|
145
|
+
args.push("NX") if nx
|
|
146
|
+
args.push("XX") if xx
|
|
147
|
+
args.push("EX", ex) if ex
|
|
148
|
+
args.push("PX", px) if px
|
|
149
|
+
args.push("EXAT", exat) if exat
|
|
150
|
+
args.push("PXAT", pxat) if pxat
|
|
151
|
+
args.push("KEEPTTL") if keepttl
|
|
152
|
+
invoke(*args) { |r| r == 1 }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Deletes +key+, or only when its string value meets a condition
|
|
156
|
+
# (:ifeq/:ifne value, :ifdeq/:ifdne digest). Returns true when deleted.
|
|
157
|
+
def delex(key, ifeq: nil, ifne: nil, ifdeq: nil, ifdne: nil)
|
|
158
|
+
args = ["DELEX", key]
|
|
159
|
+
args.push("IFEQ", ifeq) unless ifeq.nil?
|
|
160
|
+
args.push("IFNE", ifne) unless ifne.nil?
|
|
161
|
+
args.push("IFDEQ", ifdeq) unless ifdeq.nil?
|
|
162
|
+
args.push("IFDNE", ifdne) unless ifdne.nil?
|
|
163
|
+
invoke(*args) { |r| r == 1 }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The XXH3-64 digest of the string at +key+ as 16 hex digits, or nil.
|
|
167
|
+
def digest(key)
|
|
168
|
+
invoke("DIGEST", key)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def flatten_pairs(hash_or_pairs)
|
|
174
|
+
hash_or_pairs.is_a?(Hash) ? hash_or_pairs.flatten(1) : hash_or_pairs.flatten(1)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|