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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../component'
4
+ require_relative 'history'
5
+
6
+ module Wurk
7
+ module Metrics
8
+ # Leader-only background thread that rolls the per-class minute buckets
9
+ # written by Wurk::Metrics::History (`j|YYMMDD|H:M`) up into compact,
10
+ # cluster-total time-series buckets the dashboard "throughput" / "failures"
11
+ # charts read directly:
12
+ #
13
+ # jr|1m|<epoch> HASH {p,f,ms} TTL 24h (1-minute resolution)
14
+ # jr|5m|<epoch> HASH {p,f,ms} TTL 7d (5-minute resolution)
15
+ # jr|1h|<epoch> HASH {p,f,ms} TTL 30d (1-hour resolution)
16
+ #
17
+ # `<epoch>` is the UTC start-of-bucket as integer seconds. Totals are summed
18
+ # across every job class, so a 30-day chart reads ~720 small hashes instead
19
+ # of fanning out over ~43k per-class minute keys.
20
+ #
21
+ # Every write is an idempotent HSET. Each tick recomputes the trailing few
22
+ # 1m buckets from the source minute hash, then recomputes the coarse buckets
23
+ # from their 1m children. Re-running a tick (a missed tick, a leadership
24
+ # change, a late metric write) converges to the same totals — it never
25
+ # double-counts. Storage is bounded purely by the per-bucket TTLs; see
26
+ # docs/metrics-history.md for the retention math.
27
+ class Rollup
28
+ include Component
29
+
30
+ PREFIX = 'jr'
31
+
32
+ # bucket => [step_seconds, ttl_seconds]. The retention is the issue's
33
+ # spec: 1m kept 24h, 5m kept 7d, 1h kept 30d.
34
+ BUCKETS = {
35
+ '1m' => [60, 24 * 60 * 60],
36
+ '5m' => [300, 7 * 24 * 60 * 60],
37
+ '1h' => [3600, 30 * 24 * 60 * 60]
38
+ }.freeze
39
+
40
+ COARSE = %w[5m 1h].freeze
41
+
42
+ DEFAULT_TICK_SECONDS = 60
43
+ # Re-roll the last N completed minutes from source on every tick
44
+ # (idempotent). This self-heals a leadership failover / restart or a late
45
+ # metric write up to N minutes old — the source `j|…` buckets live 3 days,
46
+ # so re-reading them folds the gap back in. Only outages longer than this
47
+ # leave a hole that ages out with the bucket TTL (best-effort metrics).
48
+ LOOKBACK_MINUTES = 15
49
+
50
+ def self.bucket_key(bucket, epoch)
51
+ "#{PREFIX}|#{bucket}|#{epoch}"
52
+ end
53
+
54
+ def initialize(config)
55
+ @config = config
56
+ @done = false
57
+ @mutex = ::Mutex.new
58
+ @sleeper = ::ConditionVariable.new
59
+ @tick_interval = config[:metrics_rollup_interval] || DEFAULT_TICK_SECONDS
60
+ @thread = nil
61
+ end
62
+
63
+ def start
64
+ @thread ||= safe_thread('metrics-rollup') do # rubocop:disable Naming/MemoizedInstanceVariableName
65
+ wait
66
+ until @done
67
+ tick
68
+ wait
69
+ end
70
+ end
71
+ end
72
+
73
+ def terminate
74
+ @mutex.synchronize do
75
+ @done = true
76
+ @sleeper.signal
77
+ end
78
+ end
79
+
80
+ # Leader-gated: only the elected leader writes the cluster-total series,
81
+ # so N workers don't each HSET the same buckets every minute.
82
+ def tick(now: ::Time.now)
83
+ return unless leader?
84
+
85
+ roll(now)
86
+ rescue StandardError => e
87
+ handle_exception(e, { context: 'metrics-rollup' })
88
+ end
89
+
90
+ # One rollup pass, bypassing the leader gate and the sleep loop. Public so
91
+ # deterministic specs and a manual "roll now" can drive it directly.
92
+ def roll(now = ::Time.now)
93
+ cur_min = floor_min(now)
94
+ minutes = (1..LOOKBACK_MINUTES).map { |i| cur_min - (i * 60) }
95
+ minutes.each { |epoch_min| write_minute_bucket(epoch_min) }
96
+ recompute_coarse(minutes)
97
+ nil
98
+ end
99
+
100
+ private
101
+
102
+ def write_minute_bucket(epoch_min)
103
+ store('1m', epoch_min, minute_total(epoch_min))
104
+ end
105
+
106
+ # Sum every class's p/f/ms in the source minute hash into a single total.
107
+ def minute_total(epoch_min)
108
+ raw = redis { |c| c.call('HGETALL', source_minute_key(epoch_min)) }
109
+ raw = raw.each_slice(2).to_h if raw.is_a?(::Array)
110
+ total = { p: 0, f: 0, ms: 0 }
111
+ raw.each do |field, value|
112
+ _klass, kind = field.split('|', 2)
113
+ total[kind.to_sym] += value.to_i if kind && total.key?(kind.to_sym)
114
+ end
115
+ total
116
+ end
117
+
118
+ # Re-sum each coarse bucket from its 1m children, for every window the
119
+ # just-written minutes touched (covers the current window plus any
120
+ # boundary the lookback crossed).
121
+ def recompute_coarse(minutes)
122
+ COARSE.each do |bucket|
123
+ step, = BUCKETS[bucket]
124
+ minutes.map { |m| (m / step) * step }.uniq.each { |w| store(bucket, w, child_total(w, step)) }
125
+ end
126
+ end
127
+
128
+ def child_total(window_start, step)
129
+ keys = (window_start...(window_start + step)).step(60).map { |s| self.class.bucket_key('1m', s) }
130
+ sum_pfms(redis { |c| c.pipelined { |p| keys.each { |k| p.call('HMGET', k, 'p', 'f', 'ms') } } })
131
+ end
132
+
133
+ def sum_pfms(rows)
134
+ rows.each_with_object({ p: 0, f: 0, ms: 0 }) do |(p, f, ms), total|
135
+ total[:p] += p.to_i
136
+ total[:f] += f.to_i
137
+ total[:ms] += ms.to_i
138
+ end
139
+ end
140
+
141
+ # Skip empty buckets so an idle cluster doesn't litter Redis with zero
142
+ # rows; a missing bucket reads back as zero on the query side anyway.
143
+ def store(bucket, epoch, total)
144
+ return if total.values.all?(&:zero?)
145
+
146
+ _step, ttl = BUCKETS[bucket]
147
+ key = self.class.bucket_key(bucket, epoch)
148
+ redis do |c|
149
+ c.call('HSET', key, 'p', total[:p], 'f', total[:f], 'ms', total[:ms])
150
+ c.call('EXPIRE', key, ttl)
151
+ end
152
+ end
153
+
154
+ def source_minute_key(epoch_min)
155
+ Wurk::Metrics::History.minute_key(::Time.at(epoch_min).utc)
156
+ end
157
+
158
+ def floor_min(time)
159
+ (time.to_i / 60) * 60
160
+ end
161
+
162
+ def wait
163
+ @mutex.synchronize do
164
+ @sleeper.wait(@mutex, @tick_interval) unless @done
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ module Metrics
5
+ # Pro parity (§9): emits per-job timing + counters to a statsd / dogstatsd
6
+ # client. The client itself is plumbed in by the host app via:
7
+ #
8
+ # Wurk.configure_server do |config|
9
+ # config.dogstatsd = -> { Datadog::Statsd.new('metrics.example.com', 8125) }
10
+ # config.server_middleware { |chain| chain.add Wurk::Metrics::Statsd }
11
+ # end
12
+ #
13
+ # The `dogstatsd` accessor is a *callable* — invoked once per process,
14
+ # memoized — so the client is built lazily AFTER fork. Sharing a UDP
15
+ # socket across forks is fine, but `Datadog::Statsd` keeps thread-locals
16
+ # that must be initialized inside the child.
17
+ #
18
+ # Per-job tuning via `Statsd.options = ->(klass, job, queue) { {tags:, sample_rate:} }`.
19
+ # Default options: tags `["worker:<klass>", "queue:<q>"]`, sample_rate 1.0.
20
+ # The `dd_rate` job option, when present, overrides sample_rate.
21
+ #
22
+ # Metric naming follows Sidekiq Pro 8+: every metric prefixed `sidekiq.`
23
+ # (the prefix is hardcoded, not configurable — third-party dashboards
24
+ # built for Sidekiq Pro work unchanged).
25
+ #
26
+ # `Statsd.increment(metric, tags:)` is the class-level fast path used by
27
+ # other Wurk components (Buffered client, Expiry middleware, super_fetch
28
+ # recovery, Batch lifecycle). No-op when no client is configured so
29
+ # callers never have to guard.
30
+ #
31
+ # Spec: docs/target/sidekiq-pro.md §9.
32
+ class Statsd
33
+ include Wurk::Middleware::ServerMiddleware
34
+
35
+ METRIC_PREFIX = 'sidekiq.'
36
+ DEFAULT_SAMPLE_RATE = 1.0
37
+
38
+ class << self
39
+ attr_accessor :options
40
+
41
+ # Counter shortcut used across the codebase. Tags are forwarded as
42
+ # given — caller's job to namespace them (`"class:Foo"`, `"queue:bar"`).
43
+ # No-op when no client is wired up.
44
+ def increment(metric, tags: nil, sample_rate: DEFAULT_SAMPLE_RATE)
45
+ client = self.client
46
+ return nil unless client
47
+
48
+ opts = sample_rate_kw(sample_rate)
49
+ opts[:tags] = tags if tags
50
+ client.increment("#{METRIC_PREFIX}#{metric}", **opts)
51
+ nil
52
+ rescue StandardError => e
53
+ handle_error(e)
54
+ nil
55
+ end
56
+
57
+ def gauge(metric, value, tags: nil, sample_rate: DEFAULT_SAMPLE_RATE)
58
+ client = self.client
59
+ return nil unless client
60
+
61
+ opts = sample_rate_kw(sample_rate)
62
+ opts[:tags] = tags if tags
63
+ client.gauge("#{METRIC_PREFIX}#{metric}", value, **opts)
64
+ nil
65
+ rescue StandardError => e
66
+ handle_error(e)
67
+ nil
68
+ end
69
+
70
+ # Distribution send. Some statsd clients lack `distribution` (vanilla
71
+ # statsd-ruby, for example) — fall back to `histogram` so the metric
72
+ # still lands somewhere. `dogstatsd-ruby` always has `distribution`.
73
+ def distribution(metric, value, tags: nil, sample_rate: DEFAULT_SAMPLE_RATE)
74
+ client = self.client
75
+ return nil unless client
76
+
77
+ opts = sample_rate_kw(sample_rate)
78
+ opts[:tags] = tags if tags
79
+ name = "#{METRIC_PREFIX}#{metric}"
80
+ if client.respond_to?(:distribution)
81
+ client.distribution(name, value, **opts)
82
+ elsif client.respond_to?(:histogram)
83
+ client.histogram(name, value, **opts)
84
+ end
85
+ nil
86
+ rescue StandardError => e
87
+ handle_error(e)
88
+ nil
89
+ end
90
+
91
+ # Resolves the live client: invokes the configured `dogstatsd` proc
92
+ # exactly once per process and memoizes. Returns nil when no proc
93
+ # is configured, so callers get a clean no-op without raising.
94
+ def client
95
+ return @client if defined?(@client) && !@client.nil?
96
+
97
+ builder = Wurk.configuration.respond_to?(:dogstatsd) ? Wurk.configuration.dogstatsd : nil
98
+ return nil if builder.nil?
99
+
100
+ @client = builder.respond_to?(:call) ? builder.call : builder
101
+ end
102
+
103
+ # Test/lifecycle hook. Reset between specs and after fork so the
104
+ # parent's socket doesn't bleed into children.
105
+ def reset!
106
+ @client = nil
107
+ end
108
+
109
+ private
110
+
111
+ def sample_rate_kw(rate)
112
+ rate == DEFAULT_SAMPLE_RATE ? {} : { sample_rate: rate }
113
+ end
114
+
115
+ def handle_error(err)
116
+ Wurk.configuration.handle_exception(err, context: 'Wurk::Metrics::Statsd')
117
+ end
118
+ end
119
+
120
+ def call(_worker, job, queue) # rubocop:disable Metrics/AbcSize
121
+ client = safe_client
122
+ return yield if client.nil?
123
+
124
+ klass = job['class']
125
+ opts = per_job_options(klass, job, queue)
126
+ tags = opts[:tags]
127
+ rate = opts.fetch(:sample_rate, DEFAULT_SAMPLE_RATE)
128
+
129
+ emit(:increment, 'jobs.count', tags: tags, sample_rate: rate)
130
+ started = monotonic_ms
131
+ success = false
132
+ begin
133
+ yield
134
+ success = true
135
+ ensure
136
+ duration = monotonic_ms - started
137
+ # Metrics are best-effort: an emit failure mid-finalize must not
138
+ # corrupt the job result the caller already produced.
139
+ begin
140
+ finalize(success, duration, tags: tags, sample_rate: rate)
141
+ rescue StandardError => e
142
+ self.class.send(:handle_error, e)
143
+ end
144
+ end
145
+ end
146
+
147
+ private
148
+
149
+ # Wraps `self.class.client` so a misconfigured builder proc never turns
150
+ # a metrics-init failure into a job failure. Returns nil on error (the
151
+ # caller falls through to a plain `yield`).
152
+ def safe_client
153
+ self.class.client
154
+ rescue StandardError => e
155
+ self.class.send(:handle_error, e)
156
+ nil
157
+ end
158
+
159
+ # Per-spec §9.2: caller-supplied proc may override tags / sample_rate
160
+ # on a per-job basis. The job's own `dd_rate` field, when present,
161
+ # always wins — it's the per-push override hinted in §8.
162
+ def per_job_options(klass, job, queue)
163
+ base = { tags: default_tags(klass, queue), sample_rate: DEFAULT_SAMPLE_RATE }
164
+ proc = self.class.options
165
+ if proc.respond_to?(:call)
166
+ custom = proc.call(klass, job, queue)
167
+ base = base.merge(custom) if custom.is_a?(Hash)
168
+ end
169
+ base[:sample_rate] = job['dd_rate'].to_f if job.key?('dd_rate')
170
+ base
171
+ end
172
+
173
+ def default_tags(klass, queue)
174
+ ["worker:#{klass}", "queue:#{queue}"]
175
+ end
176
+
177
+ def finalize(success, duration, tags:, sample_rate:)
178
+ metric = success ? 'jobs.success' : 'jobs.failure'
179
+ emit(:increment, metric, tags: tags, sample_rate: sample_rate)
180
+ emit(:gauge, 'jobs.perform', duration, tags: tags, sample_rate: sample_rate)
181
+ emit(:distribution, 'jobs.perform_dist', duration, tags: tags, sample_rate: sample_rate)
182
+ end
183
+
184
+ def emit(kind, metric, value = nil, tags:, sample_rate:)
185
+ case kind
186
+ when :increment then self.class.increment(metric, tags: tags, sample_rate: sample_rate)
187
+ when :gauge then self.class.gauge(metric, value, tags: tags, sample_rate: sample_rate)
188
+ when :distribution then self.class.distribution(metric, value, tags: tags, sample_rate: sample_rate)
189
+ end
190
+ end
191
+
192
+ def monotonic_ms
193
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :float_millisecond)
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ # Metrics surface. Statsd export (Pro) + historical time-series in Redis (Ent).
5
+ module Metrics
6
+ end
7
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ module Middleware
5
+ # Ordered list of middleware bound to a config/capsule. Sidekiq contract:
6
+ # `add` appends (removing any existing entry for the same klass), `prepend`
7
+ # pushes to index 0, `insert_before`/`insert_after` anchor relative to an
8
+ # existing entry, `invoke` walks the chain handing the inner block to each.
9
+ #
10
+ # `retrieve` instantiates a fresh instance per entry on every job — entries
11
+ # store klass + ctor args, not the live object. This keeps middleware
12
+ # thread-safe by construction and matches Sidekiq's lifecycle.
13
+ #
14
+ # Spec: docs/target/sidekiq-free.md §10.1.
15
+ class Chain
16
+ include Enumerable
17
+
18
+ attr_reader :entries
19
+ attr_accessor :config
20
+
21
+ def initialize(config = nil)
22
+ @config = config
23
+ @entries = []
24
+ yield self if block_given?
25
+ end
26
+
27
+ def each(&) = @entries.each(&)
28
+
29
+ # Bind a duplicate of this chain to `capsule`. Capsules share the parent
30
+ # config's entries by reference initially but mutate independently after
31
+ # the first `add`/`remove`/etc. (Array#dup on `@entries`).
32
+ def copy_for(capsule)
33
+ copy = dup
34
+ copy.instance_variable_set(:@config, capsule)
35
+ copy
36
+ end
37
+
38
+ def remove(klass)
39
+ @entries.delete_if { |entry| entry.klass == klass }
40
+ end
41
+
42
+ def add(klass, *)
43
+ remove(klass)
44
+ @entries << Entry.new(klass, *)
45
+ end
46
+
47
+ def prepend(klass, *)
48
+ remove(klass)
49
+ @entries.unshift(Entry.new(klass, *))
50
+ end
51
+
52
+ def insert_before(oldklass, newklass, *)
53
+ i = @entries.index { |entry| entry.klass == newklass }
54
+ new_entry = i.nil? ? Entry.new(newklass, *) : @entries.delete_at(i)
55
+ i = @entries.index { |entry| entry.klass == oldklass } || 0
56
+ @entries.insert(i, new_entry)
57
+ end
58
+
59
+ def insert_after(oldklass, newklass, *)
60
+ i = @entries.index { |entry| entry.klass == newklass }
61
+ new_entry = i.nil? ? Entry.new(newklass, *) : @entries.delete_at(i)
62
+ i = @entries.index { |entry| entry.klass == oldklass } || (@entries.size - 1)
63
+ @entries.insert(i + 1, new_entry)
64
+ end
65
+
66
+ def exists?(klass)
67
+ any? { |entry| entry.klass == klass }
68
+ end
69
+ alias include? exists?
70
+
71
+ def empty?
72
+ @entries.empty?
73
+ end
74
+
75
+ def retrieve
76
+ map { |entry| entry.make_new(@config) }
77
+ end
78
+
79
+ def clear
80
+ @entries.clear
81
+ end
82
+
83
+ # Walks the chain inside-out. Each middleware receives a block that
84
+ # advances to the next; the innermost block is the caller's `&block`,
85
+ # whose return value is propagated back out. Empty chain: `yield`.
86
+ def invoke(*args, &block)
87
+ raise ArgumentError, 'middleware chain requires a block' unless block
88
+ return yield if @entries.empty?
89
+
90
+ chain = retrieve
91
+ traverse = lambda do
92
+ if chain.empty?
93
+ block.call
94
+ else
95
+ chain.shift.call(*args, &traverse)
96
+ end
97
+ end
98
+ traverse.call
99
+ end
100
+
101
+ private
102
+
103
+ # Custom dup semantics: deep-copy the entries array so a child chain's
104
+ # mutations don't bleed into the parent (or sibling capsules).
105
+ def initialize_copy(orig)
106
+ super
107
+ @entries = orig.entries.dup
108
+ end
109
+
110
+ # Holds the klass + ctor args for a registered middleware. Defers
111
+ # instantiation until `retrieve` runs so each job gets a fresh object.
112
+ class Entry
113
+ attr_reader :klass
114
+
115
+ def initialize(klass, *args)
116
+ @klass = klass
117
+ @args = args
118
+ end
119
+
120
+ def make_new(config = nil)
121
+ instance = @klass.new(*@args)
122
+ instance.config = config if config && instance.respond_to?(:config=)
123
+ instance
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../middleware'
4
+
5
+ module Wurk
6
+ module Middleware
7
+ # Propagates `ActiveSupport::CurrentAttributes` from the enqueueing process
8
+ # into the worker. Off by default — host opts in by calling
9
+ # `Wurk::Middleware::CurrentAttributes.persist(klass_or_array)`.
10
+ #
11
+ # One registered class → job hash key `"cattr"`.
12
+ # Multiple → `"cattr"`, `"cattr_1"`, `"cattr_2"`, … (keys mirror Sidekiq's
13
+ # naming exactly: wire-compat sacred).
14
+ #
15
+ # Spec: docs/target/sidekiq-free.md §10.3 and §2.2.
16
+ module CurrentAttributes
17
+ PERSISTENT_KEY = 'cattr'
18
+
19
+ class << self
20
+ # Register one or more CurrentAttributes classes. Re-registering is a
21
+ # no-op: `add` already dedupes by klass, so calling `persist` twice
22
+ # with the same set replaces the old entry with the new args.
23
+ def persist(klass_or_array, config = Wurk.configuration)
24
+ classes = Array(klass_or_array)
25
+ raise ArgumentError, 'persist requires at least one CurrentAttributes class' if classes.empty?
26
+
27
+ config.client_middleware.add(Save, classes)
28
+ config.server_middleware.add(Load, classes)
29
+ end
30
+
31
+ # Composes the wire key for the Nth registered class. Sidekiq numbers
32
+ # from 1 ("cattr_1"); index 0 keeps the bare "cattr" key.
33
+ def key_for(index)
34
+ index.zero? ? PERSISTENT_KEY : "#{PERSISTENT_KEY}_#{index}"
35
+ end
36
+
37
+ # AS::CurrentAttributes#attributes returns a HashWithIndifferentAccess;
38
+ # we coerce to a plain Hash so JSON encoding is predictable.
39
+ def snapshot(klass)
40
+ klass.attributes.to_h
41
+ end
42
+
43
+ def restore(klass, attrs)
44
+ attrs&.each { |name, value| klass.public_send("#{name}=", value) }
45
+ end
46
+ end
47
+
48
+ # Client-side: snapshot each registered CurrentAttributes class into
49
+ # the job hash. Caller-supplied keys take precedence (`||=`).
50
+ class Save
51
+ include Wurk::Middleware::ClientMiddleware
52
+
53
+ def initialize(classes)
54
+ @classes = classes
55
+ end
56
+
57
+ def call(_job_class, job, _queue, _redis_pool)
58
+ @classes.each_with_index do |klass, idx|
59
+ key = CurrentAttributes.key_for(idx)
60
+ job[key] ||= CurrentAttributes.snapshot(klass)
61
+ end
62
+ yield
63
+ end
64
+ end
65
+
66
+ # Restores each registered CurrentAttributes class for the duration
67
+ # of the inner block, then resets so the next job in the thread
68
+ # starts clean. Reset runs in `ensure` to survive raises and Skip.
69
+ class Load
70
+ include Wurk::Middleware::ServerMiddleware
71
+
72
+ def initialize(classes)
73
+ @classes = classes
74
+ end
75
+
76
+ def call(_job_or_class, job, _queue)
77
+ @classes.each_with_index do |klass, idx|
78
+ CurrentAttributes.restore(klass, job[CurrentAttributes.key_for(idx)])
79
+ end
80
+ yield
81
+ ensure
82
+ @classes.each(&:reset)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../middleware'
4
+ require_relative '../metrics/statsd'
5
+ require_relative '../processor'
6
+
7
+ module Wurk
8
+ module Middleware
9
+ # Server middleware. Drops jobs whose `expiry` timestamp (stamped at push
10
+ # by the client from `sidekiq_options expires_in:`) has passed before
11
+ # `perform` gets a chance to start. Once `perform` is invoked, expiry no
12
+ # longer preempts — long-running jobs that started in time finish.
13
+ #
14
+ # The skip path:
15
+ # * bumps Wurk::Processor::EXPIRED so the heartbeat flushes
16
+ # `stat:expired` + `stat:expired:YYYY-MM-DD` to Redis, surfacing the
17
+ # count in Wurk::Stats and the dashboard
18
+ # * emits `jobs.expired` via Wurk::Metrics::Statsd (no-op when no client
19
+ # is configured)
20
+ # * returns without yielding — no exception, so JobRetry treats it as a
21
+ # clean exit and the processor acks the UoW
22
+ # * counts as a batch success: because this middleware is registered
23
+ # AFTER `Wurk::Batch::ServerMiddleware`, returning unwinds back through
24
+ # batch's `yield`, and batch's `ack_success` still runs on the way out
25
+ #
26
+ # The expired job is *also* counted toward PROCESSED — Processor#stats's
27
+ # ensure block always increments PROCESSED, so EXPIRED is an additive
28
+ # subset (executed = processed - failed - expired). Matches Sidekiq Pro.
29
+ #
30
+ # Spec: docs/target/sidekiq-pro.md §7.
31
+ class Expiry
32
+ include Wurk::Middleware::ServerMiddleware
33
+
34
+ def call(_job_instance, job, _queue)
35
+ expiry = job['expiry']
36
+ return yield unless expiry
37
+
38
+ if ::Time.now.to_f > expiry.to_f
39
+ Wurk::Processor::EXPIRED.incr
40
+ Wurk::Metrics::Statsd.increment('jobs.expired', tags: ["class:#{job['class']}"])
41
+ return
42
+ end
43
+
44
+ yield
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ Wurk.configuration.server_middleware.add(Wurk::Middleware::Expiry)