hotkv 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/NOTICE +6 -0
  4. data/README.md +459 -0
  5. data/SECURITY.md +30 -0
  6. data/lib/hotkv/ai/call_result.rb +25 -0
  7. data/lib/hotkv/ai/prompt_cache.rb +135 -0
  8. data/lib/hotkv/ai/semantic_cache.rb +66 -0
  9. data/lib/hotkv/client.rb +201 -0
  10. data/lib/hotkv/cluster/crc16.rb +43 -0
  11. data/lib/hotkv/cluster.rb +184 -0
  12. data/lib/hotkv/commands/admin.rb +36 -0
  13. data/lib/hotkv/commands/all.rb +49 -0
  14. data/lib/hotkv/commands/bitmaps.rb +52 -0
  15. data/lib/hotkv/commands/geo.rb +146 -0
  16. data/lib/hotkv/commands/hashes.rb +169 -0
  17. data/lib/hotkv/commands/hyperloglog.rb +21 -0
  18. data/lib/hotkv/commands/keys.rb +170 -0
  19. data/lib/hotkv/commands/lists.rb +113 -0
  20. data/lib/hotkv/commands/probabilistic.rb +23 -0
  21. data/lib/hotkv/commands/publishing.rb +46 -0
  22. data/lib/hotkv/commands/scan.rb +82 -0
  23. data/lib/hotkv/commands/scripting.rb +128 -0
  24. data/lib/hotkv/commands/server.rb +175 -0
  25. data/lib/hotkv/commands/sets.rb +75 -0
  26. data/lib/hotkv/commands/sorted_sets.rb +258 -0
  27. data/lib/hotkv/commands/streams.rb +207 -0
  28. data/lib/hotkv/commands/strings.rb +178 -0
  29. data/lib/hotkv/commands/support.rb +69 -0
  30. data/lib/hotkv/connection.rb +221 -0
  31. data/lib/hotkv/errors.rb +145 -0
  32. data/lib/hotkv/extensions.rb +118 -0
  33. data/lib/hotkv/future.rb +55 -0
  34. data/lib/hotkv/llm_price.rb +53 -0
  35. data/lib/hotkv/namespaces/admin.rb +261 -0
  36. data/lib/hotkv/namespaces/amem.rb +172 -0
  37. data/lib/hotkv/namespaces/caching.rb +397 -0
  38. data/lib/hotkv/namespaces/core.rb +190 -0
  39. data/lib/hotkv/namespaces/ops.rb +166 -0
  40. data/lib/hotkv/namespaces/probabilistic.rb +180 -0
  41. data/lib/hotkv/namespaces/rag.rb +63 -0
  42. data/lib/hotkv/pipeline.rb +57 -0
  43. data/lib/hotkv/pool.rb +123 -0
  44. data/lib/hotkv/pubsub.rb +60 -0
  45. data/lib/hotkv/resp.rb +176 -0
  46. data/lib/hotkv/transaction.rb +115 -0
  47. data/lib/hotkv/url.rb +100 -0
  48. data/lib/hotkv/usage.rb +42 -0
  49. data/lib/hotkv/version.rb +7 -0
  50. data/lib/hotkv/wire.rb +145 -0
  51. data/lib/hotkv.rb +28 -0
  52. metadata +137 -0
@@ -0,0 +1,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
@@ -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