mqkv 0.2.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 +101 -13
- data/lib/mqkv/version.rb +1 -1
- metadata +2 -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
|
@@ -20,11 +20,20 @@ module MQKV
|
|
|
20
20
|
TOMBSTONE_HEADER = "__mqkv_deleted__"
|
|
21
21
|
EXPIRES_HEADER = "__mqkv_expires_at__"
|
|
22
22
|
|
|
23
|
-
|
|
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)
|
|
24
31
|
@url = url
|
|
25
32
|
@prefix = prefix
|
|
26
33
|
@read_timeout = read_timeout
|
|
34
|
+
@connect_timeout = connect_timeout
|
|
27
35
|
@confirm = confirm
|
|
36
|
+
@cache_watchers_enabled = cache_watchers
|
|
28
37
|
@logger = logger
|
|
29
38
|
@mutex = Mutex.new
|
|
30
39
|
@connection = nil
|
|
@@ -63,6 +72,31 @@ module MQKV
|
|
|
63
72
|
resolve_current(consume_stream(queue_name(key), offset: "last"))
|
|
64
73
|
end
|
|
65
74
|
|
|
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
|
|
84
|
+
|
|
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) }
|
|
98
|
+
end
|
|
99
|
+
|
|
66
100
|
def delete(key)
|
|
67
101
|
name = queue_name(key)
|
|
68
102
|
ensure_stream(name)
|
|
@@ -81,25 +115,50 @@ module MQKV
|
|
|
81
115
|
end
|
|
82
116
|
|
|
83
117
|
def history(key, limit: 10)
|
|
84
|
-
messages = consume_stream(queue_name(key), offset: "first")
|
|
85
118
|
values = []
|
|
86
|
-
|
|
119
|
+
consume_stream(queue_name(key), offset: "first") do |msg|
|
|
87
120
|
if tombstone?(msg)
|
|
88
121
|
values.clear
|
|
89
122
|
else
|
|
90
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
|
|
91
128
|
end
|
|
92
129
|
end
|
|
93
|
-
values
|
|
130
|
+
values
|
|
94
131
|
end
|
|
95
132
|
|
|
96
133
|
def preload(*keys, max_messages: 10_000)
|
|
97
134
|
@cache_mutex.synchronize { @cache ||= {} }
|
|
98
135
|
keys.each do |key|
|
|
99
|
-
|
|
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
|
|
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")
|
|
100
159
|
entry = resolve_entry(messages)
|
|
101
160
|
@cache_mutex.synchronize { @cache[key] = entry }
|
|
102
|
-
log(:debug) { "at=
|
|
161
|
+
log(:debug) { "at=preload_latest key=#{key} value=#{entry.current_value.nil? ? "nil" : "present"}" }
|
|
103
162
|
start_cache_watcher(key)
|
|
104
163
|
end
|
|
105
164
|
end
|
|
@@ -152,12 +211,16 @@ module MQKV
|
|
|
152
211
|
return @connection if @connection && !@connection.closed?
|
|
153
212
|
|
|
154
213
|
log(:info) { "at=connect url=#{sanitized_url}" }
|
|
155
|
-
@connection = AMQP::Client.new(@url).connect
|
|
214
|
+
@connection = AMQP::Client.new(@url, **client_options).connect
|
|
156
215
|
@declared_streams.clear
|
|
157
216
|
@connection
|
|
158
217
|
end
|
|
159
218
|
end
|
|
160
219
|
|
|
220
|
+
def client_options
|
|
221
|
+
@connect_timeout ? { connect_timeout: @connect_timeout } : {}
|
|
222
|
+
end
|
|
223
|
+
|
|
161
224
|
def queue_name(key)
|
|
162
225
|
"#{@prefix}.#{key}"
|
|
163
226
|
end
|
|
@@ -191,12 +254,23 @@ module MQKV
|
|
|
191
254
|
publish(name, "", headers: { TOMBSTONE_HEADER => true })
|
|
192
255
|
end
|
|
193
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.
|
|
194
265
|
def consume_stream(name, offset:, max_messages: 0)
|
|
195
266
|
ensure_stream(name)
|
|
196
267
|
ch = connection.channel
|
|
197
268
|
begin
|
|
198
|
-
ch.basic_qos(
|
|
199
|
-
collected = []
|
|
269
|
+
ch.basic_qos(CONSUME_PREFETCH)
|
|
270
|
+
collected = block_given? ? nil : []
|
|
271
|
+
count = 0
|
|
272
|
+
unacked = 0
|
|
273
|
+
last = nil
|
|
200
274
|
q = ::Queue.new
|
|
201
275
|
|
|
202
276
|
consume_ok = ch.basic_consume(name, no_ack: false,
|
|
@@ -209,13 +283,25 @@ module MQKV
|
|
|
209
283
|
msg = q.pop(timeout: @read_timeout)
|
|
210
284
|
break if msg.nil?
|
|
211
285
|
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
214
300
|
end
|
|
215
301
|
|
|
216
|
-
ch.basic_ack(
|
|
302
|
+
ch.basic_ack(last.delivery_tag, multiple: true) if last && unacked > 0
|
|
217
303
|
ch.basic_cancel(consume_ok.consumer_tag)
|
|
218
|
-
collected
|
|
304
|
+
collected || count
|
|
219
305
|
ensure
|
|
220
306
|
ch.close
|
|
221
307
|
end
|
|
@@ -251,6 +337,8 @@ module MQKV
|
|
|
251
337
|
end
|
|
252
338
|
|
|
253
339
|
def start_cache_watcher(key)
|
|
340
|
+
return unless @cache_watchers_enabled
|
|
341
|
+
|
|
254
342
|
@cache_mutex.synchronize { return if @cache_watchers.key?(key) }
|
|
255
343
|
|
|
256
344
|
name = queue_name(key)
|
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
|
|
@@ -64,7 +64,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
64
64
|
- !ruby/object:Gem::Version
|
|
65
65
|
version: '0'
|
|
66
66
|
requirements: []
|
|
67
|
-
rubygems_version: 4.0.
|
|
67
|
+
rubygems_version: 4.0.10
|
|
68
68
|
specification_version: 4
|
|
69
69
|
summary: Key-value store backed by AMQP stream queues
|
|
70
70
|
test_files: []
|