waitmate 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +136 -0
- data/app/controllers/waitmate/application_controller.rb +7 -0
- data/app/controllers/waitmate/rooms_controller.rb +97 -0
- data/app/views/waitmate/rooms/show.html.erb +350 -0
- data/bin/quality +89 -0
- data/config/routes.rb +6 -0
- data/lib/generators/waitmate/install_generator.rb +15 -0
- data/lib/generators/waitmate/templates/waitmate.rb +21 -0
- data/lib/waitmate/configuration.rb +15 -0
- data/lib/waitmate/controller_concern.rb +99 -0
- data/lib/waitmate/engine.rb +7 -0
- data/lib/waitmate/store/redis.rb +273 -0
- data/lib/waitmate/store/solid_cache.rb +358 -0
- data/lib/waitmate/store.rb +73 -0
- data/lib/waitmate/ticket.rb +78 -0
- data/lib/waitmate/version.rb +7 -0
- data/lib/waitmate.rb +31 -0
- metadata +112 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require "solid_cache" if defined?(Rails)
|
|
7
|
+
rescue LoadError
|
|
8
|
+
# Solid Cache is optional; instantiation without the gem raises ConfigurationError.
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module Waitmate
|
|
12
|
+
module Store
|
|
13
|
+
# SQL-backed fallback adapter built on top of SolidCache::Entry.
|
|
14
|
+
#
|
|
15
|
+
# Solid Cache is a fixed-schema key/value store, so Waitmate queue state
|
|
16
|
+
# is separated into two key prefixes:
|
|
17
|
+
# waitmate:waiting:{queue}:{identity} — waiting entries
|
|
18
|
+
# waitmate:active:{queue}:{identity} — admitted entries
|
|
19
|
+
#
|
|
20
|
+
# This mirrors the Redis adapter's separate sorted sets and lets SQL
|
|
21
|
+
# +LIKE+ narrow scans to one state before Ruby-side JSON parsing.
|
|
22
|
+
# Active scans are bounded by +max_concurrent+; waiting scans are
|
|
23
|
+
# bounded by queue depth ahead of the target entry.
|
|
24
|
+
#
|
|
25
|
+
# FIFO ordering relies on an +enqueued_at+ timestamp stored in the JSON
|
|
26
|
+
# value, avoiding reliance on the binary +created_at+ column which has
|
|
27
|
+
# cross-Rails-version comparison issues.
|
|
28
|
+
#
|
|
29
|
+
# Single-key lookups use +SolidCache::Entry.read+ / +delete_by_key+
|
|
30
|
+
# which operate on the indexed +key_hash+ integer column, avoiding
|
|
31
|
+
# binary +key+ column comparison entirely.
|
|
32
|
+
#
|
|
33
|
+
# Admission is serialized per queue with a mutex row so capacity
|
|
34
|
+
# accounting never follows a read-modify-write path under concurrency.
|
|
35
|
+
class SolidCache
|
|
36
|
+
KEY_PREFIX = "waitmate"
|
|
37
|
+
|
|
38
|
+
def initialize(config: Waitmate.configuration)
|
|
39
|
+
begin
|
|
40
|
+
require "solid_cache" unless defined?(::SolidCache)
|
|
41
|
+
rescue LoadError
|
|
42
|
+
# Fall through to the contract check below.
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless defined?(::SolidCache::Entry)
|
|
46
|
+
raise ConfigurationError,
|
|
47
|
+
"Solid Cache gem is not available. Add `gem 'solid_cache'` to your Gemfile " \
|
|
48
|
+
"to use the Solid Cache adapter."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@queue_ttl = config.queue_ttl
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# enqueue(queue_name, identity, ttl: nil) -> Integer (1-based position) or 0 (already active)
|
|
55
|
+
def enqueue(queue_name, identity, ttl: nil)
|
|
56
|
+
ttl ||= @queue_ttl
|
|
57
|
+
now = current_timestamp
|
|
58
|
+
expires_at = now + ttl
|
|
59
|
+
a_key = active_entry_key(queue_name, identity)
|
|
60
|
+
w_key = waiting_entry_key(queue_name, identity)
|
|
61
|
+
|
|
62
|
+
with_ar do
|
|
63
|
+
# Already active and not expired?
|
|
64
|
+
active_value = read_value(a_key)
|
|
65
|
+
if active_value
|
|
66
|
+
return 0 if active_value["expires_at"] > now
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Already waiting and not expired?
|
|
70
|
+
waiting_value = read_value(w_key)
|
|
71
|
+
if waiting_value
|
|
72
|
+
if waiting_value["expires_at"] > now
|
|
73
|
+
waiting_value["expires_at"] = expires_at
|
|
74
|
+
::SolidCache::Entry.write(w_key, waiting_value.to_json)
|
|
75
|
+
return waiting_position(queue_name, waiting_value["enqueued_at"], now)
|
|
76
|
+
end
|
|
77
|
+
# Expired waiting entry — delete so the new write gets a fresh enqueued_at
|
|
78
|
+
::SolidCache::Entry.delete_by_key(w_key)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# New entry
|
|
82
|
+
value = {state: "waiting", expires_at: expires_at, enqueued_at: now}
|
|
83
|
+
::SolidCache::Entry.write(w_key, value.to_json)
|
|
84
|
+
waiting_position(queue_name, now, now)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# position(queue_name, identity) -> Integer (1-based), 0 (active), or nil
|
|
89
|
+
def position(queue_name, identity)
|
|
90
|
+
now = current_timestamp
|
|
91
|
+
a_key = active_entry_key(queue_name, identity)
|
|
92
|
+
w_key = waiting_entry_key(queue_name, identity)
|
|
93
|
+
|
|
94
|
+
with_ar do
|
|
95
|
+
# Check active
|
|
96
|
+
active_value = read_value(a_key)
|
|
97
|
+
if active_value
|
|
98
|
+
if active_value["expires_at"] <= now
|
|
99
|
+
::SolidCache::Entry.delete_by_key(a_key)
|
|
100
|
+
return nil
|
|
101
|
+
end
|
|
102
|
+
return 0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Check waiting
|
|
106
|
+
waiting_value = read_value(w_key)
|
|
107
|
+
if waiting_value
|
|
108
|
+
if waiting_value["expires_at"] <= now
|
|
109
|
+
::SolidCache::Entry.delete_by_key(w_key)
|
|
110
|
+
return nil
|
|
111
|
+
end
|
|
112
|
+
return waiting_position(queue_name, waiting_value["enqueued_at"], now)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# active_count(queue_name) -> Integer
|
|
120
|
+
def active_count(queue_name)
|
|
121
|
+
now = current_timestamp
|
|
122
|
+
|
|
123
|
+
with_ar do
|
|
124
|
+
active_count_internal(queue_name, now)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# admit(queue_name, max_concurrent, count: max_concurrent) -> Array<String>
|
|
129
|
+
def admit(queue_name, max_concurrent, count: max_concurrent)
|
|
130
|
+
now = current_timestamp
|
|
131
|
+
active_ttl = @queue_ttl
|
|
132
|
+
|
|
133
|
+
with_ar do
|
|
134
|
+
admitted = []
|
|
135
|
+
|
|
136
|
+
ActiveRecord::Base.transaction do
|
|
137
|
+
ensure_mutex(queue_name)
|
|
138
|
+
expire_stale_internal(queue_name, now)
|
|
139
|
+
|
|
140
|
+
active = active_count_internal(queue_name, now)
|
|
141
|
+
available = max_concurrent - active
|
|
142
|
+
break admitted if available <= 0
|
|
143
|
+
|
|
144
|
+
admit_count = [available, count].min
|
|
145
|
+
|
|
146
|
+
# Separate waiting prefix guarantees all returned rows are waiting.
|
|
147
|
+
# No Ruby-side state filter needed — fixes the S2-NEWBUG capacity bug.
|
|
148
|
+
# Sort by enqueued_at from the JSON value for FIFO ordering.
|
|
149
|
+
# uncached is required because SolidCache's own write/delete methods do
|
|
150
|
+
# not dirty the Rails query cache, so a subsequent scan in the same
|
|
151
|
+
# request (e.g. release → admit) could read stale waiting rows.
|
|
152
|
+
waiting = ::SolidCache::Entry.uncached do
|
|
153
|
+
::SolidCache::Entry
|
|
154
|
+
.where("key LIKE ?", waiting_key_pattern(queue_name))
|
|
155
|
+
.to_a
|
|
156
|
+
.map { |entry| [entry, parse_value(entry.value, key: entry.key)] }
|
|
157
|
+
.select { |_, value| value["expires_at"] > now }
|
|
158
|
+
.sort_by { |_, value| value["enqueued_at"] }
|
|
159
|
+
.first(admit_count)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
waiting.each do |entry, value|
|
|
163
|
+
identity = identity_from_key(entry.key, queue_name)
|
|
164
|
+
value["state"] = "active"
|
|
165
|
+
value["expires_at"] = now + active_ttl
|
|
166
|
+
::SolidCache::Entry.delete_by_key(waiting_entry_key(queue_name, identity))
|
|
167
|
+
::SolidCache::Entry.write(active_entry_key(queue_name, identity), value.to_json)
|
|
168
|
+
admitted << identity
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
admitted
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# release(queue_name, identity) -> Boolean
|
|
177
|
+
def release(queue_name, identity)
|
|
178
|
+
key = active_entry_key(queue_name, identity)
|
|
179
|
+
|
|
180
|
+
with_ar do
|
|
181
|
+
result = ::SolidCache::Entry.delete_by_key(key)
|
|
182
|
+
result.is_a?(Integer) ? result > 0 : result
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# heartbeat(queue_name, identity, ttl: nil) -> Boolean
|
|
187
|
+
def heartbeat(queue_name, identity, ttl: nil)
|
|
188
|
+
ttl ||= @queue_ttl
|
|
189
|
+
now = current_timestamp
|
|
190
|
+
w_key = waiting_entry_key(queue_name, identity)
|
|
191
|
+
a_key = active_entry_key(queue_name, identity)
|
|
192
|
+
|
|
193
|
+
with_ar do
|
|
194
|
+
# Check waiting first
|
|
195
|
+
value = read_value(w_key)
|
|
196
|
+
if value
|
|
197
|
+
return false if value["expires_at"] <= now
|
|
198
|
+
value["expires_at"] = now + ttl
|
|
199
|
+
::SolidCache::Entry.write(w_key, value.to_json)
|
|
200
|
+
return true
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Check active
|
|
204
|
+
value = read_value(a_key)
|
|
205
|
+
if value
|
|
206
|
+
return false if value["expires_at"] <= now
|
|
207
|
+
value["expires_at"] = now + ttl
|
|
208
|
+
::SolidCache::Entry.write(a_key, value.to_json)
|
|
209
|
+
return true
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
false
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# expire_stale(queue_name) -> Hash {waiting: Integer, active: Integer}
|
|
217
|
+
def expire_stale(queue_name)
|
|
218
|
+
now = current_timestamp
|
|
219
|
+
|
|
220
|
+
with_ar do
|
|
221
|
+
ActiveRecord::Base.transaction do
|
|
222
|
+
ensure_mutex(queue_name)
|
|
223
|
+
expire_stale_internal(queue_name, now)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def waiting_entry_key(queue_name, identity)
|
|
231
|
+
"#{KEY_PREFIX}:waiting:#{queue_name}:#{identity}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def active_entry_key(queue_name, identity)
|
|
235
|
+
"#{KEY_PREFIX}:active:#{queue_name}:#{identity}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def mutex_key(queue_name)
|
|
239
|
+
"#{KEY_PREFIX}:mutex:#{queue_name}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def waiting_key_pattern(queue_name)
|
|
243
|
+
"#{KEY_PREFIX}:waiting:#{ActiveRecord::Base.sanitize_sql_like(queue_name)}:%"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def active_key_pattern(queue_name)
|
|
247
|
+
"#{KEY_PREFIX}:active:#{ActiveRecord::Base.sanitize_sql_like(queue_name)}:%"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def identity_from_key(key, queue_name)
|
|
251
|
+
key_s = key.to_s
|
|
252
|
+
%w[waiting active].each do |state|
|
|
253
|
+
prefix = "#{KEY_PREFIX}:#{state}:#{queue_name}:"
|
|
254
|
+
return key_s.sub(prefix, "") if key_s.start_with?(prefix)
|
|
255
|
+
end
|
|
256
|
+
key_s
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def current_timestamp
|
|
260
|
+
::Time.now.to_f
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Reads and parses a JSON value using Solid Cache's key_hash-based read.
|
|
264
|
+
# Returns nil if the key does not exist.
|
|
265
|
+
def read_value(key)
|
|
266
|
+
raw = ::SolidCache::Entry.read(key)
|
|
267
|
+
return nil unless raw
|
|
268
|
+
parse_value(raw, key: key)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Parses a JSON value. On parse failure, includes a hashed key fragment
|
|
272
|
+
# for debugging without leaking PII (L0036).
|
|
273
|
+
def parse_value(value, key: nil)
|
|
274
|
+
JSON.parse(value.to_s)
|
|
275
|
+
rescue JSON::ParserError => e
|
|
276
|
+
if key
|
|
277
|
+
digest = Digest::SHA256.hexdigest(key.to_s)[0, 12]
|
|
278
|
+
raise JSON::ParserError, "#{e.message} [key digest: #{digest}]"
|
|
279
|
+
end
|
|
280
|
+
raise
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Counts active entries by scanning only the active key prefix.
|
|
284
|
+
# Materializes active rows only (bounded by max_concurrent), not the
|
|
285
|
+
# full queue. Ruby-side JSON parsing for expires_at is unavoidable
|
|
286
|
+
# because Solid Cache's KV schema has no per-entry TTL column.
|
|
287
|
+
def active_count_internal(queue_name, now)
|
|
288
|
+
::SolidCache::Entry.uncached do
|
|
289
|
+
::SolidCache::Entry
|
|
290
|
+
.where("key LIKE ?", active_key_pattern(queue_name))
|
|
291
|
+
.count do |entry|
|
|
292
|
+
parse_value(entry.value, key: entry.key)["expires_at"] > now
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Counts non-expired waiting entries with enqueued_at <= target.
|
|
298
|
+
# Scans only the waiting key prefix; does not touch active rows.
|
|
299
|
+
def waiting_position(queue_name, enqueued_at, now)
|
|
300
|
+
::SolidCache::Entry.uncached do
|
|
301
|
+
::SolidCache::Entry
|
|
302
|
+
.where("key LIKE ?", waiting_key_pattern(queue_name))
|
|
303
|
+
.count do |entry|
|
|
304
|
+
value = parse_value(entry.value, key: entry.key)
|
|
305
|
+
value["expires_at"] > now && value["enqueued_at"] <= enqueued_at
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def ensure_mutex(queue_name)
|
|
311
|
+
key = mutex_key(queue_name)
|
|
312
|
+
::SolidCache::Entry.write(key, "1")
|
|
313
|
+
# Verify the mutex row exists using read (key_hash-based lookup)
|
|
314
|
+
raise Error, "Waitmate Solid Cache mutex acquisition failed" unless ::SolidCache::Entry.read(key)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Purges expired entries from both waiting and active prefixes.
|
|
318
|
+
# Called inside the mutex-protected transaction so no concurrent
|
|
319
|
+
# reader sees partially-purged state.
|
|
320
|
+
def expire_stale_internal(queue_name, now)
|
|
321
|
+
waiting_count = 0
|
|
322
|
+
active_count = 0
|
|
323
|
+
|
|
324
|
+
::SolidCache::Entry.uncached do
|
|
325
|
+
::SolidCache::Entry.where("key LIKE ?", waiting_key_pattern(queue_name)).each do |entry|
|
|
326
|
+
if parse_value(entry.value, key: entry.key)["expires_at"] <= now
|
|
327
|
+
::SolidCache::Entry.delete_by_key(entry.key)
|
|
328
|
+
waiting_count += 1
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
::SolidCache::Entry.uncached do
|
|
334
|
+
::SolidCache::Entry.where("key LIKE ?", active_key_pattern(queue_name)).each do |entry|
|
|
335
|
+
if parse_value(entry.value, key: entry.key)["expires_at"] <= now
|
|
336
|
+
::SolidCache::Entry.delete_by_key(entry.key)
|
|
337
|
+
active_count += 1
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
{waiting: waiting_count, active: active_count}
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def with_ar
|
|
346
|
+
yield
|
|
347
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
348
|
+
raise ConnectionError,
|
|
349
|
+
"Waitmate could not reach Solid Cache (#{e.class}: #{e.message}). " \
|
|
350
|
+
"Verify the database connection."
|
|
351
|
+
rescue JSON::ParserError => e
|
|
352
|
+
raise Error, "Waitmate Solid Cache store corrupted value (#{e.class}: #{e.message})"
|
|
353
|
+
rescue => e
|
|
354
|
+
raise Error, "Waitmate Solid Cache store error (#{e.class}: #{e.message})"
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Waitmate
|
|
4
|
+
# Public seam for all queue/storage operations. Host controllers and engine UI
|
|
5
|
+
# call +Waitmate::Store+, never a concrete adapter.
|
|
6
|
+
module Store
|
|
7
|
+
class Error < Waitmate::Error; end
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
class ConnectionError < Error; end
|
|
10
|
+
|
|
11
|
+
@adapter = nil
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# enqueue(queue_name, identity, ttl: nil) -> Integer (1-based position) or 0 (already active)
|
|
15
|
+
def enqueue(queue_name, identity, ttl: nil)
|
|
16
|
+
adapter.enqueue(queue_name, identity, ttl: ttl)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# position(queue_name, identity) -> Integer (1-based), 0 (active), or nil
|
|
20
|
+
def position(queue_name, identity)
|
|
21
|
+
adapter.position(queue_name, identity)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# active_count(queue_name) -> Integer
|
|
25
|
+
def active_count(queue_name)
|
|
26
|
+
adapter.active_count(queue_name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# admit(queue_name, max_concurrent, count: max_concurrent) -> Array<String>
|
|
30
|
+
def admit(queue_name, max_concurrent, count: max_concurrent)
|
|
31
|
+
adapter.admit(queue_name, max_concurrent, count: count)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# release(queue_name, identity) -> Boolean
|
|
35
|
+
def release(queue_name, identity)
|
|
36
|
+
adapter.release(queue_name, identity)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# heartbeat(queue_name, identity, ttl: nil) -> Boolean
|
|
40
|
+
def heartbeat(queue_name, identity, ttl: nil)
|
|
41
|
+
adapter.heartbeat(queue_name, identity, ttl: ttl)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# expire_stale(queue_name) -> Hash {waiting: Integer, active: Integer}
|
|
45
|
+
def expire_stale(queue_name)
|
|
46
|
+
adapter.expire_stale(queue_name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def adapter
|
|
50
|
+
@adapter ||= build_adapter(Waitmate.configuration.adapter)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_writer :adapter
|
|
54
|
+
|
|
55
|
+
def reset_adapter!
|
|
56
|
+
@adapter = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def build_adapter(name)
|
|
62
|
+
case name
|
|
63
|
+
when :redis
|
|
64
|
+
Redis.new
|
|
65
|
+
when :solid_cache
|
|
66
|
+
SolidCache.new
|
|
67
|
+
else
|
|
68
|
+
raise ConfigurationError, "Unknown Waitmate adapter: #{name.inspect}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_support/message_encryptor"
|
|
6
|
+
|
|
7
|
+
module Waitmate
|
|
8
|
+
module Ticket
|
|
9
|
+
PURPOSE = "waitmate:ticket"
|
|
10
|
+
SALT = "waitmate ticket v1"
|
|
11
|
+
KEY_LENGTH = 32
|
|
12
|
+
|
|
13
|
+
class Result
|
|
14
|
+
attr_reader :reason
|
|
15
|
+
|
|
16
|
+
def initialize(success:, reason: nil)
|
|
17
|
+
@success = success
|
|
18
|
+
@reason = reason
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def success?
|
|
22
|
+
@success
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def issue(queue_name:, session_id:)
|
|
28
|
+
encryptor.encrypt_and_sign(
|
|
29
|
+
payload(queue_name, session_id),
|
|
30
|
+
expires_in: Waitmate.configuration.ticket_ttl,
|
|
31
|
+
purpose: PURPOSE
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def verify(token:, queue_name:, session_id:)
|
|
36
|
+
data = encryptor.decrypt_and_verify(token.to_s, purpose: PURPOSE)
|
|
37
|
+
return failure(:invalid) unless valid_payload?(data, queue_name, session_id)
|
|
38
|
+
|
|
39
|
+
success
|
|
40
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
41
|
+
failure(:invalid)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def payload(queue_name, session_id)
|
|
47
|
+
{
|
|
48
|
+
"queue_name" => queue_name.to_s,
|
|
49
|
+
"identity_digest" => identity_digest(session_id)
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def identity_digest(session_id)
|
|
54
|
+
Digest::SHA256.hexdigest(session_id.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def valid_payload?(data, queue_name, session_id)
|
|
58
|
+
data.is_a?(Hash) &&
|
|
59
|
+
data["queue_name"] == queue_name.to_s &&
|
|
60
|
+
data["identity_digest"] == identity_digest(session_id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def encryptor
|
|
64
|
+
@encryptor ||= ActiveSupport::MessageEncryptor.new(
|
|
65
|
+
Rails.application.key_generator.generate_key(SALT, KEY_LENGTH)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def success
|
|
70
|
+
Result.new(success: true, reason: nil)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def failure(reason)
|
|
74
|
+
Result.new(success: false, reason: reason)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/waitmate.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "waitmate/version"
|
|
4
|
+
|
|
5
|
+
module Waitmate
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "waitmate/configuration"
|
|
10
|
+
require_relative "waitmate/ticket"
|
|
11
|
+
require_relative "waitmate/store"
|
|
12
|
+
require_relative "waitmate/store/redis"
|
|
13
|
+
require_relative "waitmate/store/solid_cache"
|
|
14
|
+
require_relative "waitmate/controller_concern"
|
|
15
|
+
require_relative "waitmate/engine" if defined?(Rails::Engine)
|
|
16
|
+
|
|
17
|
+
module Waitmate
|
|
18
|
+
class << self
|
|
19
|
+
def configuration
|
|
20
|
+
@configuration ||= Configuration.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def configure
|
|
24
|
+
yield(configuration)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reset_configuration!
|
|
28
|
+
@configuration = Configuration.new
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: waitmate
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- BartOz
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 7.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 7.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: redis
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: solid_cache
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.7'
|
|
47
|
+
- - "<"
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '2'
|
|
50
|
+
type: :runtime
|
|
51
|
+
prerelease: false
|
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '0.7'
|
|
57
|
+
- - "<"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '2'
|
|
60
|
+
description: Protects expensive controller actions from thundering-herd overload by
|
|
61
|
+
queuing overflow users, issuing signed admission tickets, and letting users wait
|
|
62
|
+
on a lightweight Engine-provided page.
|
|
63
|
+
email:
|
|
64
|
+
- bartek.ozdoba@gmail.com
|
|
65
|
+
executables: []
|
|
66
|
+
extensions: []
|
|
67
|
+
extra_rdoc_files: []
|
|
68
|
+
files:
|
|
69
|
+
- LICENSE.txt
|
|
70
|
+
- README.md
|
|
71
|
+
- app/controllers/waitmate/application_controller.rb
|
|
72
|
+
- app/controllers/waitmate/rooms_controller.rb
|
|
73
|
+
- app/views/waitmate/rooms/show.html.erb
|
|
74
|
+
- bin/quality
|
|
75
|
+
- config/routes.rb
|
|
76
|
+
- lib/generators/waitmate/install_generator.rb
|
|
77
|
+
- lib/generators/waitmate/templates/waitmate.rb
|
|
78
|
+
- lib/waitmate.rb
|
|
79
|
+
- lib/waitmate/configuration.rb
|
|
80
|
+
- lib/waitmate/controller_concern.rb
|
|
81
|
+
- lib/waitmate/engine.rb
|
|
82
|
+
- lib/waitmate/store.rb
|
|
83
|
+
- lib/waitmate/store/redis.rb
|
|
84
|
+
- lib/waitmate/store/solid_cache.rb
|
|
85
|
+
- lib/waitmate/ticket.rb
|
|
86
|
+
- lib/waitmate/version.rb
|
|
87
|
+
homepage: https://github.com/bart-oz/waitmate
|
|
88
|
+
licenses:
|
|
89
|
+
- MIT
|
|
90
|
+
metadata:
|
|
91
|
+
homepage_uri: https://github.com/bart-oz/waitmate
|
|
92
|
+
source_code_uri: https://github.com/bart-oz/waitmate
|
|
93
|
+
changelog_uri: https://github.com/bart-oz/waitmate/blob/main/CHANGELOG.md
|
|
94
|
+
rubygems_mfa_required: 'true'
|
|
95
|
+
rdoc_options: []
|
|
96
|
+
require_paths:
|
|
97
|
+
- lib
|
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: 3.2.0
|
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0'
|
|
108
|
+
requirements: []
|
|
109
|
+
rubygems_version: 4.0.6
|
|
110
|
+
specification_version: 4
|
|
111
|
+
summary: Virtual waiting room for Rails applications
|
|
112
|
+
test_files: []
|