wurk 1.3.1 → 1.4.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 +1 -0
- data/lib/wurk/batch/callbacks.rb +82 -12
- data/lib/wurk/batch/death_handler.rb +7 -4
- data/lib/wurk/batch/server_middleware.rb +1 -1
- data/lib/wurk/batch.rb +121 -15
- data/lib/wurk/capsule.rb +5 -4
- data/lib/wurk/cli.rb +48 -14
- data/lib/wurk/client/buffered.rb +193 -43
- data/lib/wurk/client.rb +87 -14
- data/lib/wurk/compat.rb +1 -1
- data/lib/wurk/component.rb +2 -2
- data/lib/wurk/configuration.rb +3 -2
- data/lib/wurk/cron.rb +94 -37
- data/lib/wurk/deploy.rb +5 -3
- data/lib/wurk/embedded.rb +13 -0
- data/lib/wurk/fetcher/reaper.rb +113 -56
- data/lib/wurk/fetcher/reliable.rb +62 -9
- data/lib/wurk/heartbeat.rb +22 -10
- data/lib/wurk/history.rb +13 -1
- data/lib/wurk/launcher.rb +133 -66
- data/lib/wurk/leader.rb +29 -10
- data/lib/wurk/limiter/base.rb +8 -10
- data/lib/wurk/limiter/bucket.rb +1 -1
- data/lib/wurk/limiter/concurrent.rb +27 -22
- data/lib/wurk/limiter/window.rb +13 -11
- data/lib/wurk/limiter.rb +7 -4
- data/lib/wurk/lua.rb +97 -14
- data/lib/wurk/manager.rb +29 -13
- data/lib/wurk/metrics/history.rb +4 -3
- data/lib/wurk/metrics/queue_rollup.rb +13 -1
- data/lib/wurk/metrics/rollup.rb +13 -1
- data/lib/wurk/middleware/interrupt_handler.rb +7 -6
- data/lib/wurk/middleware/poison_pill.rb +70 -29
- data/lib/wurk/middleware.rb +2 -2
- data/lib/wurk/pool_checkout.rb +29 -0
- data/lib/wurk/process_set.rb +10 -5
- data/lib/wurk/processor.rb +6 -0
- data/lib/wurk/profiler.rb +3 -2
- data/lib/wurk/queue.rb +10 -7
- data/lib/wurk/rails_boot.rb +38 -7
- data/lib/wurk/redis_client_adapter.rb +48 -4
- data/lib/wurk/redis_pool.rb +70 -25
- data/lib/wurk/scheduled.rb +30 -2
- data/lib/wurk/stats.rb +14 -9
- data/lib/wurk/swarm/child_boot.rb +12 -0
- data/lib/wurk/swarm.rb +174 -33
- data/lib/wurk/timer_loop.rb +14 -0
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/enterprise.rb +58 -6
- data/lib/wurk/web/extension.rb +1 -1
- data/lib/wurk/web/search.rb +5 -3
- data/lib/wurk.rb +10 -2
- data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-OmC5NPgw.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-CIpai7St.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-A_kwSR6Q.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-BG7HTqlp.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dashboard-B9rOrkzk.js → Dashboard-A_ToqHoo.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-8J21jMyK.js} +1 -1
- data/vendor/assets/dashboard/assets/Extension-B4Q9FIQu.js +1 -0
- data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-Fh_Ae7UW.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-Ceng0PMB.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-CruDWvNZ.js} +1 -1
- data/vendor/assets/dashboard/assets/{Metrics-BBTDxcaE.js → Metrics-CIT7VCoN.js} +1 -1
- data/vendor/assets/dashboard/assets/Modal-CN3rdKA_.js +1 -0
- data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-D86FYohJ.js} +1 -1
- data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-Bz1O1D-i.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-B6h2akTu.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-OOu22e5s.js} +1 -1
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
- data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +141 -0
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-dVPGEWzn.js} +1 -1
- data/vendor/assets/dashboard/index.html +2 -2
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +21 -20
- data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
- data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
- data/vendor/assets/dashboard/assets/index-DQu7WY9y.js +0 -141
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ec30c1dfc5983b7182b0f5765ec40acd60d9682a8ef69dd2f5f5abb547c9a59
|
|
4
|
+
data.tar.gz: 1d6a8578a6743782463582302d240a3be94d4474a2b1ed8c26209ebcc78c2836
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 529fb49080a16a23f3834b99f37d4f34dd2d080e772529cb9afff46f1f0ca1d9694b35804d1a538fdf7ccd9a59e297c0ab3bd712792874fe37a05792fb991a3b
|
|
7
|
+
data.tar.gz: a75598d81e4182d798fbf220febbf50c2960134f3273f238f4182fcf4f03baac65613cfc3efdbeb871e14a38f0d20a775b90962315a8a21473a44f5cb4d0c037
|
data/README.md
CHANGED
|
@@ -61,6 +61,7 @@ Plus Wurk extras: a worker topology DSL, a Kubernetes liveness/readiness listene
|
|
|
61
61
|
- **[Starting the worker](https://github.com/developerz-ai/wurk/blob/main/docs/running.md)** — Rails auto-start, the `wurk`/`wurkswarm` runners, and running standalone without Rails.
|
|
62
62
|
- **[Configuration reference](https://github.com/developerz-ai/wurk/blob/main/docs/configuration.md)** — every option, env var, YAML key, and CLI flag, with precedence and pool sizing.
|
|
63
63
|
- **[Deploying](https://github.com/developerz-ai/wurk/blob/main/docs/deployment.md)** — systemd, Capistrano, Heroku, Docker, Kubernetes, rolling restarts, memory limits.
|
|
64
|
+
- **[Secrets & credentials](https://github.com/developerz-ai/wurk/blob/main/docs/secrets.md)** — which values are secret vs config, how to supply them (ENV, Rails credentials, an init file), precedence, and what to never commit.
|
|
64
65
|
- **[Active Job adapter](https://github.com/developerz-ai/wurk/blob/main/docs/active-job.md)** — run `ActiveJob`/`deliver_later` on Wurk with `queue_adapter = :wurk`.
|
|
65
66
|
- **[Testing jobs](https://github.com/developerz-ai/wurk/blob/main/docs/testing.md)** — fake/inline modes, the jobs array, Minitest and RSpec setup.
|
|
66
67
|
- **[Migrating from Sidekiq](#migrating-from-sidekiq)** — the one-line swap and what to expect.
|
data/lib/wurk/batch/callbacks.rb
CHANGED
|
@@ -26,8 +26,9 @@ module Wurk
|
|
|
26
26
|
# parent's *own* last job acks while a child batch is still running,
|
|
27
27
|
# nothing fires here; the last child's propagate_to_parent re-invokes
|
|
28
28
|
# this and fires then. The SREM in pkids_drained? happens before that
|
|
29
|
-
# re-invocation, so
|
|
30
|
-
#
|
|
29
|
+
# re-invocation, so at most one of the racing paths reaches a fire and
|
|
30
|
+
# the callback markers absorb the rest (see `fire_complete` for the one
|
|
31
|
+
# window that can still duplicate).
|
|
31
32
|
def maybe_fire(bid, pending:, live:)
|
|
32
33
|
return unless live.zero?
|
|
33
34
|
return unless kids_finished?(bid)
|
|
@@ -49,15 +50,38 @@ module Wurk
|
|
|
49
50
|
# the dedup guard so it is restored on re-death; the callback enqueue
|
|
50
51
|
# and parent cascade stay behind the guard so `:death` is enqueued at
|
|
51
52
|
# most once per batch.
|
|
53
|
+
#
|
|
54
|
+
# That claim-before-enqueue ordering is kept deliberately, against the
|
|
55
|
+
# enqueue-before-mark rule `fire_complete` explains: everything that
|
|
56
|
+
# makes the batch *look* dead is already persisted above the guard, so a
|
|
57
|
+
# crash in the window costs the notification while `Status`, the
|
|
58
|
+
# dashboard and `subtree_dead?` all still see a dead batch. `:complete`
|
|
59
|
+
# and `:success` have no such fallback — the callback is their whole
|
|
60
|
+
# signal — and this claim additionally gates `cascade_death`, which
|
|
61
|
+
# would otherwise re-walk the ancestor chain on every re-invocation.
|
|
52
62
|
def fire_death(bid)
|
|
53
63
|
record_event(bid, 'death_at')
|
|
54
|
-
|
|
64
|
+
index_dead(bid)
|
|
55
65
|
return unless dedup_set(bid, 'death')
|
|
56
66
|
|
|
57
67
|
enqueue_callbacks(bid, 'death')
|
|
58
68
|
cascade_death(bid)
|
|
59
69
|
end
|
|
60
70
|
|
|
71
|
+
# Index the batch as dead and bound the set in the same round trip. The
|
|
72
|
+
# score stays `Time.now.to_f` (wire format, spec §2.8); `Batch.trim_index`
|
|
73
|
+
# reads it as the epoch seconds it is. See there for why the set needs a
|
|
74
|
+
# trim at all — only `Status#delete` and the death-recovery ZREM ever
|
|
75
|
+
# remove a member, and neither runs for a batch left to expire.
|
|
76
|
+
def index_dead(bid)
|
|
77
|
+
Wurk.redis do |conn|
|
|
78
|
+
conn.pipelined do |pipe|
|
|
79
|
+
pipe.call('ZADD', 'dead-batches', Time.now.to_f.to_s, bid)
|
|
80
|
+
Batch.trim_index(pipe, 'dead-batches')
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
61
85
|
# A child's death means the parent — and every ancestor — can never
|
|
62
86
|
# fully succeed, so `:death` propagates up the parent chain. The
|
|
63
87
|
# recursion bottoms out at the root (empty parent_bid); fire_death's own
|
|
@@ -70,19 +94,45 @@ module Wurk
|
|
|
70
94
|
fire_death(parent_bid)
|
|
71
95
|
end
|
|
72
96
|
|
|
97
|
+
# `:complete` and `:success` mark their dedup key *after* the enqueue,
|
|
98
|
+
# never before (F16). Nothing re-drives a fire once the acking job's
|
|
99
|
+
# BATCH_ACK_SUCCESS has SREM'd its jid — that job's retry gets
|
|
100
|
+
# `pending == -1` and returns before maybe_fire — so a claim-then-enqueue
|
|
101
|
+
# ordering turns a crash in between into callbacks that are never
|
|
102
|
+
# enqueued by anyone, ever. Enqueuing first makes the durable side effect
|
|
103
|
+
# happen before the marker that suppresses it.
|
|
104
|
+
#
|
|
105
|
+
# The accepted direction is a duplicate over a lost callback: callback
|
|
106
|
+
# jobs retry like any other job and must already be idempotent (spec
|
|
107
|
+
# §2.4, §12 "Callback retries"), so firing one twice is a cost the app
|
|
108
|
+
# is required to absorb, while losing one silently strands the batch.
|
|
109
|
+
#
|
|
110
|
+
# `dedup_marked?` still collapses every *sequential* re-invocation — a
|
|
111
|
+
# reclaimed child re-running propagate_to_parent, a second DeathHandler
|
|
112
|
+
# pass — so the duplicate window is only two genuinely concurrent acks
|
|
113
|
+
# interleaving between each other's check and mark.
|
|
114
|
+
#
|
|
115
|
+
# `record_event` stays ahead of the enqueue: the callback job reads a
|
|
116
|
+
# Status snapshot and must see `complete_at`/`success_at` already set.
|
|
73
117
|
def fire_complete(bid)
|
|
74
|
-
return
|
|
118
|
+
return if dedup_marked?(bid, 'complete')
|
|
75
119
|
|
|
76
120
|
record_event(bid, 'complete_at')
|
|
77
121
|
enqueue_callbacks(bid, 'complete')
|
|
122
|
+
dedup_set(bid, 'complete')
|
|
78
123
|
end
|
|
79
124
|
|
|
125
|
+
# Same enqueue-then-mark ordering as fire_complete. `apply_linger` runs
|
|
126
|
+
# last of all: it EXPIREs `b-<bid>-success` down to the linger window,
|
|
127
|
+
# which only holds if the marker already exists — `dedup_set`'s 30d
|
|
128
|
+
# `EX` would otherwise re-create it outside that window.
|
|
80
129
|
def fire_success(bid)
|
|
81
|
-
return
|
|
130
|
+
return if dedup_marked?(bid, 'success')
|
|
82
131
|
|
|
83
132
|
record_event(bid, 'success_at')
|
|
84
133
|
emit_duration_metric(bid)
|
|
85
134
|
enqueue_callbacks(bid, 'success')
|
|
135
|
+
dedup_set(bid, 'success')
|
|
86
136
|
apply_linger(bid)
|
|
87
137
|
end
|
|
88
138
|
|
|
@@ -90,10 +140,11 @@ module Wurk
|
|
|
90
140
|
# full success. `created_at` shares the CLOCK_REALTIME epoch we record it
|
|
91
141
|
# with. No-op without a dogstatsd client.
|
|
92
142
|
#
|
|
93
|
-
# Strictly best-effort:
|
|
94
|
-
#
|
|
95
|
-
# HGET) would
|
|
96
|
-
#
|
|
143
|
+
# Strictly best-effort: this runs on the acking job's thread ahead of the
|
|
144
|
+
# enqueue, and that ack already removed the jid, so a raise here (e.g. a
|
|
145
|
+
# Redis hiccup on the HGET) would abort `fire_success` with nothing left
|
|
146
|
+
# to re-drive it — the success callbacks and linger would be stranded for
|
|
147
|
+
# good. Swallow and log instead.
|
|
97
148
|
def emit_duration_metric(bid)
|
|
98
149
|
created = Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'created_at') }
|
|
99
150
|
return if created.nil? || created.to_s.empty?
|
|
@@ -116,9 +167,22 @@ module Wurk
|
|
|
116
167
|
end
|
|
117
168
|
end
|
|
118
169
|
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
170
|
+
# True once `b-<bid>-<event>` exists, i.e. an enqueue pass for `event`
|
|
171
|
+
# has completed. The read-side half of the enqueue-then-mark ordering in
|
|
172
|
+
# `fire_complete`/`fire_success`; `fire_death` needs no equivalent
|
|
173
|
+
# because its `dedup_set` still doubles as the claim.
|
|
174
|
+
def dedup_marked?(bid, event)
|
|
175
|
+
Wurk.redis { |conn| conn.call('EXISTS', "b-#{bid}-#{event}") }.to_i == 1
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Writes `b-<bid>-<event>`, the marker that `event`'s callbacks have been
|
|
179
|
+
# enqueued. Returns true when this call created it, false when it was
|
|
180
|
+
# already there.
|
|
181
|
+
#
|
|
182
|
+
# Two usages, deliberately different: `fire_death` calls it *before* its
|
|
183
|
+
# enqueue and treats the return as a claim (at most once); `fire_complete`
|
|
184
|
+
# and `fire_success` call it *after* theirs and ignore the return, gating
|
|
185
|
+
# on `dedup_marked?` instead. SET NX keeps both safe under racing acks.
|
|
122
186
|
def dedup_set(bid, event)
|
|
123
187
|
Wurk.redis do |conn|
|
|
124
188
|
ok = conn.call('SET', "b-#{bid}-#{event}", '1', 'NX', 'EX', Batch::CALLBACK_NOTIFY_TTL)
|
|
@@ -126,11 +190,17 @@ module Wurk
|
|
|
126
190
|
end
|
|
127
191
|
end
|
|
128
192
|
|
|
193
|
+
# The HSETs resurrect the hash when a callback fires for a batch whose keys
|
|
194
|
+
# already expired (a child batch outliving its parent's 30d window), so the
|
|
195
|
+
# write is followed by an NX stamp — without it the resurrected hash would
|
|
196
|
+
# have no clock at all. NX leaves a live batch's expiry, and the shorter
|
|
197
|
+
# post-success `linger` window, untouched.
|
|
129
198
|
def record_event(bid, field)
|
|
130
199
|
now = ::Process.clock_gettime(::Process::CLOCK_REALTIME)
|
|
131
200
|
Wurk.redis do |conn|
|
|
132
201
|
conn.call('HSET', "b-#{bid}", field, now.to_s)
|
|
133
202
|
conn.call('HSET', "b-#{bid}", field.to_s.sub('_at', ''), '1')
|
|
203
|
+
conn.call('EXPIRE', "b-#{bid}", Batch::DEFAULT_EXPIRY_SECONDS, 'NX')
|
|
134
204
|
end
|
|
135
205
|
end
|
|
136
206
|
|
|
@@ -21,7 +21,7 @@ module Wurk
|
|
|
21
21
|
conn,
|
|
22
22
|
:batch_ack_complete,
|
|
23
23
|
keys: ["b-#{bid}", "b-#{bid}-jids", "b-#{bid}-died", "b-#{bid}-failed"],
|
|
24
|
-
argv: [job['jid']]
|
|
24
|
+
argv: [job['jid'], Batch::DEFAULT_EXPIRY_SECONDS]
|
|
25
25
|
)
|
|
26
26
|
end
|
|
27
27
|
live, _died, first_death = Array(result).map(&:to_i)
|
|
@@ -38,9 +38,12 @@ module Wurk
|
|
|
38
38
|
Wurk::Batch::Callbacks.maybe_fire(bid, pending: Wurk::Batch::Callbacks.pending_for(bid), live: 0)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
41
|
+
# BATCH_ACK_COMPLETE stamps the two keys it can itself resurrect; this
|
|
42
|
+
# sweeps the rest of the batch (`-jids`, `-failed`, `-kids`, `-pkids`,
|
|
43
|
+
# callback markers). A death is the one moment we know the batch is
|
|
44
|
+
# winding down, so it is worth a round trip to leave nothing without a
|
|
45
|
+
# clock. EXPIRE NX touches only keys that have none, so a live batch's
|
|
46
|
+
# clock and a post-success `linger` window both survive.
|
|
44
47
|
def self.restamp_ttls(bid)
|
|
45
48
|
Wurk.redis do |conn|
|
|
46
49
|
conn.pipelined do |pipe|
|
data/lib/wurk/batch.rb
CHANGED
|
@@ -41,6 +41,22 @@ module Wurk
|
|
|
41
41
|
POST_SUCCESS_EXPIRY_SECONDS = 24 * 60 * 60
|
|
42
42
|
CALLBACK_NOTIFY_TTL = 30 * 24 * 60 * 60
|
|
43
43
|
|
|
44
|
+
# Member ceiling for the two batch index ZSETs (`batches`, `dead-batches`).
|
|
45
|
+
# The score axis in `.trim_index` retires entries in step with the batch data
|
|
46
|
+
# itself, so this is only the backstop for a workload creating batches faster
|
|
47
|
+
# than that window retires them. Deliberately generous: a cap that bites drops
|
|
48
|
+
# batches whose data is still live out of `BatchSet`, and at this scale the
|
|
49
|
+
# per-batch hashes dwarf the index anyway.
|
|
50
|
+
INDEX_MAX = 1_000_000
|
|
51
|
+
|
|
52
|
+
# Ceiling on the `callbacks` array of one batch hash, enforced by
|
|
53
|
+
# BATCH_APPEND_CALLBACK. Every registration re-encodes the whole array,
|
|
54
|
+
# and every entry becomes a callback job when the event fires, so an
|
|
55
|
+
# unbounded array is both a hot-path cost and a fan-out. Far above any
|
|
56
|
+
# legitimate batch — real ones register a handful — so hitting it means a
|
|
57
|
+
# loop is registering callbacks it should have registered once.
|
|
58
|
+
CALLBACKS_MAX = 1_000
|
|
59
|
+
|
|
44
60
|
# Bid is URL-safe base64 of 10 random bytes — matches Sidekiq Pro's BID
|
|
45
61
|
# generator. Length matters: third-party gems that key off bid prefix
|
|
46
62
|
# (sharded batches in Pro 8) inspect the first character.
|
|
@@ -48,10 +64,21 @@ module Wurk
|
|
|
48
64
|
|
|
49
65
|
VALID_EVENTS = %i[success complete death].freeze
|
|
50
66
|
|
|
67
|
+
# Every key a batch owns, for the sweep paths: `Status#delete` (UNLINK),
|
|
68
|
+
# `Callbacks#apply_linger` and `DeathHandler.restamp_ttls` (EXPIRE).
|
|
69
|
+
#
|
|
51
70
|
# The 'live' set tracks jobs that have not yet reached a terminal state.
|
|
52
71
|
# When it's empty, every job has either succeeded or died → `:complete`
|
|
53
72
|
# is allowed to fire.
|
|
54
|
-
|
|
73
|
+
#
|
|
74
|
+
# `complete`/`success`/`death` are the callback dedup markers written by
|
|
75
|
+
# `Callbacks#dedup_set`; they belong to the batch and must die with it.
|
|
76
|
+
# `notify`/`cbsucc`/`tags` are Sidekiq Pro's own key layout (spec §2.8) that
|
|
77
|
+
# Wurk never writes — Wurk dedups on the three markers above and indexes
|
|
78
|
+
# tags at `tags:<tag>`. They stay listed so a Redis dataset carried over
|
|
79
|
+
# from Sidekiq Pro on the gem swap gets swept too; EXPIRE/UNLINK of a
|
|
80
|
+
# missing key is a no-op for batches Wurk created itself.
|
|
81
|
+
KEY_SUFFIXES = %w[jids failed died complete success death notify cbsucc kids pkids tags].freeze
|
|
55
82
|
|
|
56
83
|
THREAD_KEY = :wurk_current_batch
|
|
57
84
|
|
|
@@ -68,6 +95,33 @@ module Wurk
|
|
|
68
95
|
[base, *KEY_SUFFIXES.map { |s| "#{base}-#{s}" }]
|
|
69
96
|
end
|
|
70
97
|
|
|
98
|
+
# Two-axis trim of a batch index ZSET (`batches`, `dead-batches`), in the
|
|
99
|
+
# shape of the morgue trim (`DeadSet#trim`): `ZREMRANGEBYSCORE` evicts
|
|
100
|
+
# entries older than `timeout`, `ZREMRANGEBYRANK 0 -max` caps the member
|
|
101
|
+
# count — and, like the morgue, that bound keeps `max - 1` of a full set.
|
|
102
|
+
# Appended to the caller's pipeline so bounding the index costs neither
|
|
103
|
+
# writer an extra round trip.
|
|
104
|
+
#
|
|
105
|
+
# Nothing else ever shrinks either set: `Status#delete` and the
|
|
106
|
+
# death-recovery `ZREM` are manual, so an index entry outlives the batch it
|
|
107
|
+
# points at and both sets grow for the life of the Redis without this.
|
|
108
|
+
#
|
|
109
|
+
# Both index in epoch seconds — `batches` from CLOCK_REALTIME,
|
|
110
|
+
# `dead-batches` from `Time.now.to_f` — so one cutoff serves both. The
|
|
111
|
+
# default window is the batch hash TTL: past it `b-<bid>` is gone and the
|
|
112
|
+
# entry only yields an empty Status. A batch that overrode `expires_in`
|
|
113
|
+
# beyond that window outlives its index entry — still reachable by bid,
|
|
114
|
+
# just no longer enumerated by `BatchSet`.
|
|
115
|
+
#
|
|
116
|
+
# `max:` / `timeout:` override the defaults for one call, so parallel tests
|
|
117
|
+
# can drive the trim on isolated limits without mutating the process-global
|
|
118
|
+
# `Wurk.configuration`.
|
|
119
|
+
def self.trim_index(pipe, key, max: nil, timeout: nil)
|
|
120
|
+
cutoff = ::Process.clock_gettime(::Process::CLOCK_REALTIME) - (timeout || DEFAULT_EXPIRY_SECONDS)
|
|
121
|
+
pipe.call('ZREMRANGEBYSCORE', key, '-inf', "(#{cutoff}")
|
|
122
|
+
pipe.call('ZREMRANGEBYRANK', key, 0, -(max || INDEX_MAX))
|
|
123
|
+
end
|
|
124
|
+
|
|
71
125
|
def initialize(bid = nil)
|
|
72
126
|
@bid = bid || SecureRandom.urlsafe_base64(BID_BYTES)
|
|
73
127
|
@existing = !bid.nil?
|
|
@@ -79,6 +133,11 @@ module Wurk
|
|
|
79
133
|
@linger = nil
|
|
80
134
|
@parent_bid = nil
|
|
81
135
|
@callbacks = []
|
|
136
|
+
# Dedup index over `@callbacks`, keyed on the encoded entry. Only the
|
|
137
|
+
# pre-flush staging path feeds it — once flushed, Redis holds the array
|
|
138
|
+
# and BATCH_APPEND_CALLBACK does the deduping — so a batch reopened by
|
|
139
|
+
# bid never pays to build it.
|
|
140
|
+
@callback_index = Set.new
|
|
82
141
|
@expires_in = DEFAULT_EXPIRY_SECONDS
|
|
83
142
|
@mutable = !@existing
|
|
84
143
|
@flushed_once = @existing
|
|
@@ -166,17 +225,22 @@ module Wurk
|
|
|
166
225
|
self
|
|
167
226
|
end
|
|
168
227
|
|
|
169
|
-
# Register a callback.
|
|
170
|
-
#
|
|
171
|
-
#
|
|
228
|
+
# Register a callback. Any number of *distinct* callbacks may be attached
|
|
229
|
+
# to one event; re-registering an identical `[event, target, options]`
|
|
230
|
+
# triple is a no-op, and past `CALLBACKS_MAX` entries the registration is
|
|
231
|
+
# dropped with a warning. The callback target may be a Class, "Foo#bar"
|
|
232
|
+
# string spec, or anything responding to `name`. `options` must be
|
|
233
|
+
# JSON-serializable.
|
|
172
234
|
def on(event, callback, options = {})
|
|
173
235
|
sym = event.to_sym
|
|
174
236
|
raise ArgumentError, "invalid event #{event.inspect}" unless VALID_EVENTS.include?(sym)
|
|
175
237
|
raise ArgumentError, 'callback options must be a Hash' unless options.is_a?(Hash)
|
|
176
238
|
|
|
177
239
|
entry = [sym.to_s, callback_target(callback), options]
|
|
178
|
-
|
|
179
|
-
|
|
240
|
+
# Before the first flush the array lives only in memory; after it, Redis
|
|
241
|
+
# is authoritative and `@callbacks` is a stale mirror nothing reads —
|
|
242
|
+
# appending to it there would just leak one entry per registration.
|
|
243
|
+
@flushed_once ? persist_callback!(entry) : stage_callback(entry)
|
|
180
244
|
self
|
|
181
245
|
end
|
|
182
246
|
|
|
@@ -246,22 +310,52 @@ module Wurk
|
|
|
246
310
|
Wurk::Client.new.flush_batched(payloads) unless payloads.empty?
|
|
247
311
|
end
|
|
248
312
|
|
|
313
|
+
# Pre-flush counterpart to `persist_callback!`. Entries registered before
|
|
314
|
+
# the first flush only exist in memory until `first_flush_hash` writes the
|
|
315
|
+
# whole array in one HSET, so BATCH_APPEND_CALLBACK never sees them — the
|
|
316
|
+
# same dedup and cap have to be applied here or the very first write can
|
|
317
|
+
# already ship duplicates and an unbounded array.
|
|
318
|
+
#
|
|
319
|
+
# Keyed on the encoded entry, which is what actually lands in the hash:
|
|
320
|
+
# `{a: 1}` and `{'a' => 1}` are one callback once persisted, so they must
|
|
321
|
+
# be one entry here too.
|
|
322
|
+
def stage_callback(entry)
|
|
323
|
+
json = entry.to_json
|
|
324
|
+
return if @callback_index.include?(json)
|
|
325
|
+
|
|
326
|
+
if @callbacks.size >= CALLBACKS_MAX
|
|
327
|
+
Wurk.logger.warn("batch #{@bid}: #{entry[0]} callback dropped — #{CALLBACKS_MAX} callback limit reached")
|
|
328
|
+
return
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
@callbacks << entry
|
|
332
|
+
@callback_index << json
|
|
333
|
+
end
|
|
334
|
+
|
|
249
335
|
# Like `linger=`, anything registered after the first flush must reach
|
|
250
336
|
# Redis — `Callbacks.enqueue_callbacks` reads specs from the hash, so an
|
|
251
337
|
# in-memory-only append would silently never fire (#213). Covers both
|
|
252
338
|
# `on` after `#jobs` and batches reopened by bid. The append runs
|
|
253
339
|
# server-side (Lua) so concurrent registrations from different processes
|
|
254
|
-
# can't lose each other to a read-modify-write race
|
|
340
|
+
# can't lose each other to a read-modify-write race, and it dedups
|
|
341
|
+
# identical triples so the reopen-per-job shape stops growing the array.
|
|
255
342
|
def persist_callback!(entry)
|
|
256
343
|
event = entry[0]
|
|
257
|
-
|
|
344
|
+
status = Wurk.redis do |conn|
|
|
258
345
|
Wurk::Lua::Loader.eval_cached(conn, :batch_append_callback,
|
|
259
|
-
keys: ["b-#{@bid}"], argv: [entry.to_json, event])
|
|
346
|
+
keys: ["b-#{@bid}"], argv: [entry.to_json, event, CALLBACKS_MAX])
|
|
347
|
+
end
|
|
348
|
+
raise ArgumentError, "cannot register #{event} callback: batch #{@bid} no longer exists" if status == -1
|
|
349
|
+
|
|
350
|
+
# Sentinels are Integers, the fired flag is the String the `<event>`
|
|
351
|
+
# hash field holds — asymmetric on purpose, so a sentinel can never be
|
|
352
|
+
# read as a fired event.
|
|
353
|
+
case status
|
|
354
|
+
when -2
|
|
355
|
+
Wurk.logger.warn("batch #{@bid}: #{event} callback dropped — #{CALLBACKS_MAX} callback limit reached")
|
|
356
|
+
when '1'
|
|
357
|
+
Wurk.logger.warn("batch #{@bid}: #{event} callback registered after #{event} already fired — it will never run")
|
|
260
358
|
end
|
|
261
|
-
raise ArgumentError, "cannot register #{event} callback: batch #{@bid} no longer exists" if fired == -1
|
|
262
|
-
return unless fired == '1'
|
|
263
|
-
|
|
264
|
-
Wurk.logger.warn("batch #{@bid}: #{event} callback registered after #{event} already fired — it will never run")
|
|
265
359
|
end
|
|
266
360
|
|
|
267
361
|
# First flush writes the core hash, registers in the global `batches`
|
|
@@ -278,10 +372,15 @@ module Wurk
|
|
|
278
372
|
Wurk::Metrics::Statsd.increment('batch.created')
|
|
279
373
|
end
|
|
280
374
|
|
|
375
|
+
# Only `b-#{@bid}` is stamped here — none of the sub-keys exist yet at first
|
|
376
|
+
# flush (BATCH_PUSH/BATCH_SCHEDULE create `-jids`, the acks create
|
|
377
|
+
# `-failed`/`-died`), and EXPIRE on a missing key is a no-op. Each key is
|
|
378
|
+
# stamped `NX` where it is created instead; see BATCH_PUSH in lua.rb.
|
|
281
379
|
def pipelined_first_flush(pipe, now)
|
|
282
380
|
pipe.call('HSET', "b-#{@bid}", *first_flush_hash(now).flatten)
|
|
283
381
|
pipe.call('EXPIRE', "b-#{@bid}", @expires_in)
|
|
284
382
|
pipe.call('ZADD', 'batches', now.to_s, @bid)
|
|
383
|
+
Batch.trim_index(pipe, 'batches')
|
|
285
384
|
@tags.each { |t| pipe.call('SADD', "tags:#{t}", @bid) }
|
|
286
385
|
link_to_parent(pipe) if current_parent_bid
|
|
287
386
|
end
|
|
@@ -312,9 +411,16 @@ module Wurk
|
|
|
312
411
|
outer.bid
|
|
313
412
|
end
|
|
314
413
|
|
|
414
|
+
# The only place `-kids`/`-pkids` are created, so this is where they get
|
|
415
|
+
# their clock. TTL is the default, not this batch's `@expires_in`: the keys
|
|
416
|
+
# belong to the *parent*, and NX means the first link sets the retention for
|
|
417
|
+
# every sibling that follows.
|
|
315
418
|
def link_to_parent(pipe)
|
|
316
|
-
|
|
317
|
-
pipe.call('SADD', "
|
|
419
|
+
parent_key = "b-#{current_parent_bid}"
|
|
420
|
+
pipe.call('SADD', "#{parent_key}-kids", @bid)
|
|
421
|
+
pipe.call('SADD', "#{parent_key}-pkids", @bid)
|
|
422
|
+
pipe.call('EXPIRE', "#{parent_key}-kids", DEFAULT_EXPIRY_SECONDS, 'NX')
|
|
423
|
+
pipe.call('EXPIRE', "#{parent_key}-pkids", DEFAULT_EXPIRY_SECONDS, 'NX')
|
|
318
424
|
end
|
|
319
425
|
|
|
320
426
|
def job_count
|
data/lib/wurk/capsule.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'redis_pool'
|
|
4
|
+
require_relative 'pool_checkout'
|
|
4
5
|
require_relative 'middleware/chain'
|
|
5
6
|
require_relative 'fetcher/reliable'
|
|
6
7
|
|
|
@@ -120,14 +121,14 @@ module Wurk
|
|
|
120
121
|
@fetch_redis_pool = nil
|
|
121
122
|
end
|
|
122
123
|
|
|
123
|
-
def redis(&)
|
|
124
|
-
|
|
124
|
+
def redis(idempotent: false, &)
|
|
125
|
+
PoolCheckout.with(redis_pool, idempotent, &)
|
|
125
126
|
end
|
|
126
127
|
|
|
127
128
|
# Checkout from the dedicated fetch pool. Only the reliable fetcher's
|
|
128
129
|
# blocking BLMOVE uses this, so a parked fetch never holds a main-pool slot.
|
|
129
|
-
def fetch_redis(&)
|
|
130
|
-
|
|
130
|
+
def fetch_redis(idempotent: false, &)
|
|
131
|
+
PoolCheckout.with(fetch_redis_pool, idempotent, &)
|
|
131
132
|
end
|
|
132
133
|
|
|
133
134
|
def lookup(name)
|
data/lib/wurk/cli.rb
CHANGED
|
@@ -106,16 +106,24 @@ module Wurk
|
|
|
106
106
|
enter_server_mode
|
|
107
107
|
boot_application if boot_app
|
|
108
108
|
self_read, self_write = ::IO.pipe
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
109
|
+
begin
|
|
110
|
+
trap_signals(self_write)
|
|
111
|
+
validate_redis!
|
|
112
|
+
validate_pool_sizes!
|
|
113
|
+
@config[:identity] = identity
|
|
114
|
+
# Force lazy server-middleware chain so worker threads don't race
|
|
115
|
+
# against each other constructing it. Spec: Sidekiq::CLI line 104.
|
|
116
|
+
@config.server_middleware
|
|
117
|
+
warm_up_process if warmup
|
|
118
|
+
fire_event(:startup, reverse: false, reraise: true)
|
|
119
|
+
launch(self_read)
|
|
120
|
+
ensure
|
|
121
|
+
# Every exit path — clean return, a raise out of validate/startup, and
|
|
122
|
+
# the SystemExit that `launch` unwinds on Interrupt. Embedded and test
|
|
123
|
+
# callers survive `run`, so a leaked pair is a real FD leak.
|
|
124
|
+
self_read.close
|
|
125
|
+
self_write.close
|
|
126
|
+
end
|
|
119
127
|
end
|
|
120
128
|
|
|
121
129
|
# Standalone multi-process boot — the `sidekiqswarm` entry point (Ent §7).
|
|
@@ -141,11 +149,18 @@ module Wurk
|
|
|
141
149
|
validate_redis!
|
|
142
150
|
validate_pool_sizes!
|
|
143
151
|
@config[:identity] = identity
|
|
144
|
-
|
|
152
|
+
warm_up_process if warmup
|
|
145
153
|
@swarm = Wurk::Swarm.new(topology: @config.topology, config: @config,
|
|
146
154
|
shutdown_timeout: @config[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT)
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
begin
|
|
156
|
+
@swarm.boot(install_signals: true)
|
|
157
|
+
@swarm.supervise
|
|
158
|
+
ensure
|
|
159
|
+
# A fork failure part-way through `boot`, or anything raising out of
|
|
160
|
+
# `supervise`, otherwise leaves live children behind with no supervisor.
|
|
161
|
+
# A no-op once the loop already drained (no children, pipe closed).
|
|
162
|
+
@swarm.shutdown
|
|
163
|
+
end
|
|
149
164
|
end
|
|
150
165
|
|
|
151
166
|
def handle_signal(sig)
|
|
@@ -164,6 +179,15 @@ module Wurk
|
|
|
164
179
|
Wurk.enter_server_mode(@config)
|
|
165
180
|
end
|
|
166
181
|
|
|
182
|
+
# Pre-fault and compact the heap before workers start — in `run_swarm` that
|
|
183
|
+
# happens before the fork, so children share the warmed pages copy-on-write.
|
|
184
|
+
# RUBY_DISABLE_WARMUP=1 is the escape hatch for hosts that already warmed up.
|
|
185
|
+
def warm_up_process
|
|
186
|
+
return unless ::Process.respond_to?(:warmup) && ENV['RUBY_DISABLE_WARMUP'] != '1'
|
|
187
|
+
|
|
188
|
+
::Process.warmup
|
|
189
|
+
end
|
|
190
|
+
|
|
167
191
|
def launch(self_read)
|
|
168
192
|
@launcher = Wurk::Launcher.new(@config)
|
|
169
193
|
begin
|
|
@@ -187,13 +211,23 @@ module Wurk
|
|
|
187
211
|
# over `self_read` in `launch`. Same approach as Sidekiq.
|
|
188
212
|
def trap_signals(self_write)
|
|
189
213
|
signal_names.each do |sig|
|
|
190
|
-
::Signal.trap(sig) { self_write
|
|
214
|
+
::Signal.trap(sig) { emit_signal(self_write, sig) }
|
|
191
215
|
rescue ArgumentError
|
|
192
216
|
# JRuby and platforms without certain signals — log and move on.
|
|
193
217
|
warn("Signal #{sig} not supported")
|
|
194
218
|
end
|
|
195
219
|
end
|
|
196
220
|
|
|
221
|
+
# Non-blocking write from trap context (same shape as Swarm#emit_signal): a
|
|
222
|
+
# blocking `puts` can stall signal delivery once the pipe fills, and the
|
|
223
|
+
# traps outlive `run`'s ensure — a signal landing after the pipe is closed
|
|
224
|
+
# would otherwise raise IOError out of a trap and into the main thread.
|
|
225
|
+
def emit_signal(pipe, sig)
|
|
226
|
+
pipe.write_nonblock("#{sig}\n", exception: false)
|
|
227
|
+
rescue ::IOError, ::Errno::EPIPE, ::Errno::EBADF
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
197
231
|
def signal_names
|
|
198
232
|
%w[INT TERM TSTP TTIN INFO USR2]
|
|
199
233
|
end
|