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.
data/bin/quality ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Waitmate Quality Gate Suite
5
+ # Runs all non-negotiable gates in sequence.
6
+ # Usage: bin/quality [--skip-appraisal]
7
+ #
8
+ # Individual gates can be run via:
9
+ # bin/quality rspec
10
+ # bin/quality standardrb
11
+ # bin/quality appraisal
12
+ # bin/quality audit
13
+
14
+ SKIP_APPRAISAL=false
15
+ SINGLE_GATE=""
16
+
17
+ for arg in "$@"; do
18
+ case "$arg" in
19
+ --skip-appraisal) SKIP_APPRAISAL=true ;;
20
+ rspec|standardrb|appraisal|audit) SINGLE_GATE="$arg" ;;
21
+ esac
22
+ done
23
+
24
+ red() { printf '\033[31m%s\033[0m\n' "$*" >&2; }
25
+ green() { printf '\033[32m%s\033[0m\n' "$*"; }
26
+ dim() { printf '\033[2m%s\033[0m\n' "$*"; }
27
+
28
+ run_gate() {
29
+ local label="$1"; shift
30
+ dim "⏳ ${label}..."
31
+ if "$@" > /dev/null 2>&1; then
32
+ green "✅ ${label}"
33
+ return 0
34
+ else
35
+ red "❌ ${label} FAILED — check output above"
36
+ return 1
37
+ fi
38
+ }
39
+
40
+ gate_rspec() {
41
+ run_gate "RSpec" bundle exec rspec
42
+ }
43
+
44
+ gate_standardrb() {
45
+ run_gate "StandardRB" bundle exec standardrb
46
+ }
47
+
48
+ gate_appraisal() {
49
+ run_gate "Rails 7.1 appraisal" bundle exec appraisal rails-7.1 rspec \
50
+ && run_gate "Rails 7.2 appraisal" bundle exec appraisal rails-7.2 rspec \
51
+ && run_gate "Rails 8.0 appraisal" bundle exec appraisal rails-8.0 rspec
52
+ }
53
+
54
+ gate_audit() {
55
+ dim "⏳ bundler-audit: updating advisory DB..."
56
+ bundle exec bundler-audit update > /dev/null 2>&1 || true
57
+ run_gate "bundler-audit" bundle exec bundler-audit check
58
+ }
59
+
60
+ run_all() {
61
+ local failed=0
62
+
63
+ gate_rspec || failed=$((failed + 1))
64
+ gate_standardrb || failed=$((failed + 1))
65
+
66
+ if [ "$SKIP_APPRAISAL" = false ]; then
67
+ gate_appraisal || failed=$((failed + 1))
68
+ else
69
+ dim "⏭️ Skipping appraisal (--skip-appraisal)"
70
+ fi
71
+
72
+ gate_audit || failed=$((failed + 1))
73
+
74
+ echo ""
75
+ if [ "$failed" -eq 0 ]; then
76
+ green "All quality gates passed"
77
+ else
78
+ red "${failed} gate(s) failed"
79
+ return 1
80
+ fi
81
+ }
82
+
83
+ case "$SINGLE_GATE" in
84
+ rspec) gate_rspec ;;
85
+ standardrb) gate_standardrb ;;
86
+ appraisal) gate_appraisal ;;
87
+ audit) gate_audit ;;
88
+ "") run_all ;;
89
+ esac
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Waitmate::Engine.routes.draw do
4
+ get "room", to: "rooms#show"
5
+ get "room/position", to: "rooms#position"
6
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waitmate
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates a Waitmate initializer in config/initializers/waitmate.rb"
9
+
10
+ def copy_initializer
11
+ template "waitmate.rb", "config/initializers/waitmate.rb"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ Waitmate.configure do |config|
4
+ # Storage adapter. :redis is recommended for high-traffic rooms.
5
+ # :solid_cache is supported when you want a Rails/Solid Stack setup
6
+ # without running Redis. Install and migrate Solid Cache first.
7
+ config.adapter = :redis
8
+
9
+ # Seconds before an abandoned queue entry expires. Each poll extends TTL.
10
+ config.queue_ttl = 300
11
+
12
+ # Seconds between browser polls for queue position.
13
+ config.polling_interval = 5
14
+
15
+ # Seconds before an admission ticket expires.
16
+ config.ticket_ttl = 120
17
+
18
+ # Engine path for the waiting-room page. The host app must mount
19
+ # Waitmate::Engine at the matching prefix in config/routes.rb.
20
+ config.waiting_room_path = "/waitmate/room"
21
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waitmate
4
+ class Configuration
5
+ attr_accessor :adapter, :queue_ttl, :polling_interval, :ticket_ttl, :waiting_room_path
6
+
7
+ def initialize
8
+ @adapter = :redis
9
+ @queue_ttl = 300
10
+ @polling_interval = 5
11
+ @ticket_ttl = 120
12
+ @waiting_room_path = "/waitmate/room"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/concern"
5
+
6
+ module Waitmate
7
+ # Shared validation for the relative target URL that is threaded between the
8
+ # host controller and the Engine waiting room. Keeping this in one place
9
+ # ensures the concern-emit path and the room-redirect path never diverge.
10
+ module TargetValidation
11
+ extend ActiveSupport::Concern
12
+
13
+ included { private }
14
+
15
+ def valid_waiting_room_target?(target)
16
+ return false unless target.is_a?(String) && target.start_with?("/")
17
+ return false if target.start_with?("//")
18
+ return false if target.include?("://")
19
+ return false if target.match?(/\Ajavascript:/i)
20
+ true
21
+ end
22
+ end
23
+
24
+ # Provides the +wait_room+ controller macro. Host controllers include this
25
+ # concern and declare which actions are capacity-gated:
26
+ #
27
+ # class CheckoutsController < ApplicationController
28
+ # include Waitmate::ControllerConcern
29
+ # wait_room :create, max_concurrent: 500
30
+ # end
31
+ #
32
+ # The macro is transport-agnostic: it performs capacity-check → redirect →
33
+ # return-verify without knowing whether the waiting room uses polling,
34
+ # ActionCable, or full-page reloads.
35
+ module ControllerConcern
36
+ extend ActiveSupport::Concern
37
+ include TargetValidation
38
+
39
+ class_methods do
40
+ def wait_room(action, max_concurrent:)
41
+ before_action(only: action) { handle_wait_room(max_concurrent: max_concurrent) }
42
+ after_action(only: action) { release_wait_room_slot(max_concurrent: max_concurrent) }
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def handle_wait_room(max_concurrent:)
49
+ queue_name = action_name.to_s
50
+ session_id = session.id.to_s
51
+
52
+ if params[:ticket].present?
53
+ handle_return(queue_name, session_id, max_concurrent)
54
+ else
55
+ handle_initial(queue_name, session_id, max_concurrent)
56
+ end
57
+ end
58
+
59
+ def handle_initial(queue_name, session_id, max_concurrent)
60
+ Store.enqueue(queue_name, session_id)
61
+ Store.admit(queue_name, max_concurrent, count: 1)
62
+
63
+ return if Store.position(queue_name, session_id) == 0
64
+
65
+ redirect_to_waiting_room(
66
+ ticket: Ticket.issue(queue_name: queue_name, session_id: session_id),
67
+ queue: queue_name,
68
+ target: request.fullpath
69
+ )
70
+ end
71
+
72
+ def handle_return(queue_name, session_id, max_concurrent)
73
+ result = Ticket.verify(token: params[:ticket], queue_name: queue_name, session_id: session_id)
74
+ target = request.fullpath
75
+ return redirect_to_waiting_room(queue: queue_name, target: target) unless result.success?
76
+
77
+ Store.admit(queue_name, max_concurrent, count: 1)
78
+
79
+ return if Store.position(queue_name, session_id) == 0
80
+
81
+ redirect_to_waiting_room(ticket: params[:ticket], queue: queue_name, target: target)
82
+ end
83
+
84
+ def redirect_to_waiting_room(ticket: nil, queue: nil, target: nil)
85
+ path = Waitmate.configuration.waiting_room_path
86
+ query = {ticket: ticket, queue: queue}.compact
87
+ query[:target] = target if valid_waiting_room_target?(target)
88
+ path += "?#{query.to_query}" if query.any?
89
+ redirect_to(path)
90
+ end
91
+
92
+ def release_wait_room_slot(max_concurrent:)
93
+ queue_name = action_name.to_s
94
+ session_id = session.id.to_s
95
+
96
+ Store.admit(queue_name, max_concurrent, count: 1) if Store.release(queue_name, session_id)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waitmate
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Waitmate
6
+ end
7
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "redis" unless defined?(::Redis)
5
+ rescue LoadError
6
+ # Redis is optional; instantiation without the gem raises ConfigurationError.
7
+ end
8
+
9
+ module Waitmate
10
+ module Store
11
+ # Redis-backed store contract implementation. All operations that touch
12
+ # capacity or ordering run inside atomic Lua scripts so concurrent callers
13
+ # cannot double-count or lose slots.
14
+ #
15
+ # Per-queue data model:
16
+ # waitmate:queue:{q} - sorted set keyed by enqueue timestamp (FIFO)
17
+ # waitmate:waiting_expiry:{q} - sorted set keyed by expiry timestamp
18
+ # waitmate:active:{q} - sorted set keyed by expiry timestamp
19
+ class Redis
20
+ KEY_PREFIX = "waitmate"
21
+ LUA_SCRIPTS = {
22
+ enqueue: <<~LUA,
23
+ local queue_key = KEYS[1]
24
+ local expiry_key = KEYS[2]
25
+ local active_key = KEYS[3]
26
+ local identity = ARGV[1]
27
+ local now = tonumber(ARGV[2])
28
+ local expiry = tonumber(ARGV[3])
29
+
30
+ redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
31
+
32
+ if redis.call('ZRANK', active_key, identity) ~= false then
33
+ return 0
34
+ end
35
+
36
+ local waiting_rank = redis.call('ZRANK', queue_key, identity)
37
+ if waiting_rank ~= false then
38
+ redis.call('ZADD', expiry_key, expiry, identity)
39
+ return waiting_rank + 1
40
+ end
41
+
42
+ redis.call('ZADD', queue_key, now, identity)
43
+ redis.call('ZADD', expiry_key, expiry, identity)
44
+ return redis.call('ZRANK', queue_key, identity) + 1
45
+ LUA
46
+
47
+ position: <<~LUA,
48
+ local queue_key = KEYS[1]
49
+ local expiry_key = KEYS[2]
50
+ local active_key = KEYS[3]
51
+ local identity = ARGV[1]
52
+ local now = tonumber(ARGV[2])
53
+
54
+ redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
55
+
56
+ local expired = redis.call('ZRANGEBYSCORE', expiry_key, '-inf', now)
57
+ for i = 1, #expired do
58
+ redis.call('ZREM', queue_key, expired[i])
59
+ redis.call('ZREM', expiry_key, expired[i])
60
+ end
61
+
62
+ if redis.call('ZRANK', active_key, identity) ~= false then
63
+ return 0
64
+ end
65
+
66
+ local waiting_rank = redis.call('ZRANK', queue_key, identity)
67
+ if waiting_rank ~= false then
68
+ return waiting_rank + 1
69
+ end
70
+
71
+ return -1
72
+ LUA
73
+
74
+ active_count: <<~LUA,
75
+ local active_key = KEYS[1]
76
+ local now = tonumber(ARGV[1])
77
+
78
+ redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
79
+ return redis.call('ZCARD', active_key)
80
+ LUA
81
+
82
+ admit: <<~LUA,
83
+ local queue_key = KEYS[1]
84
+ local expiry_key = KEYS[2]
85
+ local active_key = KEYS[3]
86
+ local now = tonumber(ARGV[1])
87
+ local active_ttl = tonumber(ARGV[2])
88
+ local max_concurrent = tonumber(ARGV[3])
89
+ local count = tonumber(ARGV[4])
90
+
91
+ redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
92
+
93
+ local expired_waiting = redis.call('ZRANGEBYSCORE', expiry_key, '-inf', now)
94
+ for i = 1, #expired_waiting do
95
+ redis.call('ZREM', queue_key, expired_waiting[i])
96
+ redis.call('ZREM', expiry_key, expired_waiting[i])
97
+ end
98
+
99
+ local active_count = redis.call('ZCARD', active_key)
100
+ local available = max_concurrent - active_count
101
+ if available <= 0 then
102
+ return {}
103
+ end
104
+
105
+ local admit_count = math.min(available, count)
106
+ local waiting = redis.call('ZRANGE', queue_key, 0, admit_count - 1)
107
+ if #waiting == 0 then
108
+ return {}
109
+ end
110
+
111
+ local active_expiry = now + active_ttl
112
+ for i = 1, #waiting do
113
+ local identity = waiting[i]
114
+ redis.call('ZREM', queue_key, identity)
115
+ redis.call('ZREM', expiry_key, identity)
116
+ redis.call('ZADD', active_key, active_expiry, identity)
117
+ end
118
+
119
+ return waiting
120
+ LUA
121
+
122
+ heartbeat: <<~LUA,
123
+ local queue_key = KEYS[1]
124
+ local expiry_key = KEYS[2]
125
+ local active_key = KEYS[3]
126
+ local identity = ARGV[1]
127
+ local now = tonumber(ARGV[2])
128
+ local ttl = tonumber(ARGV[3])
129
+
130
+ if redis.call('ZRANK', active_key, identity) ~= false then
131
+ redis.call('ZADD', active_key, now + ttl, identity)
132
+ return 1
133
+ end
134
+
135
+ if redis.call('ZRANK', queue_key, identity) ~= false then
136
+ redis.call('ZADD', expiry_key, now + ttl, identity)
137
+ return 1
138
+ end
139
+
140
+ return 0
141
+ LUA
142
+
143
+ expire_stale: <<~LUA
144
+ local queue_key = KEYS[1]
145
+ local expiry_key = KEYS[2]
146
+ local active_key = KEYS[3]
147
+ local now = tonumber(ARGV[1])
148
+
149
+ local expired_waiting = redis.call('ZRANGEBYSCORE', expiry_key, '-inf', now)
150
+ for i = 1, #expired_waiting do
151
+ redis.call('ZREM', queue_key, expired_waiting[i])
152
+ redis.call('ZREM', expiry_key, expired_waiting[i])
153
+ end
154
+
155
+ local active_removed = redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
156
+
157
+ return {#expired_waiting, active_removed}
158
+ LUA
159
+ }.freeze
160
+
161
+ def initialize(redis: nil, config: Waitmate.configuration)
162
+ unless defined?(::Redis)
163
+ raise ConfigurationError,
164
+ "Redis gem is not available. Add `gem 'redis'` to your Gemfile to use the Redis adapter."
165
+ end
166
+
167
+ @config = config
168
+ @redis = redis || ::Redis.new
169
+ end
170
+
171
+ attr_reader :redis
172
+
173
+ def enqueue(queue_name, identity, ttl: nil)
174
+ ttl ||= @config.queue_ttl
175
+ now = current_timestamp
176
+ expiry = now + ttl
177
+
178
+ eval_script(
179
+ :enqueue,
180
+ keys: [queue_key(queue_name), expiry_key(queue_name), active_key(queue_name)],
181
+ argv: [identity.to_s, now, expiry]
182
+ )
183
+ end
184
+
185
+ def position(queue_name, identity)
186
+ rank = eval_script(
187
+ :position,
188
+ keys: [queue_key(queue_name), expiry_key(queue_name), active_key(queue_name)],
189
+ argv: [identity.to_s, current_timestamp]
190
+ )
191
+
192
+ (rank == -1) ? nil : rank
193
+ end
194
+
195
+ def active_count(queue_name)
196
+ eval_script(
197
+ :active_count,
198
+ keys: [active_key(queue_name)],
199
+ argv: [current_timestamp]
200
+ )
201
+ end
202
+
203
+ def admit(queue_name, max_concurrent, count: max_concurrent)
204
+ eval_script(
205
+ :admit,
206
+ keys: [queue_key(queue_name), expiry_key(queue_name), active_key(queue_name)],
207
+ argv: [current_timestamp, @config.queue_ttl, max_concurrent, count]
208
+ )
209
+ end
210
+
211
+ def release(queue_name, identity)
212
+ result = with_redis do |redis|
213
+ redis.zrem(active_key(queue_name), identity.to_s)
214
+ end
215
+ !!result
216
+ end
217
+
218
+ def heartbeat(queue_name, identity, ttl: nil)
219
+ ttl ||= @config.queue_ttl
220
+ result = eval_script(
221
+ :heartbeat,
222
+ keys: [queue_key(queue_name), expiry_key(queue_name), active_key(queue_name)],
223
+ argv: [identity.to_s, current_timestamp, ttl]
224
+ )
225
+ result == 1
226
+ end
227
+
228
+ def expire_stale(queue_name)
229
+ waiting, active = eval_script(
230
+ :expire_stale,
231
+ keys: [queue_key(queue_name), expiry_key(queue_name), active_key(queue_name)],
232
+ argv: [current_timestamp]
233
+ )
234
+
235
+ {waiting: waiting, active: active}
236
+ end
237
+
238
+ private
239
+
240
+ def queue_key(queue_name)
241
+ "#{KEY_PREFIX}:queue:#{queue_name}"
242
+ end
243
+
244
+ def expiry_key(queue_name)
245
+ "#{KEY_PREFIX}:waiting_expiry:#{queue_name}"
246
+ end
247
+
248
+ def active_key(queue_name)
249
+ "#{KEY_PREFIX}:active:#{queue_name}"
250
+ end
251
+
252
+ def current_timestamp
253
+ ::Time.now.to_f
254
+ end
255
+
256
+ def eval_script(name, keys:, argv:)
257
+ with_redis do |redis|
258
+ redis.eval(LUA_SCRIPTS[name], keys: keys, argv: argv)
259
+ end
260
+ end
261
+
262
+ def with_redis
263
+ yield @redis
264
+ rescue ::Redis::CannotConnectError, ::Redis::ConnectionError, ::Redis::TimeoutError => e
265
+ raise ConnectionError,
266
+ "Waitmate could not reach Redis (#{e.class}: #{e.message}). " \
267
+ "Verify REDIS_URL or the configured Redis server."
268
+ rescue ::Redis::BaseError => e
269
+ raise Error, "Waitmate Redis store error (#{e.class}: #{e.message})"
270
+ end
271
+ end
272
+ end
273
+ end