mqkv 0.1.0 → 0.2.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 +166 -19
- 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: c4110775ffdb60a5ede4fadbdb553beec726b337d7a06c7feb97b4c367cf0b56
|
|
4
|
+
data.tar.gz: e4fd4b75b0d80fe6c70af944ed94507c40e92c610bd55f68259761680de7b61b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eed925c0d43599e3d8dc283ca70befc5bb79e10b72f9a5727eaa33dba93e3509e50304d6aa6cb428fe7cb6e40dff13dc97b75c01ab682b6e0c4ce4e6ab60f998
|
|
7
|
+
data.tar.gz: 5a15102dbaad4203e0b8db1fa8bf22cd9f240ee88c20edf963e6a85f2ef89e6422b7a5d7d1cf25ae529edeb2dd3e6c05e092ba8bbf306ffdcc34c09f0df8b102
|
data/lib/mqkv/store.rb
CHANGED
|
@@ -1,46 +1,77 @@
|
|
|
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
|
+
def initialize(url, prefix: "mqkv", read_timeout: 0.5, confirm: true, logger: nil)
|
|
11
24
|
@url = url
|
|
12
25
|
@prefix = prefix
|
|
13
26
|
@read_timeout = read_timeout
|
|
27
|
+
@confirm = confirm
|
|
28
|
+
@logger = logger
|
|
14
29
|
@mutex = Mutex.new
|
|
15
30
|
@connection = nil
|
|
16
31
|
@declared_streams = Set.new
|
|
32
|
+
@cache = nil
|
|
33
|
+
@cache_mutex = Mutex.new
|
|
34
|
+
@cache_watchers = {}
|
|
17
35
|
end
|
|
18
36
|
|
|
19
|
-
def set(key, value)
|
|
37
|
+
def set(key, value, ttl: nil)
|
|
20
38
|
name = queue_name(key)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
max_age = ttl ? "#{ttl.ceil}s" : nil
|
|
40
|
+
ensure_stream(name, max_age: max_age)
|
|
41
|
+
expires_at = ttl ? Process.clock_gettime(Process::CLOCK_REALTIME) + ttl : nil
|
|
42
|
+
headers = expires_at ? { EXPIRES_HEADER => expires_at } : nil
|
|
43
|
+
publish(name, value.to_s, headers: headers)
|
|
44
|
+
log(:debug) { "at=set key=#{key} queue=#{name}" }
|
|
45
|
+
if @cache
|
|
46
|
+
@cache_mutex.synchronize { @cache[key] = CacheEntry.new(value: value.to_s, expires_at: expires_at) }
|
|
47
|
+
log(:debug) { "at=set key=#{key} cache=updated" }
|
|
48
|
+
start_cache_watcher(key)
|
|
24
49
|
end
|
|
25
50
|
nil
|
|
26
51
|
end
|
|
27
52
|
|
|
28
53
|
def get(key)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
if @cache
|
|
55
|
+
@cache_mutex.synchronize do
|
|
56
|
+
if @cache.key?(key)
|
|
57
|
+
log(:debug) { "at=get key=#{key} source=cache" }
|
|
58
|
+
return @cache[key].current_value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
log(:debug) { "at=get key=#{key} source=stream" }
|
|
63
|
+
resolve_current(consume_stream(queue_name(key), offset: "last"))
|
|
36
64
|
end
|
|
37
65
|
|
|
38
66
|
def delete(key)
|
|
39
67
|
name = queue_name(key)
|
|
40
68
|
ensure_stream(name)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
69
|
+
publish_tombstone(name)
|
|
70
|
+
log(:debug) { "at=delete key=#{key} queue=#{name}" }
|
|
71
|
+
if @cache
|
|
72
|
+
@cache_mutex.synchronize { @cache[key] = CacheEntry.new(value: nil, expires_at: nil) }
|
|
73
|
+
log(:debug) { "at=delete key=#{key} cache=tombstoned" }
|
|
74
|
+
start_cache_watcher(key)
|
|
44
75
|
end
|
|
45
76
|
nil
|
|
46
77
|
end
|
|
@@ -62,6 +93,17 @@ module MQKV
|
|
|
62
93
|
values.last(limit)
|
|
63
94
|
end
|
|
64
95
|
|
|
96
|
+
def preload(*keys, max_messages: 10_000)
|
|
97
|
+
@cache_mutex.synchronize { @cache ||= {} }
|
|
98
|
+
keys.each do |key|
|
|
99
|
+
messages = consume_stream(queue_name(key), offset: "first", max_messages: max_messages)
|
|
100
|
+
entry = resolve_entry(messages)
|
|
101
|
+
@cache_mutex.synchronize { @cache[key] = entry }
|
|
102
|
+
log(:debug) { "at=preload key=#{key} messages=#{messages.size} value=#{entry.current_value.nil? ? "nil" : "present"}" }
|
|
103
|
+
start_cache_watcher(key)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
65
107
|
def watch(key, &block)
|
|
66
108
|
name = queue_name(key)
|
|
67
109
|
ensure_stream(name)
|
|
@@ -82,6 +124,7 @@ module MQKV
|
|
|
82
124
|
end
|
|
83
125
|
|
|
84
126
|
def purge!
|
|
127
|
+
stop_cache_watchers
|
|
85
128
|
conn = @mutex.synchronize { @connection }
|
|
86
129
|
return unless conn && !conn.closed?
|
|
87
130
|
|
|
@@ -93,6 +136,8 @@ module MQKV
|
|
|
93
136
|
end
|
|
94
137
|
|
|
95
138
|
def close
|
|
139
|
+
log(:info) { "at=close" }
|
|
140
|
+
stop_cache_watchers
|
|
96
141
|
@mutex.synchronize do
|
|
97
142
|
@connection&.close
|
|
98
143
|
@connection = nil
|
|
@@ -106,6 +151,7 @@ module MQKV
|
|
|
106
151
|
@mutex.synchronize do
|
|
107
152
|
return @connection if @connection && !@connection.closed?
|
|
108
153
|
|
|
154
|
+
log(:info) { "at=connect url=#{sanitized_url}" }
|
|
109
155
|
@connection = AMQP::Client.new(@url).connect
|
|
110
156
|
@declared_streams.clear
|
|
111
157
|
@connection
|
|
@@ -116,17 +162,36 @@ module MQKV
|
|
|
116
162
|
"#{@prefix}.#{key}"
|
|
117
163
|
end
|
|
118
164
|
|
|
119
|
-
def ensure_stream(name)
|
|
165
|
+
def ensure_stream(name, max_age: nil)
|
|
120
166
|
already_declared = @mutex.synchronize { @declared_streams.include?(name) }
|
|
121
167
|
return if already_declared
|
|
122
168
|
|
|
169
|
+
args = { "x-queue-type" => "stream" }
|
|
170
|
+
args["x-max-age"] = max_age if max_age
|
|
171
|
+
|
|
123
172
|
connection.with_channel do |ch|
|
|
124
|
-
ch.queue_declare(name, durable: true, arguments:
|
|
173
|
+
ch.queue_declare(name, durable: true, arguments: args)
|
|
125
174
|
end
|
|
126
175
|
@mutex.synchronize { @declared_streams.add(name) }
|
|
176
|
+
log(:debug) { "at=ensure_stream queue=#{name} status=declared" }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def publish(name, body, **properties)
|
|
180
|
+
properties.compact!
|
|
181
|
+
connection.with_channel do |ch|
|
|
182
|
+
if @confirm
|
|
183
|
+
ch.basic_publish_confirm(body, exchange: "", routing_key: name, **properties)
|
|
184
|
+
else
|
|
185
|
+
ch.basic_publish(body, exchange: "", routing_key: name, **properties)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def publish_tombstone(name)
|
|
191
|
+
publish(name, "", headers: { TOMBSTONE_HEADER => true })
|
|
127
192
|
end
|
|
128
193
|
|
|
129
|
-
def consume_stream(name, offset:)
|
|
194
|
+
def consume_stream(name, offset:, max_messages: 0)
|
|
130
195
|
ensure_stream(name)
|
|
131
196
|
ch = connection.channel
|
|
132
197
|
begin
|
|
@@ -145,6 +210,7 @@ module MQKV
|
|
|
145
210
|
break if msg.nil?
|
|
146
211
|
|
|
147
212
|
collected << msg
|
|
213
|
+
break if max_messages > 0 && collected.size >= max_messages
|
|
148
214
|
end
|
|
149
215
|
|
|
150
216
|
ch.basic_ack(collected.last.delivery_tag, multiple: true) if collected.any?
|
|
@@ -155,8 +221,89 @@ module MQKV
|
|
|
155
221
|
end
|
|
156
222
|
end
|
|
157
223
|
|
|
224
|
+
def resolve_entry(messages)
|
|
225
|
+
return CacheEntry.new(value: nil, expires_at: nil) if messages.empty?
|
|
226
|
+
|
|
227
|
+
last = messages.last
|
|
228
|
+
return CacheEntry.new(value: nil, expires_at: nil) if tombstone?(last)
|
|
229
|
+
|
|
230
|
+
CacheEntry.new(value: last.body, expires_at: msg_expires_at(last))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def resolve_current(messages)
|
|
234
|
+
resolve_entry(messages).current_value
|
|
235
|
+
end
|
|
236
|
+
|
|
158
237
|
def tombstone?(msg)
|
|
159
|
-
msg.properties&.headers&.fetch(
|
|
238
|
+
msg.properties&.headers&.fetch(TOMBSTONE_HEADER, false) == true
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def msg_expires_at(msg)
|
|
242
|
+
msg.properties&.headers&.fetch(EXPIRES_HEADER, nil)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def msg_to_cache_entry(msg)
|
|
246
|
+
if tombstone?(msg)
|
|
247
|
+
CacheEntry.new(value: nil, expires_at: nil)
|
|
248
|
+
else
|
|
249
|
+
CacheEntry.new(value: msg.body, expires_at: msg_expires_at(msg))
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def start_cache_watcher(key)
|
|
254
|
+
@cache_mutex.synchronize { return if @cache_watchers.key?(key) }
|
|
255
|
+
|
|
256
|
+
name = queue_name(key)
|
|
257
|
+
ensure_stream(name)
|
|
258
|
+
ch = connection.channel
|
|
259
|
+
ch.basic_qos(256)
|
|
260
|
+
consume_ok = ch.basic_consume(name, no_ack: false,
|
|
261
|
+
arguments: { "x-stream-offset" => "next" },
|
|
262
|
+
worker_threads: 1) do |msg|
|
|
263
|
+
msg.ack
|
|
264
|
+
entry = msg_to_cache_entry(msg)
|
|
265
|
+
@cache_mutex.synchronize { @cache[key] = entry }
|
|
266
|
+
log(:debug) { "at=cache_watcher key=#{key} value=#{entry.current_value.nil? ? "nil" : "present"}" }
|
|
267
|
+
end
|
|
268
|
+
handle = WatchHandle.new(channel: ch, consumer_tag: consume_ok.consumer_tag)
|
|
269
|
+
|
|
270
|
+
duplicate = @cache_mutex.synchronize do
|
|
271
|
+
if @cache_watchers.key?(key)
|
|
272
|
+
true
|
|
273
|
+
else
|
|
274
|
+
@cache_watchers[key] = handle
|
|
275
|
+
false
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
if duplicate
|
|
280
|
+
unwatch(handle)
|
|
281
|
+
else
|
|
282
|
+
log(:debug) { "at=cache_watcher key=#{key} status=started" }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def stop_cache_watchers
|
|
287
|
+
watchers = @cache_mutex.synchronize do
|
|
288
|
+
result = @cache_watchers.values
|
|
289
|
+
@cache_watchers.clear
|
|
290
|
+
@cache = nil
|
|
291
|
+
result
|
|
292
|
+
end
|
|
293
|
+
log(:debug) { "at=stop_cache_watchers count=#{watchers.size}" } if watchers.any?
|
|
294
|
+
watchers.each do |handle|
|
|
295
|
+
unwatch(handle)
|
|
296
|
+
rescue StandardError
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def sanitized_url
|
|
302
|
+
@url.sub(%r{//[^@]+@}, "//")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def log(level, &block)
|
|
306
|
+
@logger&.send(level, &block)
|
|
160
307
|
end
|
|
161
308
|
end
|
|
162
309
|
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.2.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.6
|
|
54
68
|
specification_version: 4
|
|
55
69
|
summary: Key-value store backed by AMQP stream queues
|
|
56
70
|
test_files: []
|