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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/mqkv/store.rb +101 -13
  3. data/lib/mqkv/version.rb +1 -1
  4. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4110775ffdb60a5ede4fadbdb553beec726b337d7a06c7feb97b4c367cf0b56
4
- data.tar.gz: e4fd4b75b0d80fe6c70af944ed94507c40e92c610bd55f68259761680de7b61b
3
+ metadata.gz: d737d046ae643ce3d316df07cc167f11f9c141b2d6f58f504557edcb3c4384c8
4
+ data.tar.gz: 574cf60004e1150b02959944c6fb38797ae14ecdca48351d7813a87441c465d4
5
5
  SHA512:
6
- metadata.gz: eed925c0d43599e3d8dc283ca70befc5bb79e10b72f9a5727eaa33dba93e3509e50304d6aa6cb428fe7cb6e40dff13dc97b75c01ab682b6e0c4ce4e6ab60f998
7
- data.tar.gz: 5a15102dbaad4203e0b8db1fa8bf22cd9f240ee88c20edf963e6a85f2ef89e6422b7a5d7d1cf25ae529edeb2dd3e6c05e092ba8bbf306ffdcc34c09f0df8b102
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
- def initialize(url, prefix: "mqkv", read_timeout: 0.5, confirm: true, logger: nil)
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
- messages.each do |msg|
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.last(limit)
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
- messages = consume_stream(queue_name(key), offset: "first", max_messages: max_messages)
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=preload key=#{key} messages=#{messages.size} value=#{entry.current_value.nil? ? "nil" : "present"}" }
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(256)
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
- collected << msg
213
- break if max_messages > 0 && collected.size >= max_messages
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(collected.last.delivery_tag, multiple: true) if collected.any?
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MQKV
4
- VERSION = "0.2.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.2.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.6
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: []