wurk 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +25 -0
  3. data/app/controllers/concerns/wurk/same_origin_guard.rb +40 -0
  4. data/app/controllers/concerns/wurk/sse_streaming.rb +48 -0
  5. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +53 -0
  6. data/app/controllers/wurk/api/pagination.rb +60 -11
  7. data/app/controllers/wurk/api/serializers.rb +5 -1
  8. data/app/controllers/wurk/api_controller.rb +51 -70
  9. data/app/controllers/wurk/application_controller.rb +26 -0
  10. data/app/controllers/wurk/dashboard_controller.rb +23 -1
  11. data/app/controllers/wurk/extensions_controller.rb +3 -12
  12. data/app/controllers/wurk/profiles_controller.rb +6 -1
  13. data/lib/wurk/batch/death_handler.rb +13 -0
  14. data/lib/wurk/batch/server_middleware.rb +9 -5
  15. data/lib/wurk/batch.rb +3 -0
  16. data/lib/wurk/capsule.rb +34 -21
  17. data/lib/wurk/cli.rb +16 -3
  18. data/lib/wurk/client/buffered.rb +30 -10
  19. data/lib/wurk/client.rb +45 -1
  20. data/lib/wurk/component.rb +23 -9
  21. data/lib/wurk/configuration.rb +83 -18
  22. data/lib/wurk/cron.rb +13 -1
  23. data/lib/wurk/dead_set.rb +16 -1
  24. data/lib/wurk/engine.rb +17 -1
  25. data/lib/wurk/fetcher/reliable.rb +46 -13
  26. data/lib/wurk/fetcher.rb +5 -0
  27. data/lib/wurk/health.rb +74 -22
  28. data/lib/wurk/history.rb +4 -21
  29. data/lib/wurk/job_retry.rb +3 -5
  30. data/lib/wurk/launcher.rb +26 -3
  31. data/lib/wurk/limiter/server_middleware.rb +16 -1
  32. data/lib/wurk/limiter.rb +6 -5
  33. data/lib/wurk/lua/loader.rb +11 -5
  34. data/lib/wurk/lua.rb +100 -9
  35. data/lib/wurk/manager.rb +59 -19
  36. data/lib/wurk/metrics/history.rb +24 -38
  37. data/lib/wurk/metrics/queue_rollup.rb +4 -21
  38. data/lib/wurk/metrics/rollup.rb +4 -21
  39. data/lib/wurk/processor.rb +8 -8
  40. data/lib/wurk/profile_set.rb +21 -6
  41. data/lib/wurk/profiler.rb +7 -5
  42. data/lib/wurk/rails_boot.rb +176 -0
  43. data/lib/wurk/railtie.rb +19 -47
  44. data/lib/wurk/redis_connection.rb +6 -9
  45. data/lib/wurk/redis_pool.rb +148 -28
  46. data/lib/wurk/scheduled.rb +35 -12
  47. data/lib/wurk/swarm/backoff.rb +70 -0
  48. data/lib/wurk/swarm/child_boot.rb +92 -13
  49. data/lib/wurk/swarm/orphan_guard.rb +105 -0
  50. data/lib/wurk/swarm/restart.rb +196 -0
  51. data/lib/wurk/swarm.rb +194 -78
  52. data/lib/wurk/timer_loop.rb +49 -0
  53. data/lib/wurk/version.rb +1 -1
  54. data/lib/wurk/web/extension.rb +4 -1
  55. data/lib/wurk/web/pool_scope.rb +46 -0
  56. data/lib/wurk/web/rack_app.rb +2 -1
  57. data/lib/wurk/web/search.rb +77 -18
  58. data/lib/wurk/web.rb +1 -0
  59. data/lib/wurk/worker/setter.rb +6 -1
  60. data/lib/wurk.rb +6 -2
  61. data/vendor/assets/dashboard/assets/ArgsValue-DYfBiXrJ.js +1 -0
  62. data/vendor/assets/dashboard/assets/BatchDetail-DTZ2HzcD.js +1 -0
  63. data/vendor/assets/dashboard/assets/Batches-BrnXA332.js +1 -0
  64. data/vendor/assets/dashboard/assets/Busy-C57G8Xb3.js +1 -0
  65. data/vendor/assets/dashboard/assets/Cron-DlyH88oo.js +1 -0
  66. data/vendor/assets/dashboard/assets/Dashboard-B887pxWf.js +1 -0
  67. data/vendor/assets/dashboard/assets/Dead-BPA7gs-X.js +1 -0
  68. data/vendor/assets/dashboard/assets/Extension-Bunf6XuU.js +1 -0
  69. data/vendor/assets/dashboard/assets/FilterBox-FCDi4ZCU.js +1 -0
  70. data/vendor/assets/dashboard/assets/JobDetailModal-DuMdKUMm.js +2 -0
  71. data/vendor/assets/dashboard/assets/Limiters-Br0aCPMK.js +1 -0
  72. data/vendor/assets/dashboard/assets/Metrics-DxBmuywH.js +1 -0
  73. data/vendor/assets/dashboard/assets/Modal-t4FI_LaY.js +1 -0
  74. data/vendor/assets/dashboard/assets/PageHeader-CsDvJSOA.js +1 -0
  75. data/vendor/assets/dashboard/assets/Profiles-Bkhoqjlq.js +1 -0
  76. data/vendor/assets/dashboard/assets/Queues-BvhA-vfI.js +1 -0
  77. data/vendor/assets/dashboard/assets/Retries-JEpB-1Yl.js +1 -0
  78. data/vendor/assets/dashboard/assets/Scheduled-DN_FbSwP.js +1 -0
  79. data/vendor/assets/dashboard/assets/Search-DJuK0YCJ.js +1 -0
  80. data/vendor/assets/dashboard/assets/Skeleton-DOYDkzg1.js +1 -0
  81. data/vendor/assets/dashboard/assets/charts-CVK0zAnC.js +1 -0
  82. data/vendor/assets/dashboard/assets/index-BnPX9Ptn.css +1 -0
  83. data/vendor/assets/dashboard/assets/index-CZTcs-pM.js +141 -0
  84. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-CoZU4b3a.js +1 -0
  85. data/vendor/assets/dashboard/assets/useSort-wQcnbdsa.js +1 -0
  86. data/vendor/assets/dashboard/index.html +3 -3
  87. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  88. metadata +55 -26
  89. data/vendor/assets/dashboard/assets/ArgsValue-BUqJa-eG.js +0 -1
  90. data/vendor/assets/dashboard/assets/BatchDetail-C5dkqAzp.js +0 -1
  91. data/vendor/assets/dashboard/assets/Batches-bgkXn8tc.js +0 -1
  92. data/vendor/assets/dashboard/assets/Busy-QSHBFXhy.js +0 -1
  93. data/vendor/assets/dashboard/assets/Cron-CUHppvTA.js +0 -1
  94. data/vendor/assets/dashboard/assets/Dashboard-CzPudckV.js +0 -1
  95. data/vendor/assets/dashboard/assets/Dead-BIq4Nz_i.js +0 -1
  96. data/vendor/assets/dashboard/assets/Extension-CW36i9R1.js +0 -1
  97. data/vendor/assets/dashboard/assets/JobDetailModal-BhNdWSp7.js +0 -2
  98. data/vendor/assets/dashboard/assets/Limiters-CiI_DfUK.js +0 -1
  99. data/vendor/assets/dashboard/assets/Metrics-BzZ8ugms.js +0 -1
  100. data/vendor/assets/dashboard/assets/Modal-DzVfgsSF.js +0 -1
  101. data/vendor/assets/dashboard/assets/PageHeader-Dp3qhX3e.js +0 -1
  102. data/vendor/assets/dashboard/assets/Profiles-BTtIlTdR.js +0 -1
  103. data/vendor/assets/dashboard/assets/Queues-BuXoxQ4W.js +0 -1
  104. data/vendor/assets/dashboard/assets/Retries-D4HAPaOQ.js +0 -1
  105. data/vendor/assets/dashboard/assets/Scheduled-6NCZYVJh.js +0 -1
  106. data/vendor/assets/dashboard/assets/Search-JaB_-52c.js +0 -1
  107. data/vendor/assets/dashboard/assets/charts-6uvCyY0x.js +0 -1
  108. data/vendor/assets/dashboard/assets/i18n-gIeA5VLo.js +0 -1
  109. data/vendor/assets/dashboard/assets/index-BDG9tvBA.css +0 -1
  110. data/vendor/assets/dashboard/assets/index-DWfWAGBc.js +0 -141
  111. data/vendor/assets/dashboard/assets/useJobSetActions-DTDaAoWr.js +0 -1
  112. data/vendor/assets/dashboard/assets/usePageParam-BcAvRko-.js +0 -1
  113. data/vendor/assets/dashboard/assets/useSort-D5Am4bGq.js +0 -1
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ class Swarm
5
+ # Orphan protection for a swarm child. A SIGKILL'd (or crashed, or OOM'd)
6
+ # supervisor leaves its children with no parent — they keep fetching
7
+ # forever, and the next redeploy that boots a fresh supervisor then runs
8
+ # doubled concurrency against the same queues. This arms two independent
9
+ # mechanisms so a child self-terminates the moment it is orphaned:
10
+ #
11
+ # * Linux: PR_SET_PDEATHSIG delivers SIGTERM the instant the forking
12
+ # thread dies (kernel-level, zero latency). It is set right after the
13
+ # fork; the fork race — the parent can die in the window before the
14
+ # syscall lands, so the signal is never queued — is closed by the
15
+ # watchdog's immediate getppid check below.
16
+ # * Everywhere: a watchdog thread compares getppid against the PID the
17
+ # parent captured *before* forking (race-free — never the reaper PID).
18
+ # On its first tick, and every `interval` seconds after, a mismatch
19
+ # means the parent is gone. This is the only mechanism on non-Linux
20
+ # (JRuby, macOS dev) and a backstop for pdeathsig's per-thread caveat.
21
+ #
22
+ # Both funnel through the child's own SIGTERM handler (Process.kill self),
23
+ # so orphan death is an ordinary graceful drain — in-flight jobs finish and
24
+ # the private list is requeued — not a hard kill.
25
+ class OrphanGuard
26
+ WATCHDOG_INTERVAL = 5
27
+ # <linux/prctl.h>: PR_SET_PDEATHSIG is option 1.
28
+ PR_SET_PDEATHSIG = 1
29
+
30
+ # pdeathsig via libc/prctl is Linux-only. Off elsewhere; the watchdog
31
+ # thread covers those platforms on its own.
32
+ def self.pdeathsig_supported?
33
+ ::RbConfig::CONFIG['host_os'].include?('linux')
34
+ end
35
+
36
+ # `on_orphan` is injectable so unit tests can observe the trip without
37
+ # actually signalling the test process. Production leaves it nil and
38
+ # self-TERMs, routing through the child's normal graceful-drain handler.
39
+ def initialize(parent_pid, logger:, interval: WATCHDOG_INTERVAL,
40
+ pdeathsig: pdeathsig_supported?, on_orphan: nil)
41
+ @parent_pid = parent_pid
42
+ @logger = logger
43
+ @interval = interval
44
+ @pdeathsig = pdeathsig
45
+ @on_orphan = on_orphan || -> { ::Process.kill('TERM', ::Process.pid) }
46
+ end
47
+
48
+ # Arm both mechanisms. Returns the watchdog thread so the caller can
49
+ # retain it (otherwise GC could reap it).
50
+ def arm
51
+ set_pdeathsig
52
+ start_watchdog
53
+ end
54
+
55
+ def orphaned?
56
+ ::Process.ppid != @parent_pid
57
+ end
58
+
59
+ private
60
+
61
+ def pdeathsig_supported?
62
+ self.class.pdeathsig_supported?
63
+ end
64
+
65
+ # `sleep until orphaned?` checks first, so an already-orphaned child
66
+ # (the pdeathsig fork race) drains on the very first tick with no wait.
67
+ def start_watchdog
68
+ ::Thread.new do
69
+ ::Thread.current.name = 'wurk-orphan-watchdog'
70
+ ::Thread.current.report_on_exception = false
71
+ sleep @interval until orphaned?
72
+ drain
73
+ end
74
+ end
75
+
76
+ def drain
77
+ @logger&.warn { "swarm child #{::Process.pid}: parent #{@parent_pid} gone; draining orphan" }
78
+ @on_orphan.call
79
+ rescue StandardError => e
80
+ @logger&.error { "swarm child #{::Process.pid}: orphan drain failed: #{e.class}: #{e.message}" }
81
+ end
82
+
83
+ def set_pdeathsig
84
+ return unless @pdeathsig
85
+
86
+ arm_pdeathsig
87
+ rescue ::StandardError, ::LoadError => e
88
+ @logger&.debug { "swarm child #{::Process.pid}: PR_SET_PDEATHSIG unavailable (#{e.class}: #{e.message})" }
89
+ end
90
+
91
+ # prctl(int option, unsigned long arg2, …) — option is int, the rest are
92
+ # unsigned long. Fiddle opens the already-loaded libc (dlopen nil).
93
+ def arm_pdeathsig
94
+ require 'fiddle'
95
+ libc = ::Fiddle.dlopen(nil)
96
+ func = ::Fiddle::Function.new(
97
+ libc['prctl'],
98
+ [::Fiddle::TYPE_INT, ::Fiddle::TYPE_LONG, ::Fiddle::TYPE_LONG, ::Fiddle::TYPE_LONG, ::Fiddle::TYPE_LONG],
99
+ ::Fiddle::TYPE_INT
100
+ )
101
+ func.call(PR_SET_PDEATHSIG, ::Signal.list.fetch('TERM'), 0, 0, 0)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ class Swarm
5
+ # Non-blocking rolling-restart / recycle state machine. One slot is in
6
+ # flight at a time; `advance` moves it a single phase per supervise tick so
7
+ # the supervisor never blocks on a restart and keeps honoring TERM:
8
+ #
9
+ # spawn_replacement → await_heartbeat(heartbeat_wait) → term_old
10
+ # → await_exit(drain_timeout) → done
11
+ #
12
+ # The reaper reports child exits via `claim_exit`, so a replacement that
13
+ # dies before it heartbeats is seen as dead (not "slow"): the old child is
14
+ # kept, a per-slot backoff applied, and the slot retried. `abort` drops
15
+ # everything — the swarm's TERM handler then drains the in-flight
16
+ # replacement + old as ordinary children.
17
+ #
18
+ # Collaborators are injected (Config) so the machine is decoupled from the
19
+ # swarm's fork/kill/Redis internals and unit-testable against fakes.
20
+ class Restart
21
+ Config = Struct.new(:spawn, :kill, :heartbeat, :describe, :now, :logger,
22
+ :heartbeat_wait, :drain_timeout, :backoff, keyword_init: true)
23
+
24
+ def initialize(config)
25
+ @spawn = config.spawn # ->(slot, idx) => replacement pid (registered by the swarm)
26
+ @kill = config.kill # ->(pid, sig)
27
+ @heartbeat = config.heartbeat # ->(pid) => truthy once the child has beaten
28
+ @describe = config.describe # ->(pid) => { slot:, index: } | nil
29
+ @now = config.now # -> monotonic seconds
30
+ @logger = config.logger
31
+ @heartbeat_wait = config.heartbeat_wait
32
+ @drain_timeout = config.drain_timeout
33
+ @backoff = config.backoff
34
+ @queue = []
35
+ @current = nil
36
+ end
37
+
38
+ def idle?
39
+ @current.nil? && @queue.empty?
40
+ end
41
+
42
+ # Queue live child PIDs for restart, skipping any already queued or in
43
+ # flight so recycle + rolling restart can't double up on one slot.
44
+ def enqueue(pids)
45
+ pids.each do |pid|
46
+ next if in_flight?(pid) || @queue.include?(pid)
47
+
48
+ @queue << pid
49
+ end
50
+ end
51
+
52
+ # Reaper hook. Returns true when `pid` belonged to the in-flight restart
53
+ # so the swarm skips crash-respawn for it. A replacement is only claimed
54
+ # while awaiting its heartbeat — once it takes over the slot (await_exit),
55
+ # its death is an ordinary crash the swarm respawns.
56
+ def claim_exit(pid) # rubocop:disable Naming/PredicateMethod
57
+ return false unless @current
58
+
59
+ if pid == @current[:old_pid]
60
+ @current[:old_exited] = true
61
+ true
62
+ elsif pid == @current[:replacement] && @current[:phase] == :await_heartbeat
63
+ @current[:replacement_dead] = true
64
+ true
65
+ else
66
+ false
67
+ end
68
+ end
69
+
70
+ def advance
71
+ start_next if @current.nil?
72
+ return if @current.nil?
73
+
74
+ case @current[:phase]
75
+ when :await_heartbeat then advance_await_heartbeat
76
+ when :await_exit then advance_await_exit
77
+ end
78
+ end
79
+
80
+ # TERM/INT: forget queued + in-flight work. The replacement and old child
81
+ # are ordinary children the swarm's shutdown TERMs and drains.
82
+ def abort
83
+ @queue.clear
84
+ @current = nil
85
+ end
86
+
87
+ private
88
+
89
+ def in_flight?(pid)
90
+ return false unless @current
91
+
92
+ pid == @current[:old_pid] || pid == @current[:replacement]
93
+ end
94
+
95
+ def start_next
96
+ pid = next_ready_pid
97
+ return unless pid
98
+
99
+ meta = @describe.call(pid)
100
+ replacement = spawn_replacement(pid, meta)
101
+ return unless replacement
102
+
103
+ @current = { old_pid: pid, index: meta[:index], replacement: replacement,
104
+ phase: :await_heartbeat, deadline: now + @heartbeat_wait }
105
+ @logger.info { "swarm: restarting slot #{meta[:index]} (old #{pid} -> new #{replacement})" }
106
+ end
107
+
108
+ # Spawn the replacement, requeuing the slot on a fork/resource failure so
109
+ # the restart work item isn't lost (and the error can't escape the
110
+ # supervise loop). A per-slot backoff paces the retry — next_ready_pid
111
+ # leaves the head queued until it elapses.
112
+ def spawn_replacement(pid, meta)
113
+ @spawn.call(meta[:slot], meta[:index])
114
+ rescue StandardError => e
115
+ delay = @backoff.fail(meta[:index], lifetime: 0.0)
116
+ @queue.unshift(pid)
117
+ @logger.warn do
118
+ "swarm: replacement spawn for slot #{meta[:index]} failed (#{e.class}: #{e.message}); retry in #{delay}s"
119
+ end
120
+ nil
121
+ end
122
+
123
+ # Head of the queue if its old child still exists and its retry backoff
124
+ # has elapsed. Drops a stale head whose old child already vanished; leaves
125
+ # the head queued (returns nil) while a retry backoff is pending, so a
126
+ # slot whose replacement won't boot doesn't cascade into the next slot.
127
+ def next_ready_pid
128
+ pid = @queue.first
129
+ return nil unless pid
130
+
131
+ meta = @describe.call(pid)
132
+ if meta.nil?
133
+ @queue.shift
134
+ return nil
135
+ end
136
+ return nil unless @backoff.ready?(meta[:index])
137
+
138
+ @queue.shift
139
+ end
140
+
141
+ def advance_await_heartbeat
142
+ cur = @current
143
+ return retry_slot if cur[:replacement_dead]
144
+
145
+ seen = @heartbeat.call(cur[:replacement])
146
+ timed_out = now >= cur[:deadline]
147
+ return unless seen || timed_out
148
+
149
+ warn_heartbeat_timeout(cur) if timed_out && !seen
150
+ @kill.call(cur[:old_pid], 'TERM')
151
+ cur[:phase] = :await_exit
152
+ cur[:deadline] = now + @drain_timeout
153
+ end
154
+
155
+ def advance_await_exit
156
+ cur = @current
157
+ return finish(cur) if cur[:old_exited]
158
+ return if now < cur[:deadline]
159
+
160
+ # SIGKILL an overrunning old child once, then wait for the reaper to
161
+ # confirm the exit before finishing — finishing early would let the
162
+ # later reap look like a fresh crash and spawn a surplus child.
163
+ return if cur[:killed]
164
+
165
+ @kill.call(cur[:old_pid], 'KILL')
166
+ cur[:killed] = true
167
+ end
168
+
169
+ def finish(cur)
170
+ @backoff.clear(cur[:index])
171
+ @current = nil
172
+ end
173
+
174
+ def retry_slot
175
+ cur = @current
176
+ delay = @backoff.fail(cur[:index], lifetime: 0.0)
177
+ @queue.unshift(cur[:old_pid])
178
+ @logger.warn do
179
+ "swarm: replacement #{cur[:replacement]} for slot #{cur[:index]} died; " \
180
+ "keeping #{cur[:old_pid]}, retry in #{delay}s"
181
+ end
182
+ @current = nil
183
+ end
184
+
185
+ def warn_heartbeat_timeout(cur)
186
+ @logger.warn do
187
+ "swarm: replacement #{cur[:replacement]} heartbeat not seen in #{@heartbeat_wait}s; proceeding to TERM old"
188
+ end
189
+ end
190
+
191
+ def now
192
+ @now.call
193
+ end
194
+ end
195
+ end
196
+ end