wurk 1.3.1 → 1.5.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 -1
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
- 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 +45 -16
- data/lib/wurk/cli.rb +48 -14
- data/lib/wurk/client/buffered.rb +200 -43
- data/lib/wurk/client.rb +181 -38
- data/lib/wurk/compat.rb +1 -1
- data/lib/wurk/component.rb +36 -4
- data/lib/wurk/configuration.rb +47 -7
- data/lib/wurk/context.rb +1 -1
- data/lib/wurk/cron.rb +94 -37
- data/lib/wurk/deploy.rb +5 -3
- data/lib/wurk/embedded.rb +13 -0
- data/lib/wurk/engine.rb +41 -1
- data/lib/wurk/errors.rb +15 -0
- data/lib/wurk/fetcher/reaper.rb +125 -58
- data/lib/wurk/fetcher/reliable.rb +366 -51
- data/lib/wurk/fetcher.rb +6 -0
- data/lib/wurk/heartbeat.rb +24 -12
- data/lib/wurk/history.rb +13 -1
- data/lib/wurk/job_logger.rb +16 -7
- data/lib/wurk/job_set.rb +3 -2
- data/lib/wurk/job_util.rb +44 -24
- data/lib/wurk/launcher.rb +230 -119
- data/lib/wurk/leader.rb +63 -14
- 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/logger.rb +1 -1
- data/lib/wurk/lua/loader.rb +9 -3
- data/lib/wurk/lua.rb +97 -14
- data/lib/wurk/manager.rb +29 -13
- data/lib/wurk/metrics/accumulator.rb +95 -0
- data/lib/wurk/metrics/flusher.rb +70 -0
- data/lib/wurk/metrics/history.rb +102 -32
- data/lib/wurk/metrics/queue_rollup.rb +13 -1
- data/lib/wurk/metrics/rollup.rb +13 -1
- data/lib/wurk/metrics/statsd.rb +32 -18
- data/lib/wurk/middleware/chain.rb +31 -14
- data/lib/wurk/middleware/interrupt_handler.rb +7 -6
- data/lib/wurk/middleware/poison_pill.rb +93 -31
- data/lib/wurk/middleware.rb +2 -2
- data/lib/wurk/pool_checkout.rb +39 -0
- data/lib/wurk/process_set.rb +10 -5
- data/lib/wurk/processor.rb +83 -9
- data/lib/wurk/profiler.rb +9 -4
- data/lib/wurk/queue.rb +18 -7
- data/lib/wurk/rails_boot.rb +38 -7
- data/lib/wurk/redis_client_adapter.rb +49 -5
- data/lib/wurk/redis_pool.rb +71 -25
- data/lib/wurk/scheduled.rb +30 -2
- data/lib/wurk/shutdown_gate.rb +79 -0
- data/lib/wurk/stats.rb +19 -10
- data/lib/wurk/swarm/child_boot.rb +36 -4
- data/lib/wurk/swarm.rb +258 -43
- data/lib/wurk/timer_loop.rb +14 -0
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +11 -7
- 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 +12 -10
- data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
- data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-CUXJUQ3Q.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-Cxan6Ngw.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-DC5EGM0g.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-Dlt8tXJA.js} +1 -1
- data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
- data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-dZ7VGlKS.js} +1 -1
- data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +1 -0
- data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-CO3aYWIq.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-DSWbT6G0.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-Cb4PKXNR.js} +1 -1
- data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
- data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +1 -0
- data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
- data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
- data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-CIyPevOy.js} +1 -1
- data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-DopwXkXl.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-1-Z7i1zE.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-ByA6eTma.js} +1 -1
- data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
- data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
- data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
- data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +32 -27
- data/vendor/assets/dashboard/assets/Dashboard-B9rOrkzk.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-BBTDxcaE.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: '0995fd5827870e14584104a8998db58e9927339197460596567c05f2dacc7da0'
|
|
4
|
+
data.tar.gz: 8159d245826bf646a9c6025e4e06a49717f957aabe18dad5d2a5b58ed99818c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d45c20c9a8c29695c7ea2e04066dda58454fea0ce56453ed866cd111e38977fb1739ae47429a60b68380fac2f4d7fe85f232b420f92f95cba330c389ee435b5b
|
|
7
|
+
data.tar.gz: 94c5599bc28408e1e5eb30967d1b0fad4806d415a17e515774c057120d0833660f3c2860d9cf0cb2ea320ae3a27bd9ce683385ce5ace1c347d46530a96cd9a1a
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center"><strong>Wurk, wurk.</strong> 🪓 <em>Ready to work. Zug zug.</em></p>
|
|
8
8
|
|
|
9
|
-
<p align="center"><strong>A 100% drop-in replacement for Sidekiq + Sidekiq Pro + Sidekiq Enterprise. Free forever
|
|
9
|
+
<p align="center"><strong>A 100% drop-in replacement for Sidekiq + Sidekiq Pro + Sidekiq Enterprise. Free forever.</strong></p>
|
|
10
10
|
|
|
11
11
|
<div align="center">
|
|
12
12
|
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
Wurk is wire-compatible with Sidekiq — same Redis keys, same job JSON, same Ruby DSL. Swap one line in your `Gemfile` and your existing jobs, batches, limiters, cron entries, and live Redis data keep working untouched. The Pro and Enterprise feature sets ship in the same free gem, with no license check and no tiers.
|
|
23
23
|
|
|
24
|
+
**On speed:** Wurk is not currently faster than stock Sidekiq — it runs at roughly 0.87×–1.02× depending on workload shape, with parity on CPU and I/O but still behind on framework overhead (noop) and boot time. Numbers, method, and the reproduction command are in [docs/benchmarks.md](docs/benchmarks.md); run them yourself with `rake bench:vs_sidekiq`.
|
|
25
|
+
|
|
24
26
|
## Install
|
|
25
27
|
|
|
26
28
|
```ruby
|
|
@@ -61,6 +63,7 @@ Plus Wurk extras: a worker topology DSL, a Kubernetes liveness/readiness listene
|
|
|
61
63
|
- **[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
64
|
- **[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
65
|
- **[Deploying](https://github.com/developerz-ai/wurk/blob/main/docs/deployment.md)** — systemd, Capistrano, Heroku, Docker, Kubernetes, rolling restarts, memory limits.
|
|
66
|
+
- **[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
67
|
- **[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
68
|
- **[Testing jobs](https://github.com/developerz-ai/wurk/blob/main/docs/testing.md)** — fake/inline modes, the jobs array, Minitest and RSpec setup.
|
|
66
69
|
- **[Migrating from Sidekiq](#migrating-from-sidekiq)** — the one-line swap and what to expect.
|
|
@@ -7,28 +7,45 @@ module Wurk
|
|
|
7
7
|
# we 503 with Retry-After — the SPA's EventSource reconnects (and its polling
|
|
8
8
|
# fallback honors Retry-After) once a slot frees. Per-process is the right
|
|
9
9
|
# scope: it's this process's own thread pool we're protecting.
|
|
10
|
+
#
|
|
11
|
+
# Slots are held as thread references rather than tallied in a counter so the
|
|
12
|
+
# cap can heal itself. A stream whose thread is killed mid-flight never
|
|
13
|
+
# reaches the `ensure` below (Puma hard-reaps worker threads past
|
|
14
|
+
# `force_shutdown_after`, and a thread killed inside an uninterruptible read
|
|
15
|
+
# can skip its ensure), which a counter would record as a slot held by nobody
|
|
16
|
+
# — ten of those and `/api/stream` 503s for the life of the process. A dead
|
|
17
|
+
# holder is instead evicted by the next acquire.
|
|
10
18
|
module StreamConcurrencyGuard
|
|
11
19
|
extend ActiveSupport::Concern
|
|
12
20
|
|
|
13
21
|
MAX_CONCURRENT_STREAMS = 10
|
|
14
22
|
RETRY_AFTER_SECONDS = 3
|
|
15
23
|
|
|
16
|
-
@
|
|
24
|
+
@holders = []
|
|
17
25
|
@lock = Mutex.new
|
|
18
26
|
|
|
19
27
|
class << self
|
|
20
|
-
# Reserve a stream slot; false when the cap is
|
|
28
|
+
# Reserve a stream slot for the calling thread; false when the cap is
|
|
29
|
+
# already reached by threads that are still alive.
|
|
21
30
|
def acquire
|
|
22
31
|
@lock.synchronize do
|
|
23
|
-
|
|
32
|
+
@holders.keep_if(&:alive?)
|
|
33
|
+
return false if @holders.size >= MAX_CONCURRENT_STREAMS
|
|
24
34
|
|
|
25
|
-
@
|
|
35
|
+
@holders << Thread.current
|
|
26
36
|
true
|
|
27
37
|
end
|
|
28
38
|
end
|
|
29
39
|
|
|
40
|
+
# Drops one slot held by the calling thread. Acquire and release always
|
|
41
|
+
# bracket a single block on one thread (`#with_stream_slot`), so a call
|
|
42
|
+
# from a thread holding nothing is a no-op rather than a slot taken away
|
|
43
|
+
# from whoever is actually streaming.
|
|
30
44
|
def release
|
|
31
|
-
@lock.synchronize
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
index = @holders.rindex(Thread.current)
|
|
47
|
+
@holders.delete_at(index) if index
|
|
48
|
+
end
|
|
32
49
|
end
|
|
33
50
|
end
|
|
34
51
|
|
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
|
|
|
@@ -16,6 +17,11 @@ module Wurk
|
|
|
16
17
|
attr_reader :name, :queues, :mode, :weights, :config
|
|
17
18
|
attr_accessor :concurrency, :fetcher
|
|
18
19
|
|
|
20
|
+
# Capsule-hosted components (Manager, Processor, Fetcher) hand their capsule
|
|
21
|
+
# to Component as `config`, and `safe_thread` reads the priority off it.
|
|
22
|
+
# Sidekiq delegates the same accessor (capsule.rb:30).
|
|
23
|
+
def thread_priority = @config.thread_priority
|
|
24
|
+
|
|
19
25
|
def initialize(name, config)
|
|
20
26
|
@name = name.to_s
|
|
21
27
|
@config = config
|
|
@@ -24,8 +30,15 @@ module Wurk
|
|
|
24
30
|
@mode = :strict
|
|
25
31
|
@weights = { 'default' => 0 }
|
|
26
32
|
@fetcher = nil
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
# One mutable Hash rather than two ivars: the pools are the only part of a
|
|
34
|
+
# capsule that legitimately changes after Configuration#freeze! — fork
|
|
35
|
+
# closes them, Launcher#stop releases them, an embedded host that boots
|
|
36
|
+
# again rebuilds them. `Object#freeze` is shallow, so the Hash stays
|
|
37
|
+
# writable and freezing a capsule keeps meaning "no more configuration"
|
|
38
|
+
# instead of "these sockets are yours forever". Before this, a reset on a
|
|
39
|
+
# frozen capsule disconnected the pool and then raised FrozenError on the
|
|
40
|
+
# memo, leaving `redis_pool` answering with a shut-down pool for good.
|
|
41
|
+
@pools = {}
|
|
29
42
|
@client_chain = nil
|
|
30
43
|
@server_chain = nil
|
|
31
44
|
end
|
|
@@ -63,9 +76,26 @@ module Wurk
|
|
|
63
76
|
# by hand; centralizing it here covers the standalone CLI and embedded
|
|
64
77
|
# paths too (the bug behind a nil `fetcher` in `exe/wurk`). Idempotent.
|
|
65
78
|
def prepare!
|
|
79
|
+
prepare_shared!
|
|
66
80
|
@fetcher ||= build_fetcher
|
|
67
81
|
redis_pool
|
|
68
82
|
fetch_redis_pool
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The half of `prepare!` a forking parent can run on every child's behalf:
|
|
87
|
+
# the chains are a pure function of this capsule's identity, not of the slot
|
|
88
|
+
# (queues + concurrency) a swarm child is assigned later, and `copy_for`
|
|
89
|
+
# opens nothing. Run before the fork, the entries are allocated once and
|
|
90
|
+
# inherited copy-on-write instead of rebuilt in every child.
|
|
91
|
+
#
|
|
92
|
+
# The rest of `prepare!` deliberately stays post-fork: both pools are sized
|
|
93
|
+
# off the slot's concurrency and one built here would hand every child an
|
|
94
|
+
# inherited socket, and `build_fetcher` fires the host's
|
|
95
|
+
# `config[:fetch_setup]` hook, which is per-child — running it in the parent
|
|
96
|
+
# would let a custom fetcher snapshot the wrong queues, or leak whatever the
|
|
97
|
+
# hook opened across the fork. Idempotent.
|
|
98
|
+
def prepare_shared!
|
|
69
99
|
client_middleware
|
|
70
100
|
server_middleware
|
|
71
101
|
self
|
|
@@ -98,7 +128,7 @@ module Wurk
|
|
|
98
128
|
MIN_POOL_SIZE = 10
|
|
99
129
|
|
|
100
130
|
def redis_pool
|
|
101
|
-
@
|
|
131
|
+
@pools[:main] ||= build_pool(size: main_pool_size, name: "#{@name}-main")
|
|
102
132
|
end
|
|
103
133
|
|
|
104
134
|
# Dedicated pool for the reliable fetcher's blocking BLMOVE: one slot per
|
|
@@ -106,28 +136,27 @@ module Wurk
|
|
|
106
136
|
# fetch at once. Keeping fetch off the main pool is what lets an idle worker
|
|
107
137
|
# hold zero main-pool connections again.
|
|
108
138
|
def fetch_redis_pool
|
|
109
|
-
@
|
|
139
|
+
@pools[:fetch] ||= build_pool(size: @concurrency, name: "#{@name}-fetch")
|
|
110
140
|
end
|
|
111
141
|
|
|
112
|
-
# Disconnect and drop cached pools. Called by Wurk::Swarm just before
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
# so dropping the reference is
|
|
142
|
+
# Disconnect and drop cached pools. Called by Wurk::Swarm just before fork
|
|
143
|
+
# (parent side: close inherited sockets), just after fork (child side:
|
|
144
|
+
# rebuild lazily), and by Launcher#stop (release what this process held).
|
|
145
|
+
# Connection_pool#shutdown is terminal, so dropping the reference is
|
|
146
|
+
# required — `redis_pool` will rebuild.
|
|
116
147
|
def reset_redis_pools!
|
|
117
|
-
@
|
|
118
|
-
@
|
|
119
|
-
@fetch_redis_pool&.disconnect!
|
|
120
|
-
@fetch_redis_pool = nil
|
|
148
|
+
@pools.each_value(&:disconnect!)
|
|
149
|
+
@pools.clear
|
|
121
150
|
end
|
|
122
151
|
|
|
123
|
-
def redis(&)
|
|
124
|
-
|
|
152
|
+
def redis(idempotent: false, &)
|
|
153
|
+
PoolCheckout.with(redis_pool, idempotent, &)
|
|
125
154
|
end
|
|
126
155
|
|
|
127
156
|
# Checkout from the dedicated fetch pool. Only the reliable fetcher's
|
|
128
157
|
# blocking BLMOVE uses this, so a parked fetch never holds a main-pool slot.
|
|
129
|
-
def fetch_redis(&)
|
|
130
|
-
|
|
158
|
+
def fetch_redis(idempotent: false, &)
|
|
159
|
+
PoolCheckout.with(fetch_redis_pool, idempotent, &)
|
|
131
160
|
end
|
|
132
161
|
|
|
133
162
|
def lookup(name)
|