cogworker 0.1.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 +7 -0
- data/exe/cogworker +6 -0
- data/exe/cogworkerswarm +6 -0
- data/lib/cogworker/basic_fetch.rb +27 -0
- data/lib/cogworker/cli.rb +64 -0
- data/lib/cogworker/client.rb +61 -0
- data/lib/cogworker/component.rb +19 -0
- data/lib/cogworker/config.rb +84 -0
- data/lib/cogworker/config_loader.rb +22 -0
- data/lib/cogworker/heartbeat.rb +150 -0
- data/lib/cogworker/history/middleware.rb +18 -0
- data/lib/cogworker/history/storage.rb +81 -0
- data/lib/cogworker/history.rb +33 -0
- data/lib/cogworker/job.rb +75 -0
- data/lib/cogworker/job_record.rb +24 -0
- data/lib/cogworker/job_util.rb +60 -0
- data/lib/cogworker/launcher.rb +93 -0
- data/lib/cogworker/logging.rb +18 -0
- data/lib/cogworker/manager.rb +71 -0
- data/lib/cogworker/middleware/chain.rb +64 -0
- data/lib/cogworker/periodic/claim.lua +27 -0
- data/lib/cogworker/periodic/entry.rb +24 -0
- data/lib/cogworker/periodic/manager.rb +28 -0
- data/lib/cogworker/periodic/release_middleware.rb +27 -0
- data/lib/cogworker/periodic/ticker.rb +123 -0
- data/lib/cogworker/process.rb +36 -0
- data/lib/cogworker/process_set.rb +29 -0
- data/lib/cogworker/processor.rb +129 -0
- data/lib/cogworker/prometheus/exporter.rb +62 -0
- data/lib/cogworker/queue.rb +60 -0
- data/lib/cogworker/redis_connection.rb +37 -0
- data/lib/cogworker/redis_keys.rb +32 -0
- data/lib/cogworker/scheduled.rb +67 -0
- data/lib/cogworker/signals.rb +15 -0
- data/lib/cogworker/stats.rb +47 -0
- data/lib/cogworker/status/client_middleware.rb +19 -0
- data/lib/cogworker/status/server_middleware.rb +31 -0
- data/lib/cogworker/status/storage.rb +30 -0
- data/lib/cogworker/status/worker.rb +27 -0
- data/lib/cogworker/status.rb +40 -0
- data/lib/cogworker/swarm.rb +169 -0
- data/lib/cogworker/testing.rb +109 -0
- data/lib/cogworker/unique_jobs/client_middleware.rb +31 -0
- data/lib/cogworker/unique_jobs/release_middleware.rb +30 -0
- data/lib/cogworker/unique_jobs.rb +32 -0
- data/lib/cogworker/version.rb +5 -0
- data/lib/cogworker/web/action.rb +63 -0
- data/lib/cogworker/web/application.rb +62 -0
- data/lib/cogworker/web/assets/ag-grid/ag-grid-community.min.js +1 -0
- data/lib/cogworker/web/assets/ag-grid/ag-grid.min.css +7 -0
- data/lib/cogworker/web/assets/ag-grid/ag-theme-alpine.min.css +2 -0
- data/lib/cogworker/web/assets/chart.umd.min.js +13 -0
- data/lib/cogworker/web/assets/htmx.min.js +1 -0
- data/lib/cogworker/web/assets/tailwind.css +1 -0
- data/lib/cogworker/web/layout.rb +352 -0
- data/lib/cogworker/web/router.rb +27 -0
- data/lib/cogworker/web/routes/busy.rb +99 -0
- data/lib/cogworker/web/routes/dead.rb +93 -0
- data/lib/cogworker/web/routes/history.rb +226 -0
- data/lib/cogworker/web/routes/periodic.rb +67 -0
- data/lib/cogworker/web/routes/queues.rb +101 -0
- data/lib/cogworker/web/routes/retries.rb +89 -0
- data/lib/cogworker/web/routes/save_session.rb +21 -0
- data/lib/cogworker/web/routes/scheduled.rb +49 -0
- data/lib/cogworker/web/routes/stats.rb +298 -0
- data/lib/cogworker/web/views.rb +25 -0
- data/lib/cogworker/web.rb +257 -0
- data/lib/cogworker/work.rb +19 -0
- data/lib/cogworker/work_set.rb +23 -0
- data/lib/cogworker/worker.rb +5 -0
- data/lib/cogworker/workers.rb +8 -0
- data/lib/cogworker.rb +114 -0
- metadata +300 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Multi-process supervisor: forks `COGWORKER_COUNT` worker children from
|
|
5
|
+
# one parent (copy-on-write memory sharing) and relays signals to them.
|
|
6
|
+
# `PHASED_RESTART=true` makes a restart trigger cycle children one at a
|
|
7
|
+
# time (quiet -> wait -> replace) instead of all at once, so the pool as a
|
|
8
|
+
# whole never has zero capacity.
|
|
9
|
+
#
|
|
10
|
+
# The parent process is never itself a job-processing Launcher — it only
|
|
11
|
+
# forks/reaps/signals. Each child re-runs the full CLI boot (re-requiring
|
|
12
|
+
# app code, resetting its own identity) *after* `fork`, never before, so
|
|
13
|
+
# nothing in Heartbeat/Scheduled/the periodic Ticker ever starts a
|
|
14
|
+
# background thread in the parent that would silently vanish in a child.
|
|
15
|
+
class Swarm
|
|
16
|
+
# How long a phased-restart replacement waits for the outgoing child to
|
|
17
|
+
# exit on its own before giving up and force-killing it. Graceful
|
|
18
|
+
# shutdown (Manager#stop! draining in-flight jobs) can legitimately take
|
|
19
|
+
# a while, but this loop is single-threaded and blocking — one child
|
|
20
|
+
# that never exits (a hung Redis call outliving even Manager#stop!'s
|
|
21
|
+
# own 25s join, or anything else) must not be able to wedge the whole
|
|
22
|
+
# restart cycle, and the supervisor's entire signal-handling loop with
|
|
23
|
+
# it, forever.
|
|
24
|
+
GRACEFUL_STOP_TIMEOUT = 20
|
|
25
|
+
|
|
26
|
+
def initialize(argv, count: ENV.fetch('COGWORKER_COUNT', 1).to_i, phased: ENV['PHASED_RESTART'] == 'true')
|
|
27
|
+
@argv = argv
|
|
28
|
+
@count = [count, 1].max
|
|
29
|
+
@phased = phased
|
|
30
|
+
@children = {} # pid => slot index
|
|
31
|
+
@signal_queue = ::Queue.new
|
|
32
|
+
@stopping = false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
install_signal_traps
|
|
37
|
+
@count.times { |slot| fork_child(slot) }
|
|
38
|
+
supervise
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def fork_child(slot)
|
|
44
|
+
pid = ::Process.fork do
|
|
45
|
+
Cogworker.reset_identity!
|
|
46
|
+
CLI.new.run(@argv.dup)
|
|
47
|
+
# `exit!`, not plain `exit`: `Launcher#run` only returns once
|
|
48
|
+
# `Manager#stop!` has had its bounded (default 25s) chance to join
|
|
49
|
+
# every processor thread — but per that method's own doc, a thread
|
|
50
|
+
# stuck past its deadline (mid a hung Redis call, say) is left
|
|
51
|
+
# running, not killed. A non-daemon thread still alive there would
|
|
52
|
+
# make plain `exit`'s normal interpreter shutdown wait for it
|
|
53
|
+
# indefinitely, so this OS process would never actually go away —
|
|
54
|
+
# and `Swarm#initiate_restart`'s own `Process.waitpid(pid)` for this
|
|
55
|
+
# exact child would then block forever right along with it.
|
|
56
|
+
::Process.exit!(true)
|
|
57
|
+
end
|
|
58
|
+
@children[pid] = slot
|
|
59
|
+
Cogworker.logger.info { "swarm: started child pid=#{pid} slot=#{slot}" }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def install_signal_traps
|
|
63
|
+
Signal.trap(Signals::QUIET) { @signal_queue << :quiet }
|
|
64
|
+
Signal.trap(Signals::STOP) { @signal_queue << :stop }
|
|
65
|
+
Signal.trap(Signals::INTERRUPT) { @signal_queue << :stop }
|
|
66
|
+
Signal.trap(Signals::RESTART) { @signal_queue << :restart }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def supervise
|
|
70
|
+
until @stopping && @children.empty?
|
|
71
|
+
drain_signals
|
|
72
|
+
reap_children
|
|
73
|
+
sleep 0.2
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def drain_signals
|
|
78
|
+
until @signal_queue.empty?
|
|
79
|
+
case @signal_queue.pop
|
|
80
|
+
when :quiet then relay(Signals::QUIET)
|
|
81
|
+
when :stop then initiate_stop
|
|
82
|
+
when :restart
|
|
83
|
+
Cogworker.logger.info { "swarm: restart signal received, phased=#{@phased}" }
|
|
84
|
+
initiate_restart
|
|
85
|
+
else Cogworker.logger.warn { "Unknown signal: #{signal}" }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def relay(signal)
|
|
91
|
+
@children.each_key { |pid| safe_kill(pid, signal) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def initiate_stop
|
|
95
|
+
@stopping = true
|
|
96
|
+
relay(Signals::STOP)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Non-phased: signal every child at once; the normal reap/respawn path
|
|
100
|
+
# below brings each one back as it exits, so there's a brief capacity
|
|
101
|
+
# dip but no explicit special-casing needed. Phased: replace children
|
|
102
|
+
# one at a time, waiting for each replacement to finish forking before
|
|
103
|
+
# touching the next, so total capacity never drops by more than one
|
|
104
|
+
# slot at a time.
|
|
105
|
+
def initiate_restart
|
|
106
|
+
return relay(Signals::STOP) unless @phased
|
|
107
|
+
|
|
108
|
+
@children.keys.each do |pid| # rubocop:disable Style/HashEachMethods -- mutates @children (delete/fork_child) during iteration; each_key is unsafe here
|
|
109
|
+
next unless @children.key?(pid)
|
|
110
|
+
|
|
111
|
+
slot = @children.delete(pid)
|
|
112
|
+
Cogworker.logger.info { "swarm: phased restart stopping child pid=#{pid} slot=#{slot}" }
|
|
113
|
+
safe_kill(pid, Signals::STOP)
|
|
114
|
+
wait_for_exit(pid)
|
|
115
|
+
Cogworker.logger.info { "swarm: phased restart child pid=#{pid} slot=#{slot} exited, respawning" }
|
|
116
|
+
fork_child(slot)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Polls instead of a plain blocking `Process.waitpid(pid)`, specifically
|
|
121
|
+
# so a child that never exits can't block this forever: past
|
|
122
|
+
# GRACEFUL_STOP_TIMEOUT it's force-killed and reaped instead of leaving
|
|
123
|
+
# `initiate_restart` (and every other signal this single-threaded
|
|
124
|
+
# supervisor needs to handle) stuck behind it indefinitely.
|
|
125
|
+
def wait_for_exit(pid)
|
|
126
|
+
deadline = monotonic_now + GRACEFUL_STOP_TIMEOUT
|
|
127
|
+
until ::Process.waitpid(pid, ::Process::WNOHANG)
|
|
128
|
+
if monotonic_now > deadline
|
|
129
|
+
Cogworker.logger.warn { "swarm: child pid=#{pid} didn't stop within #{GRACEFUL_STOP_TIMEOUT}s, killing" }
|
|
130
|
+
safe_kill(pid, 'KILL')
|
|
131
|
+
::Process.waitpid(pid)
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
sleep 0.1
|
|
135
|
+
end
|
|
136
|
+
rescue Errno::ECHILD
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def monotonic_now
|
|
141
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def reap_children
|
|
145
|
+
pid = begin
|
|
146
|
+
::Process.waitpid(-1, ::Process::WNOHANG)
|
|
147
|
+
rescue Errno::ECHILD
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
return unless pid
|
|
151
|
+
|
|
152
|
+
slot = @children.delete(pid)
|
|
153
|
+
return if slot.nil? # already handled by initiate_restart
|
|
154
|
+
|
|
155
|
+
if @stopping
|
|
156
|
+
Cogworker.logger.info { "swarm: child pid=#{pid} slot=#{slot} exited" }
|
|
157
|
+
else
|
|
158
|
+
Cogworker.logger.warn { "swarm: child pid=#{pid} slot=#{slot} exited unexpectedly, respawning" }
|
|
159
|
+
fork_child(slot)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def safe_kill(pid, signal)
|
|
164
|
+
::Process.kill(signal, pid)
|
|
165
|
+
rescue Errno::ESRCH
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# `Client.push`'s enqueue-mode switch, checked at the very end of the
|
|
5
|
+
# client middleware chain in place of the real `raw_push` — the same spot
|
|
6
|
+
# every enqueue path (`perform_async`/`perform_in`/`perform_at`, or a
|
|
7
|
+
# direct `Client.push` call) already funnels through, so flipping this
|
|
8
|
+
# mode covers all of them for free. Three states:
|
|
9
|
+
#
|
|
10
|
+
# - `:disabled` (default) — the real thing: pushes onto actual Redis, same
|
|
11
|
+
# as every non-test run. Nothing here changes any existing behavior.
|
|
12
|
+
# - `:fake` — the client middleware chain still runs (so custom middleware
|
|
13
|
+
# that mutates the job hash, e.g. tagging, is exercised same as in prod),
|
|
14
|
+
# but the normalized job hash is appended to `jobs_for(klass_name)`
|
|
15
|
+
# instead of touching Redis — nothing is scheduled or executed. Use
|
|
16
|
+
# `SomeJob.jobs` (added to `Job::ClassMethods`) to assert what got
|
|
17
|
+
# pushed, `SomeJob.clear`/`Testing.clear_jobs!` to reset between
|
|
18
|
+
# examples.
|
|
19
|
+
# - `:inline` — the job runs synchronously, right here in the calling
|
|
20
|
+
# thread, through the real server middleware chain
|
|
21
|
+
# (`Cogworker.config.server_chain`) — same as `Processor#execute`, minus
|
|
22
|
+
# the parts of that only make sense for an async worker process (work
|
|
23
|
+
# registration, stats counters, retry/dead routing on failure: there's
|
|
24
|
+
# no Processor here to retry anything later). An exception raised by
|
|
25
|
+
# `perform` propagates straight to the caller, same as calling
|
|
26
|
+
# `SomeJob.new.perform(*args)` directly would — this is a testing
|
|
27
|
+
# convenience, not a fire-and-forget execution path. `perform_in`/
|
|
28
|
+
# `perform_at`'s delay is ignored entirely; the job still runs
|
|
29
|
+
# immediately.
|
|
30
|
+
#
|
|
31
|
+
# A plain module-level ivar, not thread-local: this is a mode flipped once
|
|
32
|
+
# in a test's setup, not a per-request runtime toggle — same single-
|
|
33
|
+
# process assumption `Cogworker.config`/`Cogworker.server_process!` already
|
|
34
|
+
# make.
|
|
35
|
+
module Testing
|
|
36
|
+
class << self
|
|
37
|
+
def fake!(&block)
|
|
38
|
+
set(:fake, &block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def inline!(&block)
|
|
42
|
+
set(:inline, &block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def disable!(&block)
|
|
46
|
+
set(:disabled, &block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fake?
|
|
50
|
+
mode == :fake
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def inline?
|
|
54
|
+
mode == :inline
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def disabled?
|
|
58
|
+
mode == :disabled
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mode
|
|
62
|
+
@mode ||= :disabled
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `SomeJob.jobs` reads this via `Job::ClassMethods#jobs` — every job
|
|
66
|
+
# pushed for that exact class name while fake mode was on, oldest
|
|
67
|
+
# first, as the same normalized (string-keyed) job hash `Client.push`
|
|
68
|
+
# always produces (so `job['args']`/`job['at']`/`job['jid']` etc. all
|
|
69
|
+
# work exactly as they would reading a job back out of Redis).
|
|
70
|
+
def jobs_for(klass_name)
|
|
71
|
+
registry[klass_name] ||= []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def clear_jobs!
|
|
75
|
+
registry.clear
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Runs one job synchronously through the real server middleware chain.
|
|
79
|
+
# Only ever called from `Client.push` while `inline?`.
|
|
80
|
+
def perform_inline(job)
|
|
81
|
+
klass = Object.const_get(job['class'])
|
|
82
|
+
worker = klass.new
|
|
83
|
+
worker.jid = job['jid'] if worker.respond_to?(:jid=)
|
|
84
|
+
|
|
85
|
+
Cogworker.config.server_chain.invoke(worker, job, job['queue']) do
|
|
86
|
+
worker.perform(*job['args'])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def registry
|
|
93
|
+
@registry ||= {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def set(new_mode)
|
|
97
|
+
previous = mode
|
|
98
|
+
@mode = new_mode
|
|
99
|
+
return new_mode unless block_given?
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
yield
|
|
103
|
+
ensure
|
|
104
|
+
@mode = previous
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module UniqueJobs
|
|
5
|
+
# Client middleware, registered unconditionally on every `Config` (like
|
|
6
|
+
# `Periodic::ReleaseMiddleware` on the server side) — a no-op for any
|
|
7
|
+
# job that isn't `unique: :until_executed`. For one that is, atomically
|
|
8
|
+
# claims `cogworker:unique:<digest>` (`SET NX`) before letting the job
|
|
9
|
+
# continue down the chain to the real push. A job that loses the claim
|
|
10
|
+
# (another copy is already enqueued/scheduled/running) never reaches
|
|
11
|
+
# `dispatch` at all — it sets `job['unique_skipped']` instead, which
|
|
12
|
+
# `Client.push` reads to return `nil` rather than a jid, the same
|
|
13
|
+
# "nothing was actually pushed" signal `sidekiq-unique-jobs` gives.
|
|
14
|
+
class ClientMiddleware
|
|
15
|
+
def call(_worker_class, job, _queue, redis_pool = Cogworker.config.redis_pool)
|
|
16
|
+
return yield unless UniqueJobs.until_executed?(job)
|
|
17
|
+
|
|
18
|
+
acquired = redis_pool.with do |c|
|
|
19
|
+
c.set(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'],
|
|
20
|
+
nx: true, ex: Cogworker.config.unique_lock_ttl)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if acquired
|
|
24
|
+
yield
|
|
25
|
+
else
|
|
26
|
+
job['unique_skipped'] = true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module UniqueJobs
|
|
5
|
+
# Releases the `cogworker:unique:<digest>` lock (claimed by
|
|
6
|
+
# `ClientMiddleware` for `unique: :until_executed` jobs) on success, or
|
|
7
|
+
# on the terminal failed attempt — mirrors `Periodic::ReleaseMiddleware`
|
|
8
|
+
# exactly, just keyed by job content (class+queue+args) instead of a
|
|
9
|
+
# periodic pjid, so a retried job keeps its lock (no duplicate can sneak
|
|
10
|
+
# in while it's still retrying) and only releases once it's genuinely
|
|
11
|
+
# done, one way or the other. A no-op for any job that isn't
|
|
12
|
+
# `unique: :until_executed`. Registered unconditionally in every
|
|
13
|
+
# `Config`, same as `Periodic::ReleaseMiddleware`.
|
|
14
|
+
class ReleaseMiddleware
|
|
15
|
+
def call(_worker, job, _queue)
|
|
16
|
+
yield
|
|
17
|
+
release(job) if UniqueJobs.until_executed?(job)
|
|
18
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
19
|
+
release(job) if UniqueJobs.until_executed?(job) && JobUtil.terminal_failure?(job)
|
|
20
|
+
raise e
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def release(job)
|
|
26
|
+
Cogworker.config.redis { |c| c.del(RedisKeys.unique_lock(UniqueJobs.digest(job))) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest/sha1'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Cogworker
|
|
7
|
+
# Shared helpers for `unique: :until_executed` on regular jobs (anything
|
|
8
|
+
# pushed through `Client.push` — `perform_async`/`perform_in`/
|
|
9
|
+
# `perform_at`). Distinct from `Periodic::Entry#until_executed?`, which
|
|
10
|
+
# locks one cron *slot* per pjid; this locks on job *content* instead, so
|
|
11
|
+
# `ClientMiddleware` (before push) and `ReleaseMiddleware` (after the job
|
|
12
|
+
# hash has round-tripped through Redis/JSON) always agree on the same key
|
|
13
|
+
# without needing to stash it on the job hash itself.
|
|
14
|
+
module UniqueJobs
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# `job['unique']` round-trips through JSON exactly like `job['retry']`
|
|
18
|
+
# (see `JobUtil.max_retries`) — a caller's `cogworker_options unique:
|
|
19
|
+
# :until_executed`/direct push hash may still hold the Symbol at the
|
|
20
|
+
# point `ClientMiddleware` sees it (before the job is ever
|
|
21
|
+
# `JSON.generate`d), while `ReleaseMiddleware` only ever sees the
|
|
22
|
+
# String a popped job was parsed back from. Comparing against `.to_s`
|
|
23
|
+
# handles both, plus the absent (`nil`) case, uniformly.
|
|
24
|
+
def until_executed?(job)
|
|
25
|
+
job['unique'].to_s == 'until_executed'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def digest(job)
|
|
29
|
+
Digest::SHA1.hexdigest("#{job['class']}|#{job['queue']}|#{job['args'].to_json}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
class Web
|
|
7
|
+
# A fresh instance per request (never reused across requests/threads, so
|
|
8
|
+
# there's no cross-thread state to worry about). A registered route
|
|
9
|
+
# block is `instance_exec`'d against one of these, giving it bare
|
|
10
|
+
# `request`/`params`/`erb`/`redirect` the way TZ describes.
|
|
11
|
+
class Action
|
|
12
|
+
attr_reader :request, :params
|
|
13
|
+
|
|
14
|
+
def initialize(request, path_params)
|
|
15
|
+
@request = request
|
|
16
|
+
@params = request.params.merge(path_params)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# An extension's own routes may read either style — `params['x']` or
|
|
20
|
+
# `url_params('x')` (the latter is what the real History tab uses).
|
|
21
|
+
def url_params(key)
|
|
22
|
+
params[key.to_s]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# htmx sends this header on every request it issues (both its
|
|
26
|
+
# polling `hx-trigger` fetches and its `hx-post` form submissions), so
|
|
27
|
+
# a route can tell "the page is asking for a fragment to swap in"
|
|
28
|
+
# apart from a plain browser navigation — and fall back to a normal
|
|
29
|
+
# full-page render (or redirect) for anyone/anything without JS.
|
|
30
|
+
def hx_request?
|
|
31
|
+
request.get_header('HTTP_HX_REQUEST') == 'true'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Accepts a Symbol (a built-in template, looked up in web/views/) or a
|
|
35
|
+
# raw ERB-source String (an extension handing over `File.read(...)`
|
|
36
|
+
# itself, as the real History tab does).
|
|
37
|
+
def erb(template, locals: {})
|
|
38
|
+
source = template.is_a?(Symbol) ? Views.read(template) : template
|
|
39
|
+
locals_binding = build_binding(locals)
|
|
40
|
+
ERB.new(source).result(locals_binding)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def redirect(location)
|
|
44
|
+
# Header names must be lowercase per the Rack spec — Rack::Lint
|
|
45
|
+
# (which Puma's default dev environment runs requests through)
|
|
46
|
+
# raises on a capitalized 'Location'.
|
|
47
|
+
halt([302, { 'location' => location }, []])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def halt(response)
|
|
51
|
+
throw :halt, response
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build_binding(locals)
|
|
57
|
+
b = binding
|
|
58
|
+
locals.each { |k, v| b.local_variable_set(k, v) }
|
|
59
|
+
b
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
class Web
|
|
5
|
+
# The shared route table. Built-in tabs register through the exact same
|
|
6
|
+
# `self.get`/`register` mechanism external extensions use (dogfooding),
|
|
7
|
+
# so there's only one code path to keep working.
|
|
8
|
+
module Application
|
|
9
|
+
class << self
|
|
10
|
+
def routes
|
|
11
|
+
@routes ||= []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get(path, &block)
|
|
15
|
+
add_route('GET', path, &block)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def post(path, &block)
|
|
19
|
+
add_route('POST', path, &block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# `mod.registered(self)` — `self` here is this Application module,
|
|
23
|
+
# so inside `registered` the extension calls `app.get(...)` against
|
|
24
|
+
# it directly, matching TZ's Sinatra-style `self.registered(app)`.
|
|
25
|
+
def register(mod)
|
|
26
|
+
mod.registered(self)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(env)
|
|
30
|
+
request = Rack::Request.new(env)
|
|
31
|
+
# Rack::URLMap sets PATH_INFO to "" (not "/") for a request that
|
|
32
|
+
# exactly matches a mount point with no trailing slash — normalize
|
|
33
|
+
# it so the root route still matches when this app is `map`'d.
|
|
34
|
+
path_info = request.path_info.empty? ? '/' : request.path_info
|
|
35
|
+
route = routes.reverse.find do |r|
|
|
36
|
+
r[:method] == request.request_method && Router.match(r[:pattern], path_info)
|
|
37
|
+
end
|
|
38
|
+
return not_found unless route
|
|
39
|
+
|
|
40
|
+
captures = Router.match(route[:pattern], path_info)
|
|
41
|
+
action = Action.new(request, captures)
|
|
42
|
+
result = catch(:halt) { action.instance_exec(&route[:block]) }
|
|
43
|
+
rack_triple?(result) ? result : [200, { 'content-type' => 'text/html; charset=utf-8' }, [result.to_s]]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def add_route(method, path, &block)
|
|
49
|
+
routes << { method: method, pattern: Router.compile(path), block: block }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rack_triple?(value)
|
|
53
|
+
value.is_a?(Array) && value.size == 3 && value.first.is_a?(Integer)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def not_found
|
|
57
|
+
[404, { 'content-type' => 'text/plain' }, ['Not Found']]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|