wurk 0.0.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -0
- data/app/controllers/wurk/api/serializers.rb +48 -2
- data/app/controllers/wurk/api_controller.rb +216 -1
- data/app/controllers/wurk/dashboard_controller.rb +20 -2
- data/app/controllers/wurk/extensions_controller.rb +56 -0
- data/app/controllers/wurk/profiles_controller.rb +68 -0
- data/config/routes.rb +54 -1
- data/exe/sidekiqswarm +8 -0
- data/exe/wurkswarm +23 -0
- data/lib/active_job/queue_adapters/wurk_adapter.rb +35 -0
- data/lib/generators/wurk/install/templates/wurk.rb +14 -3
- data/lib/sidekiq/api.rb +4 -0
- data/lib/sidekiq/cli.rb +9 -0
- data/lib/sidekiq/client.rb +4 -0
- data/lib/sidekiq/job.rb +4 -0
- data/lib/sidekiq/launcher.rb +4 -0
- data/lib/sidekiq/middleware/chain.rb +4 -0
- data/lib/sidekiq/middleware/server/statsd.rb +12 -0
- data/lib/sidekiq/rails.rb +10 -0
- data/lib/sidekiq/redis_connection.rb +4 -0
- data/lib/sidekiq/scheduled.rb +4 -0
- data/lib/sidekiq/testing.rb +4 -0
- data/lib/sidekiq/version.rb +4 -0
- data/lib/sidekiq/web.rb +4 -0
- data/lib/sidekiq/worker.rb +4 -0
- data/lib/sidekiq.rb +16 -0
- data/lib/wurk/batch/callbacks.rb +103 -13
- data/lib/wurk/batch/death_handler.rb +5 -2
- data/lib/wurk/batch/server_middleware.rb +35 -3
- data/lib/wurk/batch/status.rb +9 -0
- data/lib/wurk/batch.rb +23 -1
- data/lib/wurk/capsule.rb +20 -1
- data/lib/wurk/cli.rb +84 -1
- data/lib/wurk/client.rb +20 -17
- data/lib/wurk/compat.rb +44 -2
- data/lib/wurk/component.rb +5 -4
- data/lib/wurk/configuration.rb +120 -3
- data/lib/wurk/cron.rb +51 -9
- data/lib/wurk/dead_set.rb +8 -3
- data/lib/wurk/deploy.rb +8 -4
- data/lib/wurk/encryption.rb +6 -1
- data/lib/wurk/fetcher/reaper.rb +78 -11
- data/lib/wurk/fetcher/reliable.rb +14 -4
- data/lib/wurk/heartbeat.rb +45 -0
- data/lib/wurk/history.rb +174 -0
- data/lib/wurk/iterable_job/active_record_enumerator.rb +71 -0
- data/lib/wurk/iterable_job/csv_enumerator.rb +51 -0
- data/lib/wurk/iterable_job.rb +41 -0
- data/lib/wurk/iterable_job_query.rb +75 -0
- data/lib/wurk/job.rb +8 -0
- data/lib/wurk/job_record.rb +16 -1
- data/lib/wurk/job_set.rb +4 -4
- data/lib/wurk/job_util.rb +15 -6
- data/lib/wurk/keys.rb +10 -0
- data/lib/wurk/launcher.rb +35 -1
- data/lib/wurk/leader.rb +15 -6
- data/lib/wurk/limiter/bucket.rb +14 -3
- data/lib/wurk/limiter/concurrent.rb +1 -1
- data/lib/wurk/limiter/window.rb +2 -1
- data/lib/wurk/limiter.rb +12 -0
- data/lib/wurk/lua/loader.rb +10 -0
- data/lib/wurk/lua.rb +106 -14
- data/lib/wurk/metrics/history.rb +5 -0
- data/lib/wurk/metrics/query.rb +39 -0
- data/lib/wurk/metrics/queue_rollup.rb +151 -0
- data/lib/wurk/metrics/statsd.rb +11 -0
- data/lib/wurk/middleware/current_attributes.rb +29 -6
- data/lib/wurk/middleware/interrupt_handler.rb +5 -0
- data/lib/wurk/middleware/poison_pill.rb +35 -5
- data/lib/wurk/processor.rb +17 -8
- data/lib/wurk/profile_set.rb +65 -0
- data/lib/wurk/profiler.rb +127 -0
- data/lib/wurk/railtie.rb +19 -5
- data/lib/wurk/redis_client_adapter.rb +72 -0
- data/lib/wurk/redis_connection.rb +30 -0
- data/lib/wurk/redis_pool.rb +5 -1
- data/lib/wurk/scheduled.rb +42 -0
- data/lib/wurk/sorted_entry.rb +13 -11
- data/lib/wurk/stats.rb +11 -4
- data/lib/wurk/swarm/child_boot.rb +26 -4
- data/lib/wurk/swarm.rb +1 -1
- data/lib/wurk/transaction_aware_client.rb +69 -0
- data/lib/wurk/unique.rb +49 -7
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/batch_status.rb +42 -0
- data/lib/wurk/web/config.rb +219 -17
- data/lib/wurk/web/enterprise.rb +14 -0
- data/lib/wurk/web/extension.rb +348 -0
- data/lib/wurk/web/rack_app.rb +77 -0
- data/lib/wurk/web.rb +2 -0
- data/lib/wurk/worker/setter.rb +5 -1
- data/lib/wurk/worker.rb +17 -6
- data/lib/wurk.rb +44 -0
- data/vendor/assets/dashboard/assets/fa-brands-400-BP5tdqmh.woff2 +0 -0
- data/vendor/assets/dashboard/assets/fa-regular-400-nyy7hhHF.woff2 +0 -0
- data/vendor/assets/dashboard/assets/fa-solid-900-DRAAbZTg.woff2 +0 -0
- data/vendor/assets/dashboard/assets/index-9CFRWpfG.js +77 -0
- data/vendor/assets/dashboard/assets/index-CW8AFQIv.css +2 -0
- data/vendor/assets/dashboard/assets/wurk-logo-Vy3xW4K0.png +0 -0
- data/vendor/assets/dashboard/favicon.png +0 -0
- data/vendor/assets/dashboard/index.html +10 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +42 -3
- data/vendor/assets/dashboard/assets/index-D2XR0iGw.js +0 -60
- data/vendor/assets/dashboard/assets/index-DlPr4YXw.css +0 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'redis_pool'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
# Sidekiq-compatible pool constructor. `Sidekiq::RedisConnection.create(...)`
|
|
7
|
+
# is the documented way to build a standalone Redis pool (spec §26) — it's the
|
|
8
|
+
# default pool for `Sidekiq::Deploy` (§23) and the constructor for per-shard
|
|
9
|
+
# pools in multi-shard web mounts (Pro §10.2). Returns a `Wurk::RedisPool`
|
|
10
|
+
# (connection_pool-backed, redis-client adapter), which is `.with`-compatible
|
|
11
|
+
# with everything that expects a Sidekiq pool.
|
|
12
|
+
#
|
|
13
|
+
# Accepts Sidekiq's option keys (`url`, `size`, `pool_timeout`, `name`),
|
|
14
|
+
# string- or symbol-keyed. Anything omitted falls back to RedisPool's defaults
|
|
15
|
+
# (URL = ENV["REDIS_URL"] or redis://localhost:6379/0).
|
|
16
|
+
module RedisConnection
|
|
17
|
+
# Housekeeping/standalone default; per-capsule pools size to concurrency.
|
|
18
|
+
DEFAULT_POOL_SIZE = 10
|
|
19
|
+
|
|
20
|
+
def self.create(options = {})
|
|
21
|
+
opts = options.transform_keys(&:to_sym)
|
|
22
|
+
RedisPool.new(
|
|
23
|
+
size: opts[:size] || DEFAULT_POOL_SIZE,
|
|
24
|
+
url: opts[:url] || RedisPool::DEFAULT_URL,
|
|
25
|
+
timeout: opts[:pool_timeout] || RedisPool::DEFAULT_TIMEOUT,
|
|
26
|
+
name: opts[:name] || RedisPool::DEFAULT_NAME
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/wurk/redis_pool.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'redis-client'
|
|
4
4
|
require 'connection_pool'
|
|
5
|
+
require_relative 'redis_client_adapter'
|
|
5
6
|
|
|
6
7
|
module Wurk
|
|
7
8
|
# Per-process pool over redis-client + connection_pool. Never share a socket
|
|
@@ -56,8 +57,11 @@ module Wurk
|
|
|
56
57
|
|
|
57
58
|
private
|
|
58
59
|
|
|
60
|
+
# Wrapped in the CompatClient decorator so `Sidekiq.redis { |c| c.smembers }`
|
|
61
|
+
# method-style commands work like Sidekiq 7+ (#204). Wurk's own code paths
|
|
62
|
+
# use #call, which the decorator forwards.
|
|
59
63
|
def build_client
|
|
60
|
-
RedisClient.config(url: @url, timeout: @timeout).new_client
|
|
64
|
+
RedisClientAdapter::CompatClient.new(RedisClient.config(url: @url, timeout: @timeout).new_client)
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def safe_close(conn)
|
data/lib/wurk/scheduled.rb
CHANGED
|
@@ -67,6 +67,48 @@ module Wurk
|
|
|
67
67
|
end
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# Reliable variant of Enq (Pro §4). The default Enq pops then pushes —
|
|
71
|
+
# a crash between the ZPOPBYSCORE and the client push loses the job.
|
|
72
|
+
# ReliableEnq instead promotes every due job from each set onto its target
|
|
73
|
+
# queue in a single atomic Lua (ZRANGEBYSCORE → LPUSH queue:<q> → ZREM),
|
|
74
|
+
# so there is no window where a job exists in neither place. Swapped in by
|
|
75
|
+
# `config.reliable_scheduler!` via the `scheduled_enq` seam.
|
|
76
|
+
#
|
|
77
|
+
# Spec: docs/target/sidekiq-pro.md §4.
|
|
78
|
+
class ReliableEnq
|
|
79
|
+
include Component
|
|
80
|
+
|
|
81
|
+
def initialize(container)
|
|
82
|
+
@config = container
|
|
83
|
+
@done = false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def enqueue_jobs(sorted_sets = SETS)
|
|
87
|
+
@config.redis do |conn|
|
|
88
|
+
sorted_sets.each { |sset| promote(conn, sset) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def terminate
|
|
93
|
+
@done = true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def promote(conn, sset)
|
|
99
|
+
Wurk::Lua::Loader.eval_cached(
|
|
100
|
+
conn,
|
|
101
|
+
:reliable_schedule_promote,
|
|
102
|
+
keys: [sset, Keys::QUEUES_SET],
|
|
103
|
+
argv: [real_time.to_s, Keys::QUEUE_PREFIX]
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def real_time
|
|
108
|
+
::Process.clock_gettime(::Process::CLOCK_REALTIME)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
70
112
|
# Single thread that wakes on a randomized interval, drains both ZSETs,
|
|
71
113
|
# then sleeps again. Random spread prevents the cluster from dogpiling
|
|
72
114
|
# Redis at the top of each cadence.
|
data/lib/wurk/sorted_entry.rb
CHANGED
|
@@ -42,35 +42,37 @@ module Wurk
|
|
|
42
42
|
# ZINCRBY to shift the score; positive deltas reschedule into the future.
|
|
43
43
|
# Sidekiq passes the absolute target time; we compute the delta here so
|
|
44
44
|
# the call survives clock skew between caller and Redis.
|
|
45
|
-
def reschedule(at)
|
|
45
|
+
def reschedule(at)
|
|
46
46
|
Wurk.redis { |conn| conn.call('ZINCRBY', @parent.name, at.to_f - @score, value) }
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# Removes this entry
|
|
50
|
-
#
|
|
51
|
-
#
|
|
49
|
+
# Removes this entry and re-enqueues it via the client with the payload
|
|
50
|
+
# untouched. Backs the scheduled/dead "add to queue" actions — Sidekiq's
|
|
51
|
+
# add_to_queue does not touch `retry_count`.
|
|
52
52
|
def add_to_queue
|
|
53
53
|
remove_job do |message|
|
|
54
|
-
message['retry_count'] = message['retry_count'].to_i - 1 if message['retry_count']
|
|
55
54
|
Client.new.push(message)
|
|
56
55
|
end
|
|
57
56
|
end
|
|
58
57
|
|
|
59
|
-
# Same flow
|
|
60
|
-
#
|
|
61
|
-
# the
|
|
58
|
+
# Same flow but decrements `retry_count` first: the count was already
|
|
59
|
+
# incremented when the job entered the retry set, and the next failure
|
|
60
|
+
# bumps it again — without the decrement a manual "Retry now" would
|
|
61
|
+
# consume an attempt. Wire-compat with Sidekiq's SortedEntry#retry.
|
|
62
62
|
def retry
|
|
63
63
|
remove_job do |message|
|
|
64
|
+
message['retry_count'] = message['retry_count'].to_i - 1 if message['retry_count']
|
|
64
65
|
Client.new.push(message)
|
|
65
66
|
end
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
# Removes this entry from its parent set and writes it to the dead set.
|
|
69
|
-
#
|
|
70
|
-
#
|
|
70
|
+
# Death handlers fire with the synthesized "Job killed by API" exception
|
|
71
|
+
# (Sidekiq's default) so error trackers and the batch death path observe
|
|
72
|
+
# API/UI kills.
|
|
71
73
|
def kill
|
|
72
74
|
remove_job do |message|
|
|
73
|
-
DeadSet.new.kill(Wurk.dump_json(message)
|
|
75
|
+
DeadSet.new.kill(Wurk.dump_json(message))
|
|
74
76
|
end
|
|
75
77
|
end
|
|
76
78
|
|
data/lib/wurk/stats.rb
CHANGED
|
@@ -49,7 +49,11 @@ module Wurk
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
# @return [Hash{String=>Integer}] queue name → LLEN.
|
|
52
|
+
# @return [Hash{String=>Integer}] queue name → LLEN, largest queue first.
|
|
53
|
+
# `SMEMBERS` order is arbitrary; Sidekiq sorts the name/size pairs by size
|
|
54
|
+
# descending before building the hash (Stats#queues, spec §19.1) — gems
|
|
55
|
+
# and dashboards reading this rely on that order. Match it exactly with
|
|
56
|
+
# `sort_by { |_, size| -size }`.
|
|
53
57
|
def queues
|
|
54
58
|
Wurk.redis do |conn|
|
|
55
59
|
names = conn.call('SMEMBERS', Keys::QUEUES_SET)
|
|
@@ -58,11 +62,14 @@ module Wurk
|
|
|
58
62
|
sizes = conn.pipelined do |pipe|
|
|
59
63
|
names.each { |q| pipe.call('LLEN', Keys.queue(q)) }
|
|
60
64
|
end
|
|
61
|
-
names.zip(sizes.map(&:to_i)).to_h
|
|
65
|
+
names.zip(sizes.map(&:to_i)).sort_by { |_, size| -size }.to_h
|
|
62
66
|
end
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
# @return [Array<QueueSummary>] one per known queue.
|
|
69
|
+
# @return [Array<QueueSummary>] one per known queue, largest first.
|
|
70
|
+
# Same size-descending order as Sidekiq's detailed queue list
|
|
71
|
+
# (`queue_summaries.sort_by { |qd| -qd.size }`) — this feeds the
|
|
72
|
+
# dashboard's queue table (api_controller#queues).
|
|
66
73
|
def queue_summaries
|
|
67
74
|
Wurk.redis do |conn|
|
|
68
75
|
names = conn.call('SMEMBERS', Keys::QUEUES_SET)
|
|
@@ -75,7 +82,7 @@ module Wurk
|
|
|
75
82
|
pipe.call('LRANGE', Keys.queue(q), -1, -1)
|
|
76
83
|
end
|
|
77
84
|
end
|
|
78
|
-
build_summaries(names, results, paused_set)
|
|
85
|
+
build_summaries(names, results, paused_set).sort_by { |qd| -qd.size }
|
|
79
86
|
end
|
|
80
87
|
end
|
|
81
88
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../component'
|
|
3
4
|
require_relative '../launcher'
|
|
4
5
|
require_relative '../fetcher/reliable'
|
|
5
6
|
|
|
@@ -16,6 +17,8 @@ module Wurk
|
|
|
16
17
|
# Kept separate from Wurk::Swarm so the parent supervisor stays
|
|
17
18
|
# focused on PID supervision (SRP).
|
|
18
19
|
class ChildBoot
|
|
20
|
+
include Component
|
|
21
|
+
|
|
19
22
|
CHILD_SIGNALS = { 'TERM' => :term, 'INT' => :term, 'TSTP' => :tstp, 'USR2' => :usr2 }.freeze
|
|
20
23
|
|
|
21
24
|
def initialize(config, slot, index)
|
|
@@ -29,11 +32,20 @@ module Wurk
|
|
|
29
32
|
reset_inherited_signals
|
|
30
33
|
reconnect_after_fork
|
|
31
34
|
Wurk.server = true
|
|
35
|
+
# :fork runs in each child after our internal AR/Redis reconnect and
|
|
36
|
+
# before fetching, so apps can reopen sockets / restart threads /
|
|
37
|
+
# reconnect non-fork-safe libs (Ent §7.4). It never fires in the parent
|
|
38
|
+
# (which only forks + supervises). Like :startup, the child's forked
|
|
39
|
+
# copy of the bucket is cleared after dispatch, so siblings fire theirs.
|
|
40
|
+
fire_event(:fork)
|
|
32
41
|
apply_slot_to_config
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
# :startup must fire in each worker child before its managers spin up
|
|
43
|
+
# (Sidekiq contract, reraise: true). The parent supervisor never runs
|
|
44
|
+
# jobs, so the non-swarm CLI path fires it once per process — for the
|
|
45
|
+
# swarm, each child fires it here. Its own forked copy of the bucket is
|
|
46
|
+
# cleared after, so siblings still fire their own.
|
|
47
|
+
fire_event(:startup, reraise: true)
|
|
48
|
+
run_launcher
|
|
37
49
|
exit 0
|
|
38
50
|
rescue StandardError, ::Wurk::Shutdown => e
|
|
39
51
|
@config.logger.error { "swarm child ##{@index} (#{::Process.pid}) crashed: #{e.class}: #{e.message}" }
|
|
@@ -42,6 +54,16 @@ module Wurk
|
|
|
42
54
|
|
|
43
55
|
private
|
|
44
56
|
|
|
57
|
+
# Boot the launcher and block until shutdown. wait_loop joins the
|
|
58
|
+
# signal-dispatch thread, so the child can't fall through to `exit 0`
|
|
59
|
+
# mid-drain.
|
|
60
|
+
def run_launcher
|
|
61
|
+
launcher = Wurk::Launcher.new(@config)
|
|
62
|
+
install_signal_handlers(launcher)
|
|
63
|
+
launcher.run
|
|
64
|
+
wait_loop(launcher)
|
|
65
|
+
end
|
|
66
|
+
|
|
45
67
|
# Parent installed traps for TERM/INT/TSTP/CONT/USR1 — the child
|
|
46
68
|
# needs its own behavior, not the parent's.
|
|
47
69
|
def reset_inherited_signals
|
data/lib/wurk/swarm.rb
CHANGED
|
@@ -36,7 +36,7 @@ module Wurk
|
|
|
36
36
|
|
|
37
37
|
attr_reader :topology, :children
|
|
38
38
|
|
|
39
|
-
def initialize(topology:, config: Wurk.configuration, memory_limit:
|
|
39
|
+
def initialize(topology:, config: Wurk.configuration, memory_limit: config.memory_limit_kb,
|
|
40
40
|
shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT)
|
|
41
41
|
@topology = topology
|
|
42
42
|
@config = config
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative 'client'
|
|
5
|
+
require_relative 'job_util'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
# Opt-in enqueue client that holds each `push` until the surrounding
|
|
9
|
+
# ActiveRecord transaction commits, so a job never references a row a
|
|
10
|
+
# rollback erased — the canonical "enqueue after commit" pattern.
|
|
11
|
+
#
|
|
12
|
+
# Enable globally with `Wurk.transactional_push!` (aliased
|
|
13
|
+
# `Sidekiq.transactional_push!`), which sets `default_job_options
|
|
14
|
+
# ["client_class"]`; the worker DSL then builds this client instead of the
|
|
15
|
+
# plain `Wurk::Client`.
|
|
16
|
+
#
|
|
17
|
+
# The jid is pre-allocated and returned synchronously so the caller can
|
|
18
|
+
# reference it inside the transaction; the actual Redis write runs in an
|
|
19
|
+
# after-commit hook. `push_bulk` is intentionally NOT deferred — it matches
|
|
20
|
+
# Sidekiq, whose batching/scheduling machinery can't ride the commit hook.
|
|
21
|
+
#
|
|
22
|
+
# Spec: docs/target/sidekiq-free.md §8.
|
|
23
|
+
class TransactionAwareClient
|
|
24
|
+
def initialize(pool: nil, config: nil)
|
|
25
|
+
@redis_client = Wurk::Client.new(pool: pool, config: config)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# True inside a `Batch#jobs` block. The batch counts jobs at push time, so
|
|
29
|
+
# deferring would desync its totals — push immediately instead.
|
|
30
|
+
def batching?
|
|
31
|
+
!Thread.current[Wurk::Batch::THREAD_KEY].nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [String] the pre-allocated jid (returned before the deferred push runs).
|
|
35
|
+
def push(item)
|
|
36
|
+
item['jid'] ||= SecureRandom.hex(12)
|
|
37
|
+
|
|
38
|
+
if batching?
|
|
39
|
+
@redis_client.push(item)
|
|
40
|
+
else
|
|
41
|
+
register_after_commit { @redis_client.push(item) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
item['jid']
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Bulk enqueue is never transactional (Sidekiq parity) — straight to Redis.
|
|
48
|
+
def push_bulk(items)
|
|
49
|
+
@redis_client.push_bulk(items)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Routes the push to the host's after-commit hook. AR 7.2+ exposes
|
|
55
|
+
# `ActiveRecord.after_all_transactions_commit`, which runs the block now
|
|
56
|
+
# when there's no open transaction and after commit when there is — so the
|
|
57
|
+
# "not in a transaction" and "ActiveRecord absent" cases both degrade to an
|
|
58
|
+
# immediate push, exactly the graceful no-op the spec calls for.
|
|
59
|
+
def register_after_commit(&)
|
|
60
|
+
if defined?(::ActiveRecord) && ::ActiveRecord.respond_to?(:after_all_transactions_commit)
|
|
61
|
+
::ActiveRecord.after_all_transactions_commit(&)
|
|
62
|
+
elsif defined?(::AfterCommitEverywhere)
|
|
63
|
+
::AfterCommitEverywhere.after_commit(&)
|
|
64
|
+
else
|
|
65
|
+
yield
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/wurk/unique.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'digest'
|
|
5
5
|
require_relative 'middleware'
|
|
6
|
+
require_relative 'lua'
|
|
6
7
|
|
|
7
8
|
module Wurk
|
|
8
9
|
# Sidekiq Enterprise unique jobs. Best-effort dedup at enqueue time keyed
|
|
@@ -16,6 +17,10 @@ module Wurk
|
|
|
16
17
|
# *before* invoking perform; a duplicate can be enqueued while the
|
|
17
18
|
# first is running.
|
|
18
19
|
#
|
|
20
|
+
# A job that dies *automatically* (retries exhausted / discarded) releases
|
|
21
|
+
# its lock via a death handler; manual UI kills keep the lock until TTL
|
|
22
|
+
# expiry (Ent wiki, Ent-Unique-Jobs).
|
|
23
|
+
#
|
|
19
24
|
# Wire-compat (§3.9): single-key Redis layout — `unique:<sha256>` STRING
|
|
20
25
|
# holding the owning JID. Scheduled jobs extend the TTL by the delay so
|
|
21
26
|
# the lock covers the entire wait+execution window (§3.4).
|
|
@@ -26,6 +31,31 @@ module Wurk
|
|
|
26
31
|
DEFAULT_UNTIL = :success
|
|
27
32
|
VALID_UNTIL = %i[success start].freeze
|
|
28
33
|
|
|
34
|
+
# Ent parity: a job that dies automatically releases its lock so a
|
|
35
|
+
# duplicate can enqueue immediately. Manual API/UI kills keep the lock
|
|
36
|
+
# until TTL expiry (Ent wiki, Ent-Unique-Jobs) — they reach death
|
|
37
|
+
# handlers too (Sidekiq fires them on API kills), so we recognize the
|
|
38
|
+
# synthesized kill exception and skip the release for it. Atomic
|
|
39
|
+
# CAS-DEL via the shared Lua script mirrors ServerMiddleware#release.
|
|
40
|
+
DEATH_HANDLER = lambda do |job, exception|
|
|
41
|
+
next unless Wurk::Unique.enabled?
|
|
42
|
+
next unless Wurk::Unique.coerce_ttl(job['unique_for'])
|
|
43
|
+
next if exception.instance_of?(::RuntimeError) && exception.message == DeadSet::API_KILL_MESSAGE
|
|
44
|
+
|
|
45
|
+
Wurk.redis { |conn| Wurk::Unique.release_if_owner(conn, Wurk::Unique.lock_key_for(job), job['jid']) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Atomic compare-and-delete of a unique lock key. Two-command
|
|
49
|
+
# GET-then-DEL is not a real CAS — the key can expire between the GET
|
|
50
|
+
# and DEL, letting a fresh enqueue grab it, and the bare DEL would
|
|
51
|
+
# then drop the new owner's lock. Routed through a single Lua script
|
|
52
|
+
# (`Wurk::Lua::RELEASE_IF_OWNER`) shared by `ServerMiddleware#release`
|
|
53
|
+
# (normal success/start release) and `DEATH_HANDLER` (automatic-death
|
|
54
|
+
# release) so the two paths cannot drift.
|
|
55
|
+
def self.release_if_owner(conn, key, jid)
|
|
56
|
+
Wurk::Lua::Loader.eval_cached(conn, :release_if_owner, keys: [key], argv: [jid])
|
|
57
|
+
end
|
|
58
|
+
|
|
29
59
|
# `Sidekiq::Enterprise.unique!` flips this on. The middleware pair is
|
|
30
60
|
# always loaded (so worker `sidekiq_options unique_for:` is a no-op
|
|
31
61
|
# without `unique!`) — only when the flag is set does the client
|
|
@@ -91,6 +121,8 @@ module Wurk
|
|
|
91
121
|
unless Wurk.configuration.client_middleware.exists?(ClientMiddleware)
|
|
92
122
|
Wurk.configuration.server_middleware.add(ServerMiddleware) \
|
|
93
123
|
unless Wurk.configuration.server_middleware.exists?(ServerMiddleware)
|
|
124
|
+
handlers = Wurk.configuration.death_handlers
|
|
125
|
+
handlers << DEATH_HANDLER unless handlers.include?(DEATH_HANDLER)
|
|
94
126
|
end
|
|
95
127
|
end
|
|
96
128
|
|
|
@@ -172,7 +204,17 @@ module Wurk
|
|
|
172
204
|
pool.with do |conn|
|
|
173
205
|
return yield if conn.call('SET', key, job['jid'], 'NX', 'EX', ttl) == 'OK'
|
|
174
206
|
|
|
175
|
-
|
|
207
|
+
holder = conn.call('GET', key)
|
|
208
|
+
# The job's own jid holding the lock is a re-push, not a duplicate:
|
|
209
|
+
# scheduled/retry promotion re-runs this chain while the
|
|
210
|
+
# enqueue-time lock is still live (§3.4/§3.7) — dropping here would
|
|
211
|
+
# silently lose the job.
|
|
212
|
+
return yield if holder == job['jid']
|
|
213
|
+
# nil holder: the lock expired between the failed SET NX and the
|
|
214
|
+
# GET — re-acquire instead of dropping.
|
|
215
|
+
return yield if holder.nil? && conn.call('SET', key, job['jid'], 'NX', 'EX', ttl) == 'OK'
|
|
216
|
+
|
|
217
|
+
log_duplicate(job, holder)
|
|
176
218
|
end
|
|
177
219
|
nil
|
|
178
220
|
end
|
|
@@ -225,13 +267,13 @@ module Wurk
|
|
|
225
267
|
VALID_UNTIL.include?(sym) ? sym : DEFAULT_UNTIL
|
|
226
268
|
end
|
|
227
269
|
|
|
228
|
-
# CAS
|
|
229
|
-
# Prevents a long-overdue retry from releasing a fresh lock
|
|
230
|
-
# a re-enqueued duplicate after the original TTL expired.
|
|
270
|
+
# Atomic CAS-DEL: only drop the key if the owning JID still matches
|
|
271
|
+
# ours. Prevents a long-overdue retry from releasing a fresh lock
|
|
272
|
+
# held by a re-enqueued duplicate after the original TTL expired.
|
|
273
|
+
# Shares the Lua script with `DEATH_HANDLER` so both release paths
|
|
274
|
+
# have identical semantics.
|
|
231
275
|
def release(key, jid)
|
|
232
|
-
redis_pool.with
|
|
233
|
-
conn.call('DEL', key) if conn.call('GET', key) == jid
|
|
234
|
-
end
|
|
276
|
+
redis_pool.with { |conn| Wurk::Unique.release_if_owner(conn, key, jid) }
|
|
235
277
|
rescue StandardError => e
|
|
236
278
|
Wurk.logger&.warn { "Wurk::Unique release failed: #{e.class}: #{e.message}" }
|
|
237
279
|
end
|
data/lib/wurk/version.rb
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../batch/status'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
class Web
|
|
7
|
+
# Lightweight Rack middleware for batch-progress polling without mounting
|
|
8
|
+
# the full dashboard. Drop into a rack stack and app JS can drive progress
|
|
9
|
+
# bars off plain JSON:
|
|
10
|
+
#
|
|
11
|
+
# # config.ru
|
|
12
|
+
# use Sidekiq::Pro::BatchStatus
|
|
13
|
+
# run Rails.application
|
|
14
|
+
#
|
|
15
|
+
# `GET /batch_status/<bid>.json` → `Wurk::Batch::Status#data` JSON
|
|
16
|
+
# (bid, total, pending, failures, complete, created_at, description, …).
|
|
17
|
+
# Unknown/expired bid → 404. Every other request passes straight through.
|
|
18
|
+
#
|
|
19
|
+
# Spec: docs/target/sidekiq-pro.md §10.3.
|
|
20
|
+
class BatchStatus
|
|
21
|
+
ROUTE = %r{\A/batch_status/(?<bid>[^/]+)\.json\z}
|
|
22
|
+
|
|
23
|
+
def initialize(app)
|
|
24
|
+
@app = app
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(env)
|
|
28
|
+
match = env['REQUEST_METHOD'] == 'GET' && ROUTE.match(env['PATH_INFO'])
|
|
29
|
+
return @app.call(env) unless match
|
|
30
|
+
|
|
31
|
+
status = Wurk::Batch::Status.new(match[:bid])
|
|
32
|
+
status.exists? ? json(200, status.data) : json(404, { 'error' => 'not_found' })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def json(code, payload)
|
|
38
|
+
[code, { 'content-type' => 'application/json' }, [Wurk.dump_json(payload)]]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|