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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'component'
4
+ require_relative 'keys'
5
+ require_relative 'processor'
6
+
7
+ module Wurk
8
+ # Single-purpose owner of the Redis-side process heartbeat. Lifted out of
9
+ # Wurk::Launcher so the launcher can stay focused on lifecycle and so the
10
+ # heartbeat schema lives in one place readers can grep for.
11
+ #
12
+ # Each beat is one pipelined round-trip:
13
+ # SADD processes <identity>
14
+ # HSET <identity> info concurrency busy beat quiet rss rtt_us
15
+ # EXPIRE <identity> 60
16
+ # UNLINK <identity>:work
17
+ # HSET <identity>:work <tid> <json> ... (only if WORK_STATE non-empty)
18
+ # EXPIRE <identity>:work 60 (only if WORK_STATE non-empty)
19
+ # LPOP <identity>-signals × BEAT_PAUSE
20
+ #
21
+ # The work hash is UNLINK-then-rewritten on every beat — a dropped beat
22
+ # momentarily empties it, and ProcessSet#cleanup compensates by SREM-ing
23
+ # identities whose `info` field has expired.
24
+ #
25
+ # Heartbeat owns only the wire. Signals queued by the dashboard are pulled
26
+ # out of the pipelined results and returned to the caller for dispatch —
27
+ # TSTP/TERM semantics live in Launcher.
28
+ #
29
+ # `:heartbeat` fires once on the first successful beat and again after any
30
+ # network partition (rearmed when beat! rescues). `:beat` fires every tick.
31
+ #
32
+ # Spec: docs/target/sidekiq-free.md §12 (Launcher#❤️).
33
+ class Heartbeat
34
+ include Component
35
+
36
+ # Cadence in seconds. Key TTL is 60s — a process is dead after ~6 misses.
37
+ BEAT_PAUSE = 10
38
+ TTL_SECONDS = 60
39
+
40
+ attr_reader :identity, :rtt_us, :last_beat_at
41
+
42
+ # `quiet:` is a callable so Launcher can keep ownership of its `@done`
43
+ # flag without an awkward setter contract. `info_overrides:` lets the
44
+ # caller (Launcher#embedded, tests) inject fields without forcing
45
+ # Heartbeat to know about every flag the host process tracks.
46
+ def initialize(identity:, config:, started_at: nil, embedded: false, quiet: nil)
47
+ @identity = identity
48
+ @config = config
49
+ @started_at = started_at || Time.now.to_f
50
+ @embedded = embedded
51
+ @quiet_proc = quiet || -> { false }
52
+ @first_heartbeat = true
53
+ @rtt_us = 0
54
+ end
55
+
56
+ # Pipelined beat. Returns the drained signals as Array<String> on
57
+ # success, or nil if Redis raised — the next successful beat refires
58
+ # `:heartbeat` so partition recovery is observable.
59
+ def beat!
60
+ sigs, @rtt_us = pipelined_beat
61
+ @last_beat_at = Time.now.to_f
62
+ if @first_heartbeat
63
+ @first_heartbeat = false
64
+ fire_event(:heartbeat)
65
+ end
66
+ fire_event(:beat, oneshot: false)
67
+ emit_statsd_gauges
68
+ sigs.compact
69
+ rescue StandardError => e
70
+ @first_heartbeat = true
71
+ handle_exception(e, { context: 'heartbeat' })
72
+ nil
73
+ end
74
+
75
+ # Best-effort teardown. SREM removes us from the live list; UNLINK
76
+ # drops the identity hash and its work mirror. Idempotent: if Redis
77
+ # is down the next process boot's ProcessSet#cleanup prunes us.
78
+ def stop!
79
+ redis do |conn|
80
+ conn.pipelined do |pipe|
81
+ pipe.call('SREM', Keys::PROCESSES, @identity)
82
+ pipe.call('UNLINK', @identity, "#{@identity}:work")
83
+ end
84
+ end
85
+ rescue StandardError
86
+ nil
87
+ end
88
+
89
+ private
90
+
91
+ # Two extra writes (HSET + EXPIRE for the work mirror) when WORK_STATE
92
+ # has entries, so `lead` shifts to skip them when slicing signals out
93
+ # of the pipeline result.
94
+ def pipelined_beat
95
+ work_snapshot = Processor::WORK_STATE.dup
96
+ lead = 4 + (work_snapshot.empty? ? 0 : 2)
97
+ t0 = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :microsecond)
98
+ results = redis { |conn| conn.pipelined { |pipe| write_beat(pipe, work_snapshot) } }
99
+ rtt = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :microsecond) - t0
100
+ [results[lead, BEAT_PAUSE] || [], rtt]
101
+ end
102
+
103
+ def write_beat(pipe, work_snapshot)
104
+ pipe.call('SADD', Keys::PROCESSES, @identity)
105
+ pipe.call('HSET', @identity, *beat_hash_args(work_snapshot.size))
106
+ pipe.call('EXPIRE', @identity, TTL_SECONDS)
107
+ write_work_hash(pipe, work_snapshot)
108
+ drain_signals(pipe)
109
+ end
110
+
111
+ def beat_hash_args(busy)
112
+ [
113
+ 'info', Wurk.dump_json(info_hash),
114
+ 'concurrency', total_concurrency.to_s,
115
+ 'busy', busy.to_s,
116
+ 'beat', Time.now.to_f.to_s,
117
+ 'quiet', @quiet_proc.call.to_s,
118
+ 'rss', memory_usage_kb.to_s,
119
+ 'rtt_us', @rtt_us.to_s
120
+ ]
121
+ end
122
+
123
+ def write_work_hash(pipe, work_snapshot)
124
+ work_key = "#{@identity}:work"
125
+ pipe.call('UNLINK', work_key)
126
+ return if work_snapshot.empty?
127
+
128
+ args = work_snapshot.flat_map { |tid, hash| [tid.to_s, Wurk.dump_json(hash)] }
129
+ pipe.call('HSET', work_key, *args)
130
+ pipe.call('EXPIRE', work_key, TTL_SECONDS)
131
+ end
132
+
133
+ # LPOP one entry per second of cadence so a flood of queued signals
134
+ # can't stall the beat; anything older drains on the next beat.
135
+ def drain_signals(pipe)
136
+ BEAT_PAUSE.times { pipe.call('LPOP', "#{@identity}-signals") }
137
+ end
138
+
139
+ def info_hash
140
+ caps = @config.capsules.each_value
141
+ {
142
+ 'hostname' => hostname,
143
+ 'started_at' => @started_at,
144
+ 'pid' => ::Process.pid,
145
+ 'tag' => @config[:tag] || default_tag,
146
+ 'concurrency' => total_concurrency,
147
+ 'capsules' => capsules_info,
148
+ 'queues' => caps.flat_map(&:queues).uniq,
149
+ 'weights' => caps.map(&:weights),
150
+ 'labels' => Array(@config[:labels]),
151
+ 'identity' => @identity,
152
+ 'version' => Wurk::VERSION,
153
+ 'embedded' => @embedded
154
+ }
155
+ end
156
+
157
+ def capsules_info
158
+ @config.capsules.transform_values do |cap|
159
+ { 'concurrency' => cap.concurrency, 'mode' => cap.mode.to_s, 'weights' => cap.weights }
160
+ end
161
+ end
162
+
163
+ def total_concurrency
164
+ @config.capsules.each_value.sum(&:concurrency)
165
+ end
166
+
167
+ # Best-effort statsd snapshot per beat. Emits `sidekiq.busy` (this
168
+ # process's in-flight job count) and `sidekiq.queue.size` (per queue
169
+ # LLEN). Tagged with `process:<identity>` so multi-process emissions
170
+ # of the global `queue.size` are tag-distinguishable downstream — pick
171
+ # `max` across the process tag in your dashboard. No-op when
172
+ # `config.dogstatsd` is unset (Statsd.gauge short-circuits on nil
173
+ # client) so the LLEN pipeline is also skipped.
174
+ # Spec hint: docs/target/sidekiq-ent.md §5.2 names (`busy`, `queue.size`).
175
+ def emit_statsd_gauges
176
+ return unless Wurk::Metrics::Statsd.client
177
+
178
+ busy = Processor::WORK_STATE.size
179
+ Wurk::Metrics::Statsd.gauge('busy', busy, tags: ["process:#{@identity}"])
180
+ emit_queue_sizes
181
+ rescue StandardError => e
182
+ handle_exception(e, { context: 'heartbeat metrics' })
183
+ end
184
+
185
+ def emit_queue_sizes
186
+ queues = @config.capsules.each_value.flat_map(&:queues).uniq
187
+ return if queues.empty?
188
+
189
+ sizes = redis { |conn| conn.pipelined { |pipe| queues.each { |q| pipe.call('LLEN', "queue:#{q}") } } }
190
+ queues.zip(sizes).each do |queue, size|
191
+ Wurk::Metrics::Statsd.gauge(
192
+ 'queue.size', size,
193
+ tags: ["queue:#{queue}", "process:#{@identity}"]
194
+ )
195
+ end
196
+ end
197
+
198
+ # Linux first via /proc/self/statm[1] (resident pages × 4 KB);
199
+ # `ps` fallback for macOS/BSD test runners. Zero on failure — the
200
+ # dashboard shows "—" rather than crashing.
201
+ def memory_usage_kb
202
+ if ::File.exist?('/proc/self/statm')
203
+ ::File.read('/proc/self/statm').split[1].to_i * 4
204
+ else
205
+ `ps -o rss= -p #{::Process.pid}`.to_i
206
+ end
207
+ rescue StandardError
208
+ 0
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'job'
5
+
6
+ module Wurk
7
+ # Iterable jobs split long-running work into small, idempotent chunks.
8
+ # Override `#build_enumerator` (yielding `[item, new_cursor]` pairs) and
9
+ # `#each_iteration(item, *args)`; the framework drives the loop, persists
10
+ # the cursor, and resumes after interruption.
11
+ #
12
+ # Defining `#perform` on the including class is refused at `method_added`
13
+ # — IterableJob owns the run loop. User code overrides `#each_iteration`.
14
+ #
15
+ # State lives in the `it-<jid>` HASH (sidekiq-free.md §1.5):
16
+ #
17
+ # ex : execution count (int)
18
+ # c : cursor (JSON string)
19
+ # rt : runtime accumulated (float seconds)
20
+ # cancelled : timestamp (int) if cancelled
21
+ #
22
+ # Spec: docs/target/sidekiq-free.md §6.4.
23
+ module IterableJob # rubocop:disable Metrics/ModuleLength
24
+ # Alias to the canonical `Wurk::Job::Interrupted`. The exception lives on
25
+ # `Wurk::Job` so non-iterable code paths (manual `interrupted?` checks)
26
+ # can raise the same class; the interrupt-handler middleware rescues by
27
+ # the `Wurk::Job::Interrupted` name.
28
+ Interrupted = Wurk::Job::Interrupted
29
+
30
+ # Default expiry for an iteration state HASH while the job is running or
31
+ # awaiting resume. Refreshed on every checkpoint.
32
+ STATE_TTL = 30 * 86_400
33
+
34
+ # Cursor flush + cancellation poll cadence. Both share the timer so
35
+ # a long-running iteration that hits the 5-second mark checkpoints
36
+ # *and* checks for cross-process cancellation in the same tick.
37
+ STATE_FLUSH_INTERVAL = 5
38
+
39
+ # Shorter TTL applied once the state is marked cancelled. The HASH
40
+ # outlives `cancel!` long enough for live workers to observe the flag
41
+ # but is reaped well before the 30-day default would expire.
42
+ CANCELLATION_PERIOD = 3 * 86_400
43
+
44
+ # Class-level guard injected via singleton-class prepend so we can call
45
+ # `super` cleanly and stay compatible with anything else hooking
46
+ # `method_added`.
47
+ module MethodAddedGuard
48
+ def method_added(method_name)
49
+ if method_name == :perform
50
+ raise ArgumentError,
51
+ "#{self} is an IterableJob; override #each_iteration instead of #perform"
52
+ end
53
+ super
54
+ end
55
+ end
56
+
57
+ def self.included(base)
58
+ base.include(Wurk::Job)
59
+ base.singleton_class.prepend(MethodAddedGuard)
60
+ end
61
+
62
+ # User overrides — must return an Enumerator yielding `[item, new_cursor]`
63
+ # pairs. The cursor must round-trip through JSON.
64
+ def build_enumerator(*, cursor:)
65
+ _ = cursor
66
+ raise NotImplementedError, "#{self.class} must override #build_enumerator"
67
+ end
68
+
69
+ def each_iteration(*)
70
+ raise NotImplementedError, "#{self.class} must override #each_iteration"
71
+ end
72
+
73
+ # --- lifecycle hooks (no-op defaults; users override as needed) -----
74
+
75
+ def on_start; end
76
+ def on_resume; end
77
+ def on_stop; end
78
+ def on_cancel; end
79
+ def on_complete; end
80
+
81
+ def around_iteration
82
+ yield
83
+ end
84
+
85
+ # --- iteration state accessors --------------------------------------
86
+
87
+ attr_reader :current_object
88
+
89
+ def arguments
90
+ @arguments ||= []
91
+ end
92
+
93
+ def cursor
94
+ @cursor
95
+ end
96
+
97
+ # Mark this iteration cancelled. Sets the in-process flag immediately
98
+ # (so the next `cancelled?` check inside the run loop trips) and, when
99
+ # a jid is bound, writes the timestamp to the `it-<jid>` HASH so other
100
+ # processes observe it on their next 5-second poll.
101
+ #
102
+ # Returns the integer epoch-seconds timestamp written.
103
+ def cancel!
104
+ ts_ms = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
105
+ @cancelled_at ||= ts_ms
106
+ ts = ts_ms / 1000
107
+ persist_cancellation(ts)
108
+ ts
109
+ end
110
+
111
+ # True once `cancel!` has been called locally, OR — for cross-process
112
+ # cancellation — once the `cancelled` field appears in the `it-<jid>`
113
+ # HASH. The remote check is rate-limited to once per `STATE_FLUSH_INTERVAL`
114
+ # to keep the hot loop cheap.
115
+ def cancelled?
116
+ return true if @cancelled_at
117
+
118
+ ts = poll_remote_cancellation
119
+ return false unless ts
120
+
121
+ @cancelled_at = ts * 1000
122
+ true
123
+ end
124
+
125
+ # Redis HASH key holding iteration state for this job. Wire-compat
126
+ # with Sidekiq's `it-<jid>` schema (sidekiq-free.md §1.5).
127
+ def iteration_key
128
+ "it-#{jid}"
129
+ end
130
+
131
+ # Foundation run loop. Loads any persisted state, drives the enumerator,
132
+ # checkpoints every `STATE_FLUSH_INTERVAL`, and on interruption persists
133
+ # the final cursor before re-raising so the interrupt-handler middleware
134
+ # can re-push the job at the head of the queue.
135
+ def perform(*args)
136
+ reset_run_state(args)
137
+ load_state
138
+ fire_lifecycle_start
139
+ @executions += 1
140
+
141
+ run_iterations(args)
142
+
143
+ finalize_complete
144
+ rescue Interrupted
145
+ finalize_interrupted
146
+ raise
147
+ end
148
+
149
+ private
150
+
151
+ def reset_run_state(args)
152
+ @cancelled_at = nil
153
+ @arguments = args
154
+ @last_cancel_poll_ms = nil
155
+ @last_flush_ms = nil
156
+ @run_started_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
157
+ @executions = 0
158
+ @runtime_acc = 0.0
159
+ @cursor = nil
160
+ end
161
+
162
+ def fire_lifecycle_start
163
+ if @executions.positive?
164
+ on_resume
165
+ else
166
+ on_start
167
+ end
168
+ end
169
+
170
+ def run_iterations(args)
171
+ enum = build_enumerator(*args, cursor: @cursor)
172
+ enum.each do |item, new_cursor|
173
+ raise Interrupted if cancelled?
174
+
175
+ @current_object = item
176
+ around_iteration { each_iteration(item, *args) }
177
+ @cursor = new_cursor
178
+ maybe_flush_state
179
+ end
180
+ end
181
+
182
+ def finalize_complete
183
+ flush_state(final: true)
184
+ on_complete
185
+ delete_state
186
+ end
187
+
188
+ def finalize_interrupted
189
+ flush_state(final: true)
190
+ on_cancel if @cancelled_at
191
+ on_stop
192
+ end
193
+
194
+ # --- persistence ----------------------------------------------------
195
+
196
+ def load_state
197
+ return unless persistable?
198
+
199
+ hash = normalize_hgetall(redis_call('HGETALL', iteration_key))
200
+ return if hash.empty?
201
+
202
+ apply_loaded_state(hash)
203
+ end
204
+
205
+ def apply_loaded_state(hash)
206
+ @executions = hash['ex'].to_i if hash['ex']
207
+ @runtime_acc = hash['rt'].to_f if hash['rt']
208
+ @cursor = ::JSON.parse(hash['c']) if hash['c']
209
+ @cancelled_at = hash['cancelled'].to_i * 1000 if hash['cancelled']
210
+ end
211
+
212
+ def maybe_flush_state
213
+ return unless persistable?
214
+
215
+ now_ms = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
216
+ @last_flush_ms ||= now_ms
217
+ return if now_ms - @last_flush_ms < STATE_FLUSH_INTERVAL * 1000
218
+
219
+ flush_state
220
+ @last_flush_ms = now_ms
221
+ end
222
+
223
+ def flush_state(final: false)
224
+ return unless persistable?
225
+
226
+ runtime = @runtime_acc + (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - @run_started_at)
227
+ @runtime_acc = runtime if final
228
+ ttl = @cancelled_at ? CANCELLATION_PERIOD : STATE_TTL
229
+ redis_pool.with do |conn|
230
+ conn.pipelined do |pipe|
231
+ pipe.call('HSET', iteration_key,
232
+ 'ex', @executions.to_s,
233
+ 'c', ::JSON.generate(@cursor),
234
+ 'rt', runtime.to_s)
235
+ pipe.call('EXPIRE', iteration_key, ttl)
236
+ end
237
+ end
238
+ end
239
+
240
+ def delete_state
241
+ return unless persistable?
242
+
243
+ redis_call('DEL', iteration_key)
244
+ end
245
+
246
+ def persist_cancellation(timestamp)
247
+ return unless persistable?
248
+
249
+ redis_pool.with do |conn|
250
+ conn.pipelined do |pipe|
251
+ pipe.call('HSET', iteration_key, 'cancelled', timestamp)
252
+ pipe.call('EXPIRE', iteration_key, CANCELLATION_PERIOD)
253
+ end
254
+ end
255
+ end
256
+
257
+ def poll_remote_cancellation
258
+ return nil unless persistable?
259
+
260
+ now_ms = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
261
+ return nil if @last_cancel_poll_ms && now_ms - @last_cancel_poll_ms < STATE_FLUSH_INTERVAL * 1000
262
+
263
+ @last_cancel_poll_ms = now_ms
264
+ raw = redis_call('HGET', iteration_key, 'cancelled')
265
+ raw && !raw.to_s.empty? ? raw.to_i : nil
266
+ end
267
+
268
+ # --- helpers --------------------------------------------------------
269
+
270
+ def persistable?
271
+ !jid.nil? && !jid.to_s.empty?
272
+ end
273
+
274
+ def redis_pool
275
+ Wurk.redis_pool
276
+ end
277
+
278
+ def redis_call(*args)
279
+ redis_pool.with { |conn| conn.call(*args) }
280
+ end
281
+
282
+ # redis-client returns HGETALL as a flat array on some adapters and as
283
+ # a Hash on others. Normalize to a Hash with String keys/values either way.
284
+ def normalize_hgetall(raw)
285
+ case raw
286
+ when Hash then raw
287
+ when Array then raw.each_slice(2).to_h
288
+ else {}
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ module Job
5
+ # Options-only slice of the Wurk::Worker DSL. Mixed into `ActiveJob::Base`
6
+ # by the wurk adapter so native AJ classes can configure Wurk/Sidekiq
7
+ # features (`sidekiq_options retry: 3`, `sidekiq_retry_in { ... }`, …)
8
+ # without including the full `perform_async`/`set` surface that doesn't
9
+ # apply to AJ.
10
+ #
11
+ # Aliased as `Sidekiq::Job::Options`. Wire-compat sacred — third-party
12
+ # gems that extend AJ via this module load unchanged.
13
+ #
14
+ # Spec: docs/target/sidekiq-free.md §6 (Sidekiq::Job::Options).
15
+ module Options
16
+ def self.included(base)
17
+ base.extend(ClassMethods)
18
+ end
19
+
20
+ module ClassMethods
21
+ def sidekiq_options(opts = {})
22
+ merged = get_sidekiq_options.merge(opts.transform_keys(&:to_s))
23
+ @sidekiq_options_hash = merged
24
+ end
25
+
26
+ # Sidekiq's public API name — must stay `get_sidekiq_options`.
27
+ def get_sidekiq_options # rubocop:disable Naming/AccessorMethodName
28
+ @sidekiq_options_hash ||= inherited_sidekiq_options # rubocop:disable Naming/MemoizedInstanceVariableName
29
+ end
30
+
31
+ def sidekiq_options_hash
32
+ get_sidekiq_options
33
+ end
34
+
35
+ attr_reader :sidekiq_retry_in_block, :sidekiq_retries_exhausted_block
36
+
37
+ def sidekiq_retry_in(&block)
38
+ @sidekiq_retry_in_block = block
39
+ end
40
+
41
+ def sidekiq_retries_exhausted(&block)
42
+ @sidekiq_retries_exhausted_block = block
43
+ end
44
+
45
+ def inherited(subclass)
46
+ super
47
+ subclass.instance_variable_set(:@sidekiq_options_hash, get_sidekiq_options.dup)
48
+ inherit_ivar(subclass, :@sidekiq_retry_in_block)
49
+ inherit_ivar(subclass, :@sidekiq_retries_exhausted_block)
50
+ end
51
+
52
+ private
53
+
54
+ def inherited_sidekiq_options
55
+ if superclass.respond_to?(:get_sidekiq_options)
56
+ superclass.get_sidekiq_options.dup
57
+ else
58
+ Wurk.default_job_options.dup
59
+ end
60
+ end
61
+
62
+ def inherit_ivar(subclass, ivar)
63
+ return unless instance_variable_defined?(ivar)
64
+
65
+ subclass.instance_variable_set(ivar, instance_variable_get(ivar))
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
data/lib/wurk/job.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'worker'
4
+
5
+ module Wurk
6
+ # Sidekiq 7+ alias for Wurk::Worker. `include Wurk::Job` and
7
+ # `include Sidekiq::Job` are the same surface.
8
+ #
9
+ # Instance-level `jid`, `_context`, `interrupted?`, and `logger` come
10
+ # from Wurk::Worker. Class-level DSL (`sidekiq_options`, `perform_*`,
11
+ # `set`, retry blocks) does too — Job is a pure alias module that
12
+ # re-exposes Worker under the modern name.
13
+ module Job
14
+ # Raised mid-iteration when the run loop must yield — either a swarm
15
+ # shutdown signal or a cooperative cancellation. The interrupt-handler
16
+ # middleware catches it, re-pushes the job, and raises
17
+ # `Wurk::JobRetry::Skip` so the retry layer skips error bookkeeping.
18
+ # User code must not rescue this.
19
+ #
20
+ # Spec: docs/target/sidekiq-free.md §6.4.
21
+ class Interrupted < RuntimeError; end
22
+
23
+ def self.included(base)
24
+ base.include(Wurk::Worker)
25
+ end
26
+
27
+ # Mirror the module-level test helpers so `Sidekiq::Job.jobs /
28
+ # clear_all / drain_all` work the same as `Sidekiq::Worker.*`.
29
+ def self.jobs = Wurk::Worker.jobs
30
+ def self.clear_all = Wurk::Worker.clear_all
31
+ def self.drain_all = Wurk::Worker.drain_all
32
+ end
33
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ # Wraps the per-job execution span. Logs "start"/"done"/"fail" at INFO,
5
+ # pushes :elapsed into Wurk::Context so the logger formatter can pick it
6
+ # up, and prepares the thread-local context hash (jid, class, plus
7
+ # config[:logged_job_attributes]).
8
+ #
9
+ # Two entry points, called from Processor#process in this order:
10
+ # 1. prepare(job_hash) { ... } → sets thread-local context, applies
11
+ # per-job log_level, yields to the rest of dispatch.
12
+ # 2. call(item, queue) { ... } → wraps the actual perform with the
13
+ # start/done/fail log line trio and the elapsed-ms measurement.
14
+ #
15
+ # Skipping default logging is controlled by config[:skip_default_job_logging];
16
+ # the prepare step still runs so context/log_level still apply.
17
+ #
18
+ # Spec: docs/target/sidekiq-free.md §18.
19
+ class JobLogger
20
+ def initialize(config)
21
+ @config = config
22
+ @logger = @config.logger
23
+ @skip = !!@config[:skip_default_job_logging]
24
+ end
25
+
26
+ def call(_item, _queue)
27
+ start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
28
+ @logger.info { 'start' } unless @skip
29
+
30
+ yield
31
+
32
+ Wurk::Context.add(:elapsed, elapsed(start))
33
+ @logger.info { 'done' } unless @skip
34
+ rescue Exception # rubocop:disable Lint/RescueException
35
+ Wurk::Context.add(:elapsed, elapsed(start))
36
+ @logger.info { 'fail' } unless @skip
37
+ raise
38
+ end
39
+
40
+ # Sets thread-local context for the duration of `block`, optionally
41
+ # under a per-job log level. ActiveJob-wrapped jobs expose the real
42
+ # class via the "wrapped" key — log that, not the wrapper.
43
+ def prepare(job_hash, &block)
44
+ h = {
45
+ jid: job_hash['jid'],
46
+ class: job_hash['wrapped'] || job_hash['class']
47
+ }
48
+ @config[:logged_job_attributes].each do |attr|
49
+ h[attr.to_sym] = job_hash[attr] if job_hash.key?(attr)
50
+ end
51
+
52
+ level = job_hash['log_level']
53
+ Wurk::Context.with(h) do
54
+ if level
55
+ @logger.with_level(level, &block)
56
+ else
57
+ yield
58
+ end
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def elapsed(start)
65
+ (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start).round(3)
66
+ end
67
+ end
68
+ end