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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/CONTRIBUTING.md +73 -0
  4. data/LICENSE +21 -0
  5. data/README.md +137 -0
  6. data/SECURITY.md +39 -0
  7. data/app/controllers/wurk/api/pagination.rb +67 -0
  8. data/app/controllers/wurk/api/serializers.rb +131 -0
  9. data/app/controllers/wurk/api_controller.rb +248 -0
  10. data/app/controllers/wurk/application_controller.rb +7 -0
  11. data/app/controllers/wurk/dashboard_controller.rb +48 -0
  12. data/config/locales/en.yml +15 -0
  13. data/config/routes.rb +34 -0
  14. data/exe/wurk +22 -0
  15. data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
  16. data/lib/generators/wurk/install/install_generator.rb +22 -0
  17. data/lib/generators/wurk/install/templates/wurk.rb +16 -0
  18. data/lib/wurk/active_job/wrapper.rb +32 -0
  19. data/lib/wurk/api/fast.rb +78 -0
  20. data/lib/wurk/batch/buffer.rb +26 -0
  21. data/lib/wurk/batch/callback_job.rb +37 -0
  22. data/lib/wurk/batch/callbacks.rb +176 -0
  23. data/lib/wurk/batch/client_middleware.rb +27 -0
  24. data/lib/wurk/batch/death_handler.rb +39 -0
  25. data/lib/wurk/batch/empty.rb +21 -0
  26. data/lib/wurk/batch/server_middleware.rb +62 -0
  27. data/lib/wurk/batch/status.rb +140 -0
  28. data/lib/wurk/batch.rb +351 -0
  29. data/lib/wurk/batch_set.rb +67 -0
  30. data/lib/wurk/capsule.rb +176 -0
  31. data/lib/wurk/cli.rb +349 -0
  32. data/lib/wurk/client/buffered.rb +372 -0
  33. data/lib/wurk/client.rb +330 -0
  34. data/lib/wurk/compat.rb +136 -0
  35. data/lib/wurk/component.rb +136 -0
  36. data/lib/wurk/configuration.rb +373 -0
  37. data/lib/wurk/context.rb +35 -0
  38. data/lib/wurk/cron.rb +636 -0
  39. data/lib/wurk/dashboard_manifest.rb +39 -0
  40. data/lib/wurk/dead_set.rb +78 -0
  41. data/lib/wurk/deploy.rb +91 -0
  42. data/lib/wurk/embedded.rb +94 -0
  43. data/lib/wurk/encryption.rb +276 -0
  44. data/lib/wurk/engine.rb +81 -0
  45. data/lib/wurk/fetcher/reaper.rb +264 -0
  46. data/lib/wurk/fetcher/reliable.rb +138 -0
  47. data/lib/wurk/fetcher.rb +11 -0
  48. data/lib/wurk/health.rb +193 -0
  49. data/lib/wurk/heartbeat.rb +211 -0
  50. data/lib/wurk/iterable_job.rb +292 -0
  51. data/lib/wurk/job/options.rb +70 -0
  52. data/lib/wurk/job.rb +33 -0
  53. data/lib/wurk/job_logger.rb +68 -0
  54. data/lib/wurk/job_record.rb +156 -0
  55. data/lib/wurk/job_retry.rb +320 -0
  56. data/lib/wurk/job_set.rb +212 -0
  57. data/lib/wurk/job_util.rb +162 -0
  58. data/lib/wurk/keys.rb +52 -0
  59. data/lib/wurk/launcher.rb +289 -0
  60. data/lib/wurk/leader.rb +221 -0
  61. data/lib/wurk/limiter/base.rb +138 -0
  62. data/lib/wurk/limiter/bucket.rb +80 -0
  63. data/lib/wurk/limiter/concurrent.rb +132 -0
  64. data/lib/wurk/limiter/leaky.rb +91 -0
  65. data/lib/wurk/limiter/points.rb +89 -0
  66. data/lib/wurk/limiter/server_middleware.rb +77 -0
  67. data/lib/wurk/limiter/unlimited.rb +48 -0
  68. data/lib/wurk/limiter/window.rb +80 -0
  69. data/lib/wurk/limiter.rb +255 -0
  70. data/lib/wurk/logger.rb +81 -0
  71. data/lib/wurk/lua/loader.rb +53 -0
  72. data/lib/wurk/lua.rb +187 -0
  73. data/lib/wurk/manager.rb +132 -0
  74. data/lib/wurk/metrics/history.rb +151 -0
  75. data/lib/wurk/metrics/query.rb +173 -0
  76. data/lib/wurk/metrics/rollup.rb +169 -0
  77. data/lib/wurk/metrics/statsd.rb +197 -0
  78. data/lib/wurk/metrics.rb +7 -0
  79. data/lib/wurk/middleware/chain.rb +128 -0
  80. data/lib/wurk/middleware/current_attributes.rb +87 -0
  81. data/lib/wurk/middleware/expiry.rb +50 -0
  82. data/lib/wurk/middleware/i18n.rb +63 -0
  83. data/lib/wurk/middleware/interrupt_handler.rb +45 -0
  84. data/lib/wurk/middleware/poison_pill.rb +149 -0
  85. data/lib/wurk/middleware.rb +34 -0
  86. data/lib/wurk/process_set.rb +243 -0
  87. data/lib/wurk/processor.rb +247 -0
  88. data/lib/wurk/queue.rb +108 -0
  89. data/lib/wurk/queues.rb +80 -0
  90. data/lib/wurk/rails.rb +9 -0
  91. data/lib/wurk/railtie.rb +28 -0
  92. data/lib/wurk/redis_pool.rb +79 -0
  93. data/lib/wurk/retry_set.rb +17 -0
  94. data/lib/wurk/scheduled.rb +189 -0
  95. data/lib/wurk/scheduled_set.rb +18 -0
  96. data/lib/wurk/sorted_entry.rb +95 -0
  97. data/lib/wurk/stats.rb +190 -0
  98. data/lib/wurk/swarm/child_boot.rb +105 -0
  99. data/lib/wurk/swarm.rb +260 -0
  100. data/lib/wurk/testing.rb +102 -0
  101. data/lib/wurk/topology.rb +74 -0
  102. data/lib/wurk/unique.rb +240 -0
  103. data/lib/wurk/version.rb +5 -0
  104. data/lib/wurk/web/config.rb +180 -0
  105. data/lib/wurk/web/enterprise.rb +138 -0
  106. data/lib/wurk/web/search.rb +139 -0
  107. data/lib/wurk/web.rb +25 -0
  108. data/lib/wurk/work_set.rb +116 -0
  109. data/lib/wurk/worker/setter.rb +93 -0
  110. data/lib/wurk/worker.rb +216 -0
  111. data/lib/wurk.rb +238 -0
  112. data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
  113. data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
  114. data/vendor/assets/dashboard/index.html +13 -0
  115. data/vendor/assets/dashboard/wurk-manifest.json +4 -0
  116. metadata +232 -0
@@ -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