mqkv 0.1.0 → 0.3.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 +4 -4
- data/lib/mqkv/store.rb +261 -26
- data/lib/mqkv/version.rb +1 -1
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d737d046ae643ce3d316df07cc167f11f9c141b2d6f58f504557edcb3c4384c8
|
|
4
|
+
data.tar.gz: 574cf60004e1150b02959944c6fb38797ae14ecdca48351d7813a87441c465d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6880875f67dc0e324073475cba672c60c8e747ff6da11289b39ff41d5a2c20b4c772a39fa114ffef00ad9d0c5aadcbad438d385ae3d6f1fce37c408086122e9c
|
|
7
|
+
data.tar.gz: 168e701a4272b63237cc2a59755fb89fe4e28f94828a171632655a355dd9cc7581d931b7431bfefd79221bd403c08de54374534b3c4abc457a0d29e0c3f6a434
|
data/lib/mqkv/store.rb
CHANGED
|
@@ -1,46 +1,111 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "amqp-client"
|
|
4
|
+
require "logger"
|
|
4
5
|
require "set"
|
|
5
6
|
|
|
6
7
|
module MQKV
|
|
7
8
|
class Store
|
|
8
9
|
WatchHandle = Data.define(:channel, :consumer_tag)
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
CacheEntry = Data.define(:value, :expires_at) do
|
|
12
|
+
def current_value
|
|
13
|
+
return nil if value.nil?
|
|
14
|
+
return nil if expires_at && Process.clock_gettime(Process::CLOCK_REALTIME) >= expires_at
|
|
15
|
+
|
|
16
|
+
value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
TOMBSTONE_HEADER = "__mqkv_deleted__"
|
|
21
|
+
EXPIRES_HEADER = "__mqkv_expires_at__"
|
|
22
|
+
|
|
23
|
+
# Stream consumers must ack to advance the broker's flow-control
|
|
24
|
+
# window: with manual acks and a prefetch of N, delivery stalls
|
|
25
|
+
# after N outstanding messages. Acking every half-window keeps
|
|
26
|
+
# deliveries flowing while still batching (multiple: true).
|
|
27
|
+
CONSUME_PREFETCH = 256
|
|
28
|
+
CONSUME_ACK_BATCH = CONSUME_PREFETCH / 2
|
|
29
|
+
|
|
30
|
+
def initialize(url, prefix: "mqkv", read_timeout: 0.5, connect_timeout: nil, confirm: true, cache_watchers: true, logger: nil)
|
|
11
31
|
@url = url
|
|
12
32
|
@prefix = prefix
|
|
13
33
|
@read_timeout = read_timeout
|
|
34
|
+
@connect_timeout = connect_timeout
|
|
35
|
+
@confirm = confirm
|
|
36
|
+
@cache_watchers_enabled = cache_watchers
|
|
37
|
+
@logger = logger
|
|
14
38
|
@mutex = Mutex.new
|
|
15
39
|
@connection = nil
|
|
16
40
|
@declared_streams = Set.new
|
|
41
|
+
@cache = nil
|
|
42
|
+
@cache_mutex = Mutex.new
|
|
43
|
+
@cache_watchers = {}
|
|
17
44
|
end
|
|
18
45
|
|
|
19
|
-
def set(key, value)
|
|
46
|
+
def set(key, value, ttl: nil)
|
|
20
47
|
name = queue_name(key)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
48
|
+
max_age = ttl ? "#{ttl.ceil}s" : nil
|
|
49
|
+
ensure_stream(name, max_age: max_age)
|
|
50
|
+
expires_at = ttl ? Process.clock_gettime(Process::CLOCK_REALTIME) + ttl : nil
|
|
51
|
+
headers = expires_at ? { EXPIRES_HEADER => expires_at } : nil
|
|
52
|
+
publish(name, value.to_s, headers: headers)
|
|
53
|
+
log(:debug) { "at=set key=#{key} queue=#{name}" }
|
|
54
|
+
if @cache
|
|
55
|
+
@cache_mutex.synchronize { @cache[key] = CacheEntry.new(value: value.to_s, expires_at: expires_at) }
|
|
56
|
+
log(:debug) { "at=set key=#{key} cache=updated" }
|
|
57
|
+
start_cache_watcher(key)
|
|
24
58
|
end
|
|
25
59
|
nil
|
|
26
60
|
end
|
|
27
61
|
|
|
28
62
|
def get(key)
|
|
29
|
-
|
|
30
|
-
|
|
63
|
+
if @cache
|
|
64
|
+
@cache_mutex.synchronize do
|
|
65
|
+
if @cache.key?(key)
|
|
66
|
+
log(:debug) { "at=get key=#{key} source=cache" }
|
|
67
|
+
return @cache[key].current_value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
log(:debug) { "at=get key=#{key} source=stream" }
|
|
72
|
+
resolve_current(consume_stream(queue_name(key), offset: "last"))
|
|
73
|
+
end
|
|
31
74
|
|
|
32
|
-
|
|
33
|
-
|
|
75
|
+
# Cache-only read. Returns the cached value, or nil if the key has
|
|
76
|
+
# been tombstoned or isn't in the cache at all. Never falls through
|
|
77
|
+
# to the stream — intended for callers that want to decouple read
|
|
78
|
+
# latency from the broker (e.g. an HTTP handler that should show a
|
|
79
|
+
# placeholder when the cache isn't ready yet rather than block on
|
|
80
|
+
# AMQP). Use `cached?(key)` to distinguish "tombstoned" from
|
|
81
|
+
# "not cached".
|
|
82
|
+
def cached_get(key)
|
|
83
|
+
return nil unless @cache
|
|
34
84
|
|
|
35
|
-
|
|
85
|
+
@cache_mutex.synchronize do
|
|
86
|
+
return @cache[key].current_value if @cache.key?(key)
|
|
87
|
+
end
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Whether the key is present in the in-memory cache. A tombstoned
|
|
92
|
+
# key still counts as cached (cached_get returns nil for it).
|
|
93
|
+
# Returns false if no preload has happened yet (no cache exists).
|
|
94
|
+
def cached?(key)
|
|
95
|
+
return false unless @cache
|
|
96
|
+
|
|
97
|
+
@cache_mutex.synchronize { @cache.key?(key) }
|
|
36
98
|
end
|
|
37
99
|
|
|
38
100
|
def delete(key)
|
|
39
101
|
name = queue_name(key)
|
|
40
102
|
ensure_stream(name)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
103
|
+
publish_tombstone(name)
|
|
104
|
+
log(:debug) { "at=delete key=#{key} queue=#{name}" }
|
|
105
|
+
if @cache
|
|
106
|
+
@cache_mutex.synchronize { @cache[key] = CacheEntry.new(value: nil, expires_at: nil) }
|
|
107
|
+
log(:debug) { "at=delete key=#{key} cache=tombstoned" }
|
|
108
|
+
start_cache_watcher(key)
|
|
44
109
|
end
|
|
45
110
|
nil
|
|
46
111
|
end
|
|
@@ -50,16 +115,52 @@ module MQKV
|
|
|
50
115
|
end
|
|
51
116
|
|
|
52
117
|
def history(key, limit: 10)
|
|
53
|
-
messages = consume_stream(queue_name(key), offset: "first")
|
|
54
118
|
values = []
|
|
55
|
-
|
|
119
|
+
consume_stream(queue_name(key), offset: "first") do |msg|
|
|
56
120
|
if tombstone?(msg)
|
|
57
121
|
values.clear
|
|
58
122
|
else
|
|
59
123
|
values << msg.body
|
|
124
|
+
# Only the last `limit` values can ever be returned, so drop
|
|
125
|
+
# older ones as we scan — memory stays O(limit) instead of
|
|
126
|
+
# holding every body in the stream at once.
|
|
127
|
+
values.shift if values.size > limit
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
values
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def preload(*keys, max_messages: 10_000)
|
|
134
|
+
@cache_mutex.synchronize { @cache ||= {} }
|
|
135
|
+
keys.each do |key|
|
|
136
|
+
# Only the last message decides the cache entry; stream the
|
|
137
|
+
# scan so a long history never sits in memory all at once.
|
|
138
|
+
last = nil
|
|
139
|
+
count = consume_stream(queue_name(key), offset: "first", max_messages: max_messages) do |msg|
|
|
140
|
+
last = msg
|
|
60
141
|
end
|
|
142
|
+
entry = last ? msg_to_cache_entry(last) : CacheEntry.new(value: nil, expires_at: nil)
|
|
143
|
+
@cache_mutex.synchronize { @cache[key] = entry }
|
|
144
|
+
log(:debug) { "at=preload key=#{key} messages=#{count} value=#{entry.current_value.nil? ? "nil" : "present"}" }
|
|
145
|
+
start_cache_watcher(key)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Like `preload`, but reads from `x-stream-offset: last` so only the
|
|
150
|
+
# current value is consumed (plus anything appended during the brief
|
|
151
|
+
# `read_timeout` window). Use this when keys live in streams with long
|
|
152
|
+
# histories and callers only care about the latest value — `preload`'s
|
|
153
|
+
# `offset: "first"` would otherwise drain up to `max_messages` of
|
|
154
|
+
# accumulated history per key on every boot.
|
|
155
|
+
def preload_latest(*keys)
|
|
156
|
+
@cache_mutex.synchronize { @cache ||= {} }
|
|
157
|
+
keys.each do |key|
|
|
158
|
+
messages = consume_stream(queue_name(key), offset: "last")
|
|
159
|
+
entry = resolve_entry(messages)
|
|
160
|
+
@cache_mutex.synchronize { @cache[key] = entry }
|
|
161
|
+
log(:debug) { "at=preload_latest key=#{key} value=#{entry.current_value.nil? ? "nil" : "present"}" }
|
|
162
|
+
start_cache_watcher(key)
|
|
61
163
|
end
|
|
62
|
-
values.last(limit)
|
|
63
164
|
end
|
|
64
165
|
|
|
65
166
|
def watch(key, &block)
|
|
@@ -82,6 +183,7 @@ module MQKV
|
|
|
82
183
|
end
|
|
83
184
|
|
|
84
185
|
def purge!
|
|
186
|
+
stop_cache_watchers
|
|
85
187
|
conn = @mutex.synchronize { @connection }
|
|
86
188
|
return unless conn && !conn.closed?
|
|
87
189
|
|
|
@@ -93,6 +195,8 @@ module MQKV
|
|
|
93
195
|
end
|
|
94
196
|
|
|
95
197
|
def close
|
|
198
|
+
log(:info) { "at=close" }
|
|
199
|
+
stop_cache_watchers
|
|
96
200
|
@mutex.synchronize do
|
|
97
201
|
@connection&.close
|
|
98
202
|
@connection = nil
|
|
@@ -106,32 +210,67 @@ module MQKV
|
|
|
106
210
|
@mutex.synchronize do
|
|
107
211
|
return @connection if @connection && !@connection.closed?
|
|
108
212
|
|
|
109
|
-
|
|
213
|
+
log(:info) { "at=connect url=#{sanitized_url}" }
|
|
214
|
+
@connection = AMQP::Client.new(@url, **client_options).connect
|
|
110
215
|
@declared_streams.clear
|
|
111
216
|
@connection
|
|
112
217
|
end
|
|
113
218
|
end
|
|
114
219
|
|
|
220
|
+
def client_options
|
|
221
|
+
@connect_timeout ? { connect_timeout: @connect_timeout } : {}
|
|
222
|
+
end
|
|
223
|
+
|
|
115
224
|
def queue_name(key)
|
|
116
225
|
"#{@prefix}.#{key}"
|
|
117
226
|
end
|
|
118
227
|
|
|
119
|
-
def ensure_stream(name)
|
|
228
|
+
def ensure_stream(name, max_age: nil)
|
|
120
229
|
already_declared = @mutex.synchronize { @declared_streams.include?(name) }
|
|
121
230
|
return if already_declared
|
|
122
231
|
|
|
232
|
+
args = { "x-queue-type" => "stream" }
|
|
233
|
+
args["x-max-age"] = max_age if max_age
|
|
234
|
+
|
|
123
235
|
connection.with_channel do |ch|
|
|
124
|
-
ch.queue_declare(name, durable: true, arguments:
|
|
236
|
+
ch.queue_declare(name, durable: true, arguments: args)
|
|
125
237
|
end
|
|
126
238
|
@mutex.synchronize { @declared_streams.add(name) }
|
|
239
|
+
log(:debug) { "at=ensure_stream queue=#{name} status=declared" }
|
|
127
240
|
end
|
|
128
241
|
|
|
129
|
-
def
|
|
242
|
+
def publish(name, body, **properties)
|
|
243
|
+
properties.compact!
|
|
244
|
+
connection.with_channel do |ch|
|
|
245
|
+
if @confirm
|
|
246
|
+
ch.basic_publish_confirm(body, exchange: "", routing_key: name, **properties)
|
|
247
|
+
else
|
|
248
|
+
ch.basic_publish(body, exchange: "", routing_key: name, **properties)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def publish_tombstone(name)
|
|
254
|
+
publish(name, "", headers: { TOMBSTONE_HEADER => true })
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Drains the stream from `offset` until it goes quiet for
|
|
258
|
+
# `read_timeout`. With a block, each message is yielded as it
|
|
259
|
+
# arrives and the message count is returned — nothing is retained,
|
|
260
|
+
# so callers control their own memory. Without a block, all
|
|
261
|
+
# messages are collected and returned (only suitable for short
|
|
262
|
+
# reads like offset=last). Acks are issued in batches as the scan
|
|
263
|
+
# progresses; a single ack at the end would stall delivery once
|
|
264
|
+
# the prefetch window fills and silently truncate longer streams.
|
|
265
|
+
def consume_stream(name, offset:, max_messages: 0)
|
|
130
266
|
ensure_stream(name)
|
|
131
267
|
ch = connection.channel
|
|
132
268
|
begin
|
|
133
|
-
ch.basic_qos(
|
|
134
|
-
collected = []
|
|
269
|
+
ch.basic_qos(CONSUME_PREFETCH)
|
|
270
|
+
collected = block_given? ? nil : []
|
|
271
|
+
count = 0
|
|
272
|
+
unacked = 0
|
|
273
|
+
last = nil
|
|
135
274
|
q = ::Queue.new
|
|
136
275
|
|
|
137
276
|
consume_ok = ch.basic_consume(name, no_ack: false,
|
|
@@ -144,19 +283,115 @@ module MQKV
|
|
|
144
283
|
msg = q.pop(timeout: @read_timeout)
|
|
145
284
|
break if msg.nil?
|
|
146
285
|
|
|
147
|
-
|
|
286
|
+
count += 1
|
|
287
|
+
last = msg
|
|
288
|
+
if collected
|
|
289
|
+
collected << msg
|
|
290
|
+
else
|
|
291
|
+
yield msg
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
unacked += 1
|
|
295
|
+
if unacked >= CONSUME_ACK_BATCH
|
|
296
|
+
ch.basic_ack(msg.delivery_tag, multiple: true)
|
|
297
|
+
unacked = 0
|
|
298
|
+
end
|
|
299
|
+
break if max_messages > 0 && count >= max_messages
|
|
148
300
|
end
|
|
149
301
|
|
|
150
|
-
ch.basic_ack(
|
|
302
|
+
ch.basic_ack(last.delivery_tag, multiple: true) if last && unacked > 0
|
|
151
303
|
ch.basic_cancel(consume_ok.consumer_tag)
|
|
152
|
-
collected
|
|
304
|
+
collected || count
|
|
153
305
|
ensure
|
|
154
306
|
ch.close
|
|
155
307
|
end
|
|
156
308
|
end
|
|
157
309
|
|
|
310
|
+
def resolve_entry(messages)
|
|
311
|
+
return CacheEntry.new(value: nil, expires_at: nil) if messages.empty?
|
|
312
|
+
|
|
313
|
+
last = messages.last
|
|
314
|
+
return CacheEntry.new(value: nil, expires_at: nil) if tombstone?(last)
|
|
315
|
+
|
|
316
|
+
CacheEntry.new(value: last.body, expires_at: msg_expires_at(last))
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def resolve_current(messages)
|
|
320
|
+
resolve_entry(messages).current_value
|
|
321
|
+
end
|
|
322
|
+
|
|
158
323
|
def tombstone?(msg)
|
|
159
|
-
msg.properties&.headers&.fetch(
|
|
324
|
+
msg.properties&.headers&.fetch(TOMBSTONE_HEADER, false) == true
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def msg_expires_at(msg)
|
|
328
|
+
msg.properties&.headers&.fetch(EXPIRES_HEADER, nil)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def msg_to_cache_entry(msg)
|
|
332
|
+
if tombstone?(msg)
|
|
333
|
+
CacheEntry.new(value: nil, expires_at: nil)
|
|
334
|
+
else
|
|
335
|
+
CacheEntry.new(value: msg.body, expires_at: msg_expires_at(msg))
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def start_cache_watcher(key)
|
|
340
|
+
return unless @cache_watchers_enabled
|
|
341
|
+
|
|
342
|
+
@cache_mutex.synchronize { return if @cache_watchers.key?(key) }
|
|
343
|
+
|
|
344
|
+
name = queue_name(key)
|
|
345
|
+
ensure_stream(name)
|
|
346
|
+
ch = connection.channel
|
|
347
|
+
ch.basic_qos(256)
|
|
348
|
+
consume_ok = ch.basic_consume(name, no_ack: false,
|
|
349
|
+
arguments: { "x-stream-offset" => "next" },
|
|
350
|
+
worker_threads: 1) do |msg|
|
|
351
|
+
msg.ack
|
|
352
|
+
entry = msg_to_cache_entry(msg)
|
|
353
|
+
@cache_mutex.synchronize { @cache[key] = entry }
|
|
354
|
+
log(:debug) { "at=cache_watcher key=#{key} value=#{entry.current_value.nil? ? "nil" : "present"}" }
|
|
355
|
+
end
|
|
356
|
+
handle = WatchHandle.new(channel: ch, consumer_tag: consume_ok.consumer_tag)
|
|
357
|
+
|
|
358
|
+
duplicate = @cache_mutex.synchronize do
|
|
359
|
+
if @cache_watchers.key?(key)
|
|
360
|
+
true
|
|
361
|
+
else
|
|
362
|
+
@cache_watchers[key] = handle
|
|
363
|
+
false
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
if duplicate
|
|
368
|
+
unwatch(handle)
|
|
369
|
+
else
|
|
370
|
+
log(:debug) { "at=cache_watcher key=#{key} status=started" }
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def stop_cache_watchers
|
|
375
|
+
watchers = @cache_mutex.synchronize do
|
|
376
|
+
result = @cache_watchers.values
|
|
377
|
+
@cache_watchers.clear
|
|
378
|
+
@cache = nil
|
|
379
|
+
result
|
|
380
|
+
end
|
|
381
|
+
log(:debug) { "at=stop_cache_watchers count=#{watchers.size}" } if watchers.any?
|
|
382
|
+
watchers.each do |handle|
|
|
383
|
+
unwatch(handle)
|
|
384
|
+
rescue StandardError
|
|
385
|
+
nil
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def sanitized_url
|
|
390
|
+
@url.sub(%r{//[^@]+@}, "//")
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def log(level, &block)
|
|
394
|
+
@logger&.send(level, &block)
|
|
160
395
|
end
|
|
161
396
|
end
|
|
162
397
|
end
|
data/lib/mqkv/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mqkv
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- mqkv contributors
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '2.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: logger
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
26
40
|
description: Uses AMQP stream queues (RabbitMQ 3.9+ / LavinMQ) as a key-value store.
|
|
27
41
|
Each key maps to a dedicated stream queue; the latest message is the current value.
|
|
28
42
|
executables: []
|
|
@@ -50,7 +64,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
50
64
|
- !ruby/object:Gem::Version
|
|
51
65
|
version: '0'
|
|
52
66
|
requirements: []
|
|
53
|
-
rubygems_version: 4.0.
|
|
67
|
+
rubygems_version: 4.0.10
|
|
54
68
|
specification_version: 4
|
|
55
69
|
summary: Key-value store backed by AMQP stream queues
|
|
56
70
|
test_files: []
|