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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/mqkv/store.rb +166 -19
  3. data/lib/mqkv/version.rb +1 -1
  4. metadata +16 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52ec1386119f7a8371f41f5942360c248f6b9e9c3a4efaf67a80fac35d1a2d27
4
- data.tar.gz: 1863f574c1fa148399abb666248cec1e2f31b2e9b5c4da52b686e9c2ce84b7bf
3
+ metadata.gz: c4110775ffdb60a5ede4fadbdb553beec726b337d7a06c7feb97b4c367cf0b56
4
+ data.tar.gz: e4fd4b75b0d80fe6c70af944ed94507c40e92c610bd55f68259761680de7b61b
5
5
  SHA512:
6
- metadata.gz: 761d510fea8ee47f91116b7c11f61435f47095cd92d8805603065d5d19723d3027111ed1ee89c4c5785ce2fb9c70cb8766fe55508f5cf3b8d48f563ea5d0021d
7
- data.tar.gz: 877451290430c1b5e268881c6193f239619a0f9c4c506b3f6697c70939404f4ef8fea80561e4dc634f48d81c0167f6ba85680ca40fba748ea06d38763f46996c
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
- def initialize(url, prefix: "mqkv", read_timeout: 0.5)
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
- ensure_stream(name)
22
- connection.with_channel do |ch|
23
- ch.basic_publish_confirm(value.to_s, exchange: "", routing_key: name)
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
- messages = consume_stream(queue_name(key), offset: "last")
30
- return nil if messages.empty?
31
-
32
- last = messages.last
33
- return nil if tombstone?(last)
34
-
35
- last.body
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
- connection.with_channel do |ch|
42
- ch.basic_publish_confirm("", exchange: "", routing_key: name,
43
- headers: { "__mqkv_deleted__" => true })
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: { "x-queue-type" => "stream" })
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("__mqkv_deleted__", false) == true
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MQKV
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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.7
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: []