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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/mqkv/store.rb +261 -26
  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: d737d046ae643ce3d316df07cc167f11f9c141b2d6f58f504557edcb3c4384c8
4
+ data.tar.gz: 574cf60004e1150b02959944c6fb38797ae14ecdca48351d7813a87441c465d4
5
5
  SHA512:
6
- metadata.gz: 761d510fea8ee47f91116b7c11f61435f47095cd92d8805603065d5d19723d3027111ed1ee89c4c5785ce2fb9c70cb8766fe55508f5cf3b8d48f563ea5d0021d
7
- data.tar.gz: 877451290430c1b5e268881c6193f239619a0f9c4c506b3f6697c70939404f4ef8fea80561e4dc634f48d81c0167f6ba85680ca40fba748ea06d38763f46996c
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
- 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
+ # 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
- ensure_stream(name)
22
- connection.with_channel do |ch|
23
- ch.basic_publish_confirm(value.to_s, exchange: "", routing_key: name)
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
- messages = consume_stream(queue_name(key), offset: "last")
30
- return nil if messages.empty?
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
- last = messages.last
33
- return nil if tombstone?(last)
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
- last.body
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
- connection.with_channel do |ch|
42
- ch.basic_publish_confirm("", exchange: "", routing_key: name,
43
- headers: { "__mqkv_deleted__" => true })
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
- messages.each do |msg|
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
- @connection = AMQP::Client.new(@url).connect
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: { "x-queue-type" => "stream" })
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 consume_stream(name, offset:)
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(256)
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
- collected << msg
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(collected.last.delivery_tag, multiple: true) if collected.any?
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("__mqkv_deleted__", false) == true
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MQKV
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.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.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.7
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: []