wurk 0.0.1
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/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +73 -0
- data/LICENSE +21 -0
- data/README.md +137 -0
- data/SECURITY.md +39 -0
- data/app/controllers/wurk/api/pagination.rb +67 -0
- data/app/controllers/wurk/api/serializers.rb +131 -0
- data/app/controllers/wurk/api_controller.rb +248 -0
- data/app/controllers/wurk/application_controller.rb +7 -0
- data/app/controllers/wurk/dashboard_controller.rb +48 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +34 -0
- data/exe/wurk +22 -0
- data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
- data/lib/generators/wurk/install/install_generator.rb +22 -0
- data/lib/generators/wurk/install/templates/wurk.rb +16 -0
- data/lib/wurk/active_job/wrapper.rb +32 -0
- data/lib/wurk/api/fast.rb +78 -0
- data/lib/wurk/batch/buffer.rb +26 -0
- data/lib/wurk/batch/callback_job.rb +37 -0
- data/lib/wurk/batch/callbacks.rb +176 -0
- data/lib/wurk/batch/client_middleware.rb +27 -0
- data/lib/wurk/batch/death_handler.rb +39 -0
- data/lib/wurk/batch/empty.rb +21 -0
- data/lib/wurk/batch/server_middleware.rb +62 -0
- data/lib/wurk/batch/status.rb +140 -0
- data/lib/wurk/batch.rb +351 -0
- data/lib/wurk/batch_set.rb +67 -0
- data/lib/wurk/capsule.rb +176 -0
- data/lib/wurk/cli.rb +349 -0
- data/lib/wurk/client/buffered.rb +372 -0
- data/lib/wurk/client.rb +330 -0
- data/lib/wurk/compat.rb +136 -0
- data/lib/wurk/component.rb +136 -0
- data/lib/wurk/configuration.rb +373 -0
- data/lib/wurk/context.rb +35 -0
- data/lib/wurk/cron.rb +636 -0
- data/lib/wurk/dashboard_manifest.rb +39 -0
- data/lib/wurk/dead_set.rb +78 -0
- data/lib/wurk/deploy.rb +91 -0
- data/lib/wurk/embedded.rb +94 -0
- data/lib/wurk/encryption.rb +276 -0
- data/lib/wurk/engine.rb +81 -0
- data/lib/wurk/fetcher/reaper.rb +264 -0
- data/lib/wurk/fetcher/reliable.rb +138 -0
- data/lib/wurk/fetcher.rb +11 -0
- data/lib/wurk/health.rb +193 -0
- data/lib/wurk/heartbeat.rb +211 -0
- data/lib/wurk/iterable_job.rb +292 -0
- data/lib/wurk/job/options.rb +70 -0
- data/lib/wurk/job.rb +33 -0
- data/lib/wurk/job_logger.rb +68 -0
- data/lib/wurk/job_record.rb +156 -0
- data/lib/wurk/job_retry.rb +320 -0
- data/lib/wurk/job_set.rb +212 -0
- data/lib/wurk/job_util.rb +162 -0
- data/lib/wurk/keys.rb +52 -0
- data/lib/wurk/launcher.rb +289 -0
- data/lib/wurk/leader.rb +221 -0
- data/lib/wurk/limiter/base.rb +138 -0
- data/lib/wurk/limiter/bucket.rb +80 -0
- data/lib/wurk/limiter/concurrent.rb +132 -0
- data/lib/wurk/limiter/leaky.rb +91 -0
- data/lib/wurk/limiter/points.rb +89 -0
- data/lib/wurk/limiter/server_middleware.rb +77 -0
- data/lib/wurk/limiter/unlimited.rb +48 -0
- data/lib/wurk/limiter/window.rb +80 -0
- data/lib/wurk/limiter.rb +255 -0
- data/lib/wurk/logger.rb +81 -0
- data/lib/wurk/lua/loader.rb +53 -0
- data/lib/wurk/lua.rb +187 -0
- data/lib/wurk/manager.rb +132 -0
- data/lib/wurk/metrics/history.rb +151 -0
- data/lib/wurk/metrics/query.rb +173 -0
- data/lib/wurk/metrics/rollup.rb +169 -0
- data/lib/wurk/metrics/statsd.rb +197 -0
- data/lib/wurk/metrics.rb +7 -0
- data/lib/wurk/middleware/chain.rb +128 -0
- data/lib/wurk/middleware/current_attributes.rb +87 -0
- data/lib/wurk/middleware/expiry.rb +50 -0
- data/lib/wurk/middleware/i18n.rb +63 -0
- data/lib/wurk/middleware/interrupt_handler.rb +45 -0
- data/lib/wurk/middleware/poison_pill.rb +149 -0
- data/lib/wurk/middleware.rb +34 -0
- data/lib/wurk/process_set.rb +243 -0
- data/lib/wurk/processor.rb +247 -0
- data/lib/wurk/queue.rb +108 -0
- data/lib/wurk/queues.rb +80 -0
- data/lib/wurk/rails.rb +9 -0
- data/lib/wurk/railtie.rb +28 -0
- data/lib/wurk/redis_pool.rb +79 -0
- data/lib/wurk/retry_set.rb +17 -0
- data/lib/wurk/scheduled.rb +189 -0
- data/lib/wurk/scheduled_set.rb +18 -0
- data/lib/wurk/sorted_entry.rb +95 -0
- data/lib/wurk/stats.rb +190 -0
- data/lib/wurk/swarm/child_boot.rb +105 -0
- data/lib/wurk/swarm.rb +260 -0
- data/lib/wurk/testing.rb +102 -0
- data/lib/wurk/topology.rb +74 -0
- data/lib/wurk/unique.rb +240 -0
- data/lib/wurk/version.rb +5 -0
- data/lib/wurk/web/config.rb +180 -0
- data/lib/wurk/web/enterprise.rb +138 -0
- data/lib/wurk/web/search.rb +139 -0
- data/lib/wurk/web.rb +25 -0
- data/lib/wurk/work_set.rb +116 -0
- data/lib/wurk/worker/setter.rb +93 -0
- data/lib/wurk/worker.rb +216 -0
- data/lib/wurk.rb +238 -0
- data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
- data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
- data/vendor/assets/dashboard/index.html +13 -0
- data/vendor/assets/dashboard/wurk-manifest.json +4 -0
- metadata +232 -0
data/lib/wurk/leader.rb
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require_relative 'component'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
# Cluster leader election via Redis `SET NX EX`. Single-leader-per-cluster
|
|
9
|
+
# is best-effort (not Raft): a partitioned ex-leader can briefly co-exist
|
|
10
|
+
# with a new one until the TTL expires. Callers that need strict mutual
|
|
11
|
+
# exclusion must idempotency-guard their writes (see `#token`).
|
|
12
|
+
#
|
|
13
|
+
# Wire-compat: the cluster lock lives at `dear-leader` (STRING, EX≈30s)
|
|
14
|
+
# holding the `<hostname>:<pid>:<process_nonce>` identity. Each fresh
|
|
15
|
+
# gain also pulls a monotonic fencing token via `INCR leader-token`,
|
|
16
|
+
# exposed on `#token`. Wurk goes a small step beyond Sidekiq Enterprise
|
|
17
|
+
# (which deliberately does not expose fencing) so downstream code that
|
|
18
|
+
# *can* benefit from a guard has one available; the token is best-effort
|
|
19
|
+
# too — it is never re-read on subsequent acquires, only on transitions.
|
|
20
|
+
#
|
|
21
|
+
# Cadence per spec: renew every 15s while leader, recheck every 60s as
|
|
22
|
+
# follower, lock TTL 30s. Opt out a process from campaigning entirely
|
|
23
|
+
# with `WURK_LEADER=false` (useful for hot-standby pools).
|
|
24
|
+
#
|
|
25
|
+
# Spec: docs/target/sidekiq-ent.md §6.
|
|
26
|
+
class Leader
|
|
27
|
+
DEFAULT_KEY = 'dear-leader'
|
|
28
|
+
TOKEN_KEY = 'leader-token'
|
|
29
|
+
DEFAULT_TTL = 30
|
|
30
|
+
DEFAULT_RENEW_INTERVAL = 15
|
|
31
|
+
DEFAULT_FOLLOWER_INTERVAL = 60
|
|
32
|
+
OPT_OUT_ENV = 'WURK_LEADER'
|
|
33
|
+
THREAD_NAME = 'wurk-leader'
|
|
34
|
+
|
|
35
|
+
attr_reader :key, :ttl, :owner, :token, :config
|
|
36
|
+
|
|
37
|
+
def initialize(config: nil, key: DEFAULT_KEY, ttl: DEFAULT_TTL, # rubocop:disable Metrics/ParameterLists
|
|
38
|
+
renew_interval: DEFAULT_RENEW_INTERVAL,
|
|
39
|
+
follower_interval: DEFAULT_FOLLOWER_INTERVAL,
|
|
40
|
+
pool: nil, owner: nil)
|
|
41
|
+
@config = config
|
|
42
|
+
@key = key
|
|
43
|
+
@ttl = ttl
|
|
44
|
+
@renew_interval = renew_interval
|
|
45
|
+
@follower_interval = follower_interval
|
|
46
|
+
@pool = pool
|
|
47
|
+
@owner = owner || cluster_identity
|
|
48
|
+
@held = false
|
|
49
|
+
@token = nil
|
|
50
|
+
@thread = nil
|
|
51
|
+
@done = false
|
|
52
|
+
@mutex = ::Mutex.new
|
|
53
|
+
@sleeper = ::ConditionVariable.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# `WURK_LEADER=false` makes `acquire` a no-op and `leader?` permanently
|
|
57
|
+
# false; the renewal thread also refuses to start. Useful for hot-
|
|
58
|
+
# standby pools that must never campaign.
|
|
59
|
+
def disabled?
|
|
60
|
+
ENV[OPT_OUT_ENV].to_s.downcase == 'false'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# SET NX EX. If the key already holds *our* owner string (rare — same
|
|
64
|
+
# process re-entering after a hiccup), refresh via EXPIRE so leadership
|
|
65
|
+
# doesn't lapse. On any follower → leader transition, INCR the global
|
|
66
|
+
# `leader-token` so the new token is strictly greater than every prior
|
|
67
|
+
# leader's, then dispatch the `:leader` lifecycle event.
|
|
68
|
+
def acquire # rubocop:disable Naming/PredicateMethod
|
|
69
|
+
return false if disabled?
|
|
70
|
+
|
|
71
|
+
transition = run_set_or_refresh
|
|
72
|
+
return false unless transition
|
|
73
|
+
|
|
74
|
+
if transition == :gained
|
|
75
|
+
@token = redis_call { |c| c.call('INCR', TOKEN_KEY) }
|
|
76
|
+
dispatch_leader_event
|
|
77
|
+
end
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# CAS DEL — only drop the key if we still own it, otherwise a stale
|
|
82
|
+
# release would yank leadership from whichever follower took over.
|
|
83
|
+
def release
|
|
84
|
+
redis_call do |c|
|
|
85
|
+
c.call('DEL', @key) if c.call('GET', @key) == @owner
|
|
86
|
+
end
|
|
87
|
+
@held = false
|
|
88
|
+
@token = nil
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def leader?
|
|
93
|
+
@held
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Spawns the periodic re-election thread. Idempotent. While leader,
|
|
97
|
+
# the loop re-acquires (refreshing TTL) every `renew_interval`; while
|
|
98
|
+
# follower, it polls every `follower_interval`. Caller must invoke
|
|
99
|
+
# `stop` for orderly shutdown — the thread also releases its lock on
|
|
100
|
+
# exit.
|
|
101
|
+
def start
|
|
102
|
+
return nil if disabled?
|
|
103
|
+
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
return @thread if @thread
|
|
106
|
+
|
|
107
|
+
@done = false
|
|
108
|
+
end
|
|
109
|
+
@thread = spawn_loop_thread
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def stop
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
@done = true
|
|
115
|
+
@sleeper.signal
|
|
116
|
+
end
|
|
117
|
+
@thread&.join
|
|
118
|
+
@thread = nil
|
|
119
|
+
release
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def running?
|
|
123
|
+
!@thread.nil? && @thread.alive?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Returns `:gained` on first acquisition, `:held` on renewal, or `nil`
|
|
129
|
+
# if another process currently holds the key. Sets `@held` accordingly
|
|
130
|
+
# so `leader?` reflects the latest state regardless of branch.
|
|
131
|
+
def run_set_or_refresh
|
|
132
|
+
redis_call do |c|
|
|
133
|
+
result = c.call('SET', @key, @owner, 'NX', 'EX', @ttl)
|
|
134
|
+
if result == 'OK'
|
|
135
|
+
outcome = @held ? :held : :gained
|
|
136
|
+
@held = true
|
|
137
|
+
return outcome
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if c.call('GET', @key) == @owner
|
|
141
|
+
c.call('EXPIRE', @key, @ttl)
|
|
142
|
+
outcome = @held ? :held : :gained
|
|
143
|
+
@held = true
|
|
144
|
+
return outcome
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
@held = false
|
|
148
|
+
@token = nil
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def dispatch_leader_event
|
|
154
|
+
return if @config.nil?
|
|
155
|
+
return unless @config.respond_to?(:[])
|
|
156
|
+
|
|
157
|
+
bucket = @config[:lifecycle_events]&.[](:leader)
|
|
158
|
+
return if bucket.nil? || bucket.empty?
|
|
159
|
+
|
|
160
|
+
bucket.each { |hook| invoke_hook(hook) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def invoke_hook(hook)
|
|
164
|
+
hook.call
|
|
165
|
+
rescue StandardError => e
|
|
166
|
+
@config.handle_exception(e, event: :leader) if @config.respond_to?(:handle_exception)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def redis_call(&)
|
|
170
|
+
if @pool
|
|
171
|
+
@pool.with(&)
|
|
172
|
+
elsif @config
|
|
173
|
+
@config.redis(&)
|
|
174
|
+
else
|
|
175
|
+
Wurk.redis(&)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Matches `Wurk::Component#identity` so `ProcessSet::Process#leader?`
|
|
180
|
+
# (which compares `dear-leader == process.identity`) sees a match.
|
|
181
|
+
def cluster_identity
|
|
182
|
+
"#{hostname}:#{::Process.pid}:#{Component::PROCESS_NONCE}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def hostname
|
|
186
|
+
ENV['DYNO'] || ::Socket.gethostname
|
|
187
|
+
rescue StandardError
|
|
188
|
+
'localhost'
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def spawn_loop_thread
|
|
192
|
+
t = Thread.new { run_loop }
|
|
193
|
+
t.name = THREAD_NAME
|
|
194
|
+
t.report_on_exception = false
|
|
195
|
+
t
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def run_loop
|
|
199
|
+
until done?
|
|
200
|
+
tick_once
|
|
201
|
+
wait_next
|
|
202
|
+
end
|
|
203
|
+
release
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def tick_once
|
|
207
|
+
acquire
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
@config.handle_exception(e, context: THREAD_NAME) if @config.respond_to?(:handle_exception)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def wait_next
|
|
213
|
+
interval = @held ? @renew_interval : @follower_interval
|
|
214
|
+
@mutex.synchronize { @sleeper.wait(@mutex, interval) unless @done }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def done?
|
|
218
|
+
@mutex.synchronize { @done }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
module Limiter
|
|
9
|
+
# Shared base for every limiter type. Holds the public introspection
|
|
10
|
+
# contract documented in §1.5 — name / type / options / size / status /
|
|
11
|
+
# reset / delete plus the `within_limit(...) { ... }` block. Subclasses
|
|
12
|
+
# override the acquire path and the per-type metric/size methods.
|
|
13
|
+
#
|
|
14
|
+
# `status` is uniform across types (#16): `{ used:, limit:, reset_at:,
|
|
15
|
+
# available? }`. Subclasses supply the three values via `build_status`;
|
|
16
|
+
# Concurrent additionally merges its metric counters.
|
|
17
|
+
class Base
|
|
18
|
+
attr_reader :name, :options
|
|
19
|
+
|
|
20
|
+
# `register:` defaults true so constructing a limiter publishes its
|
|
21
|
+
# metadata + `lmtr-list` membership. The dashboard reconstructs limiters
|
|
22
|
+
# purely to read `status` on a GET, so it passes `register: false` to
|
|
23
|
+
# keep introspection side-effect-free.
|
|
24
|
+
def initialize(name, register: true, **options)
|
|
25
|
+
unless name.is_a?(String) && NAME_PATTERN.match?(name)
|
|
26
|
+
raise ArgumentError, "limiter name must match #{NAME_PATTERN.inspect} (got #{name.inspect})"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
ttl = options[:ttl] || DEFAULT_TTL
|
|
30
|
+
# Spec §1.2: ttl floor of 24h. Anything tighter risks losing the
|
|
31
|
+
# metadata hash mid-job and orphaning slots that read it.
|
|
32
|
+
raise ArgumentError, 'ttl must be >= 86_400' if ttl < 86_400
|
|
33
|
+
|
|
34
|
+
@name = name.dup.freeze
|
|
35
|
+
@options = options
|
|
36
|
+
register! if register
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def type
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def within_limit(**, &)
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def size
|
|
48
|
+
0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Uniform across types (#16). Subclasses override to fill in real
|
|
52
|
+
# numbers; the default reports an idle, unlimited shape.
|
|
53
|
+
def status
|
|
54
|
+
build_status(used: 0, limit: nil, reset_at: nil)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset
|
|
58
|
+
Wurk::Limiter.redis do |c|
|
|
59
|
+
state_keys.each { |k| c.call('DEL', k) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def delete
|
|
64
|
+
Wurk::Limiter.redis do |c|
|
|
65
|
+
(state_keys + [meta_key]).each { |k| c.call('DEL', k) }
|
|
66
|
+
c.call('SREM', LIST_KEY, @name)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Stable fingerprint for the limiter — Sidekiq 8.0+ switched from
|
|
71
|
+
# SHA1 → SHA256 (~10% larger encoding). Web UI groups limiters by
|
|
72
|
+
# this so that interpolated names (`stripe-#{user_id}`) get a single
|
|
73
|
+
# row per shape.
|
|
74
|
+
def fingerprint
|
|
75
|
+
@fingerprint ||= Digest::SHA256.hexdigest("#{type}|#{@name}|#{JSON.dump(serializable_options)}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
protected
|
|
79
|
+
|
|
80
|
+
# Assemble the uniform status hash. `available?` is derived: an
|
|
81
|
+
# unlimited (nil) limit is always available; otherwise headroom remains
|
|
82
|
+
# while `used < limit`. `reset_at` is an epoch-seconds Float (or nil
|
|
83
|
+
# when the type has no clock-driven reset).
|
|
84
|
+
def build_status(used:, limit:, reset_at:)
|
|
85
|
+
{ used: used, limit: limit, reset_at: reset_at,
|
|
86
|
+
available?: limit.nil? || used < limit }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def meta_key
|
|
90
|
+
"lmtr:#{@name}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def state_keys
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def serializable_options
|
|
98
|
+
@options.transform_values do |v|
|
|
99
|
+
case v
|
|
100
|
+
when Proc then '<proc>'
|
|
101
|
+
when Symbol, Numeric, String, true, false, nil then v
|
|
102
|
+
else v.to_s
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def register!
|
|
108
|
+
Wurk::Limiter.redis do |c|
|
|
109
|
+
c.call('SADD', LIST_KEY, @name)
|
|
110
|
+
c.call(
|
|
111
|
+
'HSET', meta_key,
|
|
112
|
+
'type', type.to_s,
|
|
113
|
+
'options', JSON.dump(serializable_options),
|
|
114
|
+
'fingerprint', fingerprint
|
|
115
|
+
)
|
|
116
|
+
c.call('EXPIRE', meta_key, @options[:ttl])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ttl
|
|
121
|
+
@options[:ttl]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Stable random slot id (Concurrent) / nonce (Window). 16 hex chars
|
|
125
|
+
# = 8 bytes — wide enough to avoid collision under any realistic
|
|
126
|
+
# burst and short enough to keep ZSET memory bounded.
|
|
127
|
+
def random_id
|
|
128
|
+
SecureRandom.hex(8)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def lua(name, keys:, argv:)
|
|
132
|
+
Wurk::Limiter.redis do |c|
|
|
133
|
+
Wurk::Lua::Loader.eval_cached(c, name, keys: keys, argv: argv)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
module Limiter
|
|
7
|
+
# Cardinal-boundary counter. Reset at the top of the unit (00:00 of
|
|
8
|
+
# minute/hour/day). On exhaustion sleep until next boundary, retry,
|
|
9
|
+
# OverLimit if that exceeds wait_timeout.
|
|
10
|
+
class Bucket < Base
|
|
11
|
+
def type = :bucket
|
|
12
|
+
|
|
13
|
+
def initialize(name, **options)
|
|
14
|
+
# Eager interval validation so a typo in `:fortnight` blows up at boot,
|
|
15
|
+
# not at first call. Lazy validation defers failures to runtime.
|
|
16
|
+
Limiter.interval_seconds(options[:interval], allow_integer: false)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def size
|
|
21
|
+
Wurk::Limiter.redis { |c| (c.call('GET', epoch_key) || '0').to_i }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# used = this period's count; limit = count; reset_at = the next
|
|
25
|
+
# cardinal boundary, when the counter rolls back to zero (#16).
|
|
26
|
+
def status
|
|
27
|
+
build_status(used: size, limit: @options[:count], reset_at: next_boundary)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def within_limit(used: 1, &block)
|
|
31
|
+
raise ArgumentError, 'block required' unless block
|
|
32
|
+
|
|
33
|
+
deadline = ::Time.now.to_f + @options[:wait_timeout]
|
|
34
|
+
loop do
|
|
35
|
+
ok, _current, secs_to_next = acquire(used)
|
|
36
|
+
return block.call if ok.to_i == 1
|
|
37
|
+
|
|
38
|
+
remaining = deadline - ::Time.now.to_f
|
|
39
|
+
raise OverLimit, self if remaining <= 0
|
|
40
|
+
|
|
41
|
+
sleep [remaining, secs_to_next.to_f, 0.05].compact.min.clamp(0.0, remaining)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
protected
|
|
46
|
+
|
|
47
|
+
def state_keys
|
|
48
|
+
# Bucket keys are per-epoch and self-expiring; reset wipes the
|
|
49
|
+
# current epoch, the only one a caller could care about. Older
|
|
50
|
+
# epochs already EXPIRE'd.
|
|
51
|
+
epoch = ::Time.now.to_i / interval_seconds
|
|
52
|
+
["lmtr-b:#{@name}:#{epoch}"]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def epoch_index
|
|
58
|
+
::Time.now.to_i / interval_seconds
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def epoch_key
|
|
62
|
+
"lmtr-b:#{@name}:#{epoch_index}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def next_boundary
|
|
66
|
+
(epoch_index + 1).to_f * interval_seconds
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def interval_seconds
|
|
70
|
+
@interval_seconds ||= Limiter.interval_seconds(@options[:interval], allow_integer: false)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def acquire(used)
|
|
74
|
+
lua(:limiter_bucket_acquire,
|
|
75
|
+
keys: ["lmtr-b:#{@name}"],
|
|
76
|
+
argv: [@options[:count], interval_seconds, used, ttl])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
module Limiter
|
|
7
|
+
# Atomic slot acquisition in a ZSET. Score = expiry epoch; the acquire
|
|
8
|
+
# script first evicts expired slots (bumping the `reclaimed` metric)
|
|
9
|
+
# then ZADDs if there's headroom.
|
|
10
|
+
#
|
|
11
|
+
# On exhaustion: spin loop with backoff. The spec says "blocks via
|
|
12
|
+
# Redis stream XREAD" — that's a perf optimization; the visible
|
|
13
|
+
# behavior is identical: blocks up to `wait_timeout` then OverLimit
|
|
14
|
+
# (or silent return for `policy: :ignore`).
|
|
15
|
+
class Concurrent < Base
|
|
16
|
+
WAIT_SLEEP = 0.05
|
|
17
|
+
|
|
18
|
+
METRIC_FIELDS = %w[held held_time immediate waited wait_time overages reclaimed].freeze
|
|
19
|
+
|
|
20
|
+
def type = :concurrent
|
|
21
|
+
|
|
22
|
+
def size
|
|
23
|
+
Wurk::Limiter.redis { |c| c.call('ZCARD', state_key).to_i }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Uniform `{ used:, limit:, reset_at:, available? }` (#16) merged with
|
|
27
|
+
# the concurrent-only metric counters (§1.5) the dashboard already
|
|
28
|
+
# renders. Slots free on release rather than on a clock, so `reset_at`
|
|
29
|
+
# is the soonest in-flight slot expiry (a worst-case "available by"),
|
|
30
|
+
# or nil when idle.
|
|
31
|
+
def status
|
|
32
|
+
used = size
|
|
33
|
+
build_status(used: used, limit: @options[:limit], reset_at: soonest_expiry)
|
|
34
|
+
.merge(metrics)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def within_limit(&block)
|
|
38
|
+
raise ArgumentError, 'block required' unless block
|
|
39
|
+
|
|
40
|
+
started = monotime
|
|
41
|
+
deadline = started + @options[:wait_timeout]
|
|
42
|
+
slot = random_id
|
|
43
|
+
acquired_at = nil
|
|
44
|
+
loop do
|
|
45
|
+
result = acquire(slot)
|
|
46
|
+
if result[0].to_i == 1
|
|
47
|
+
acquired_at = monotime
|
|
48
|
+
break
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
return if @options[:policy] == :ignore
|
|
52
|
+
|
|
53
|
+
remaining = deadline - monotime
|
|
54
|
+
if remaining <= 0
|
|
55
|
+
bump_counter('overages')
|
|
56
|
+
raise OverLimit, self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sleep [remaining, WAIT_SLEEP].min
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
incr_immediate_or_waited(acquired_at - started)
|
|
64
|
+
block.call
|
|
65
|
+
ensure
|
|
66
|
+
release(slot)
|
|
67
|
+
bump_counter('held_time', (monotime - acquired_at).to_i) if acquired_at
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
protected
|
|
72
|
+
|
|
73
|
+
def state_keys
|
|
74
|
+
[state_key, stats_key]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def metrics
|
|
80
|
+
h = Wurk::Limiter.redis { |c| c.call('HGETALL', stats_key) }
|
|
81
|
+
# redis-client returns either a flat array or a hash depending on
|
|
82
|
+
# the server version — normalize once so callers always see a hash.
|
|
83
|
+
h = h.each_slice(2).to_h if h.is_a?(Array)
|
|
84
|
+
METRIC_FIELDS.to_h { |k| [k, (h[k] || '0').to_i] }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Lowest slot expiry epoch (the next slot to free), or nil when empty.
|
|
88
|
+
def soonest_expiry
|
|
89
|
+
row = Wurk::Limiter.redis { |c| c.call('ZRANGE', state_key, 0, 0, 'WITHSCORES') }
|
|
90
|
+
row && !row.empty? ? row[1].to_f : nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def state_key
|
|
94
|
+
"lmtr-cs:#{@name}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stats_key
|
|
98
|
+
"lmtr-stats:#{@name}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def acquire(slot)
|
|
102
|
+
lua(:limiter_concurrent_acquire,
|
|
103
|
+
keys: [state_key, stats_key],
|
|
104
|
+
argv: [@options[:limit], @options[:lock_timeout], slot, ttl])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def release(slot)
|
|
108
|
+
lua(:limiter_concurrent_release, keys: [state_key], argv: [slot])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def bump_counter(field, by = 1)
|
|
112
|
+
Wurk::Limiter.redis do |c|
|
|
113
|
+
c.call('HINCRBY', stats_key, field, by)
|
|
114
|
+
c.call('EXPIRE', stats_key, ttl)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def incr_immediate_or_waited(elapsed)
|
|
119
|
+
if elapsed < WAIT_SLEEP
|
|
120
|
+
bump_counter('immediate')
|
|
121
|
+
else
|
|
122
|
+
bump_counter('waited')
|
|
123
|
+
bump_counter('wait_time', elapsed.to_i)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def monotime
|
|
128
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
module Limiter
|
|
7
|
+
# Leaky bucket: drain rate = bucket_size / drain ops/sec. Stored as a
|
|
8
|
+
# HASH of {level, last} — Lua compares level vs bucket_size after
|
|
9
|
+
# leaking elapsed * drain_per_sec.
|
|
10
|
+
class Leaky < Base
|
|
11
|
+
WAIT_SLEEP = 0.05
|
|
12
|
+
|
|
13
|
+
def type = :leaky
|
|
14
|
+
|
|
15
|
+
def size
|
|
16
|
+
h = Wurk::Limiter.redis { |c| c.call('HGET', state_key, 'level') }
|
|
17
|
+
h.to_f
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# used = current fill level; limit = bucket_size; reset_at = when the
|
|
21
|
+
# steady drip frees a slot (only meaningful while full), else nil (#16).
|
|
22
|
+
def status
|
|
23
|
+
level = size
|
|
24
|
+
cap = @options[:bucket_size]
|
|
25
|
+
reset_at = level >= cap ? ::Time.now.to_f + ((level - cap + 1) / drain_per_sec) : nil
|
|
26
|
+
build_status(used: level, limit: cap, reset_at: reset_at)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def within_limit(&block)
|
|
30
|
+
raise ArgumentError, 'block required' unless block
|
|
31
|
+
|
|
32
|
+
deadline = ::Time.now.to_f + @options[:wait_timeout]
|
|
33
|
+
loop do
|
|
34
|
+
ok, _level = acquire
|
|
35
|
+
return block.call if ok.to_i == 1
|
|
36
|
+
|
|
37
|
+
remaining = deadline - ::Time.now.to_f
|
|
38
|
+
raise OverLimit, self if remaining <= 0
|
|
39
|
+
|
|
40
|
+
sleep [remaining, WAIT_SLEEP].min
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
protected
|
|
45
|
+
|
|
46
|
+
def state_keys
|
|
47
|
+
[state_key]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def state_key
|
|
53
|
+
"lmtr-l:#{@name}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def drain_interval_seconds
|
|
57
|
+
case @options[:drain]
|
|
58
|
+
when Symbol
|
|
59
|
+
Limiter.interval_seconds(@options[:drain], allow_integer: false)
|
|
60
|
+
when Integer
|
|
61
|
+
raise ArgumentError, "drain must be > 0 (got #{@options[:drain]})" unless @options[:drain].positive?
|
|
62
|
+
|
|
63
|
+
@options[:drain]
|
|
64
|
+
else
|
|
65
|
+
raise ArgumentError, "drain must be Symbol or Integer (got #{@options[:drain].class})"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# bucket_size / drain → ops per second. A zero/negative bucket_size would
|
|
70
|
+
# silently produce a non-positive rate (and a 1/0 in `status` reset_at);
|
|
71
|
+
# fail fast at the boundary so callers see the bad config, not a NaN.
|
|
72
|
+
def drain_per_sec
|
|
73
|
+
@drain_per_sec ||= begin
|
|
74
|
+
rate = @options[:bucket_size].to_f / drain_interval_seconds
|
|
75
|
+
unless rate.positive?
|
|
76
|
+
raise ArgumentError,
|
|
77
|
+
"drain_per_sec must be > 0 (bucket_size=#{@options[:bucket_size]}, drain=#{@options[:drain]})"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
rate
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def acquire
|
|
85
|
+
lua(:limiter_leaky_acquire,
|
|
86
|
+
keys: [state_key],
|
|
87
|
+
argv: [@options[:bucket_size], drain_per_sec, ttl])
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|