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
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,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,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
|