jobtick 0.1.4 → 0.3.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/CHANGELOG.md +35 -0
- data/README.md +55 -10
- data/lib/jobtick/client.rb +12 -31
- data/lib/jobtick/configuration.rb +15 -5
- data/lib/jobtick/dispatcher.rb +305 -0
- data/lib/jobtick/hooks/active_job.rb +1 -1
- data/lib/jobtick/middleware/sidekiq.rb +1 -1
- data/lib/jobtick/monitor.rb +10 -6
- data/lib/jobtick/parsers/sidekiq.rb +2 -4
- data/lib/jobtick/parsers/solid_queue.rb +64 -14
- data/lib/jobtick/parsers/whenever.rb +60 -17
- data/lib/jobtick/parsers.rb +20 -0
- data/lib/jobtick/railtie.rb +34 -1
- data/lib/jobtick/registry.rb +35 -5
- data/lib/jobtick/version.rb +1 -1
- data/lib/jobtick/whenever_setup.rb +35 -14
- data/lib/jobtick.rb +19 -9
- data/lib/tasks/jobtick.rake +5 -0
- data/sig/jobtick.rbs +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e04ffd3cc7f9e27dc57953e65230cc43d518e3c03413ad1a0f39ef07ac9cde73
|
|
4
|
+
data.tar.gz: ab9648e703ca1baa1397f07a397dad8a5bac19d4c9773a32c64a82e8ca1e1f92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f6ed9a0f0486689336f4eccc6d0d7c75eb20038b999399240ddab1015b5c85d8411a4b7aa08e8ba21943998b5f5efcfa67a6b288a149684ffdd95942744df721
|
|
7
|
+
data.tar.gz: 4713a1d1f9ee0f4ee9e6e74de2dadac6f18b70fb85bb1f0036554a28feb487a35e1c2b01aea9fe3f7927a9beeb532adc1a87f3840a467cc660fe628fd5a98df8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
## [0.3.0] - 2026-08-20
|
|
2
|
+
|
|
3
|
+
- **Fix: Whenever monitors never actually registered.** `Parsers::Whenever` called a `Whenever::JobList#jobs` API that doesn't exist on the real gem (only `attr_reader :roles`), so every discovery attempt raised and was silently swallowed — no Whenever job has ever been registered by prior versions. The parser now reads the real, public `generate_cron_output` instead, and `WheneverSetup` injects the monitor key as a literal job option so the registered key and the pinged key are always the same value by construction. `config/schedule.rb` needs `require 'jobtick/whenever_setup'` before `JobTick::WheneverSetup.install!(self)` now — see the README.
|
|
4
|
+
- **Fix: the Whenever shell wrapper could send a false failure alert.** `inner && ping_completed || ping_failed` fired the `failed` ping whenever the *completed* ping's own `curl` call failed — even though the job succeeded. Rewritten as `if <job> ; then ... completed ; else ... failed ; fi`, plus `curl --max-time 10` so a hung ping can't wedge a cron slot, and `exit $rc` so cron still sees the job's real exit status.
|
|
5
|
+
- **Fix: the persistent HTTPS connection was fork-unsafe.** A clustered app server (Puma with `preload_app!`, Passenger, Unicorn) forking after boot left every worker sharing one file descriptor, corrupting or dropping pings under load. The dispatcher now detects a pid change on every call from `.enqueue`/`.send_sync` and rebuilds its connection and background thread in the child without touching the parent's socket.
|
|
6
|
+
- **Fix: `Configuration#environment`-scoped `config/recurring.yml` registered environment names as monitors.** When the current environment had no matching top-level key, the parser fell back to treating the whole file as a task list — registering `solid_queue.production`, `solid_queue.staging`, etc. as permanently-down monitors. It now returns no monitors (with a warning) instead.
|
|
7
|
+
- **Fix:** HTTP responses were previously discarded entirely — a bad API key returned 401 forever in total silence. The dispatcher now logs a 401/403 once and opens a circuit breaker so pings stop being sent to an API that's rejecting them; other error statuses are logged at a throttled rate.
|
|
8
|
+
- **Fix:** `Dispatcher.reset!` could leave a stale connection open (e.g. after only `.send_sync` had ever run), so a subsequent endpoint change kept posting to the old host.
|
|
9
|
+
- Add a circuit breaker: after 3 consecutive network failures, the dispatcher stops attempting connections for a backed-off window (30s–300s) instead of paying a connect timeout per queued ping during an outage.
|
|
10
|
+
- Boot-time sync no longer blocks: it now goes through the async dispatcher, and is skipped entirely for `rails console`, `rails runner`, and rake tasks (configurable via `config.sync_on_boot`). `rake jobtick:sync` remains a blocking call for use as an explicit deploy step.
|
|
11
|
+
- Add `config.ping_started` (default `true`) to suppress the `started` ping for high-frequency jobs.
|
|
12
|
+
- `Parsers::Whenever`/`Parsers::SolidQueue` now resolve their config file paths against `Rails.root` (or `Dir.pwd` outside Rails) instead of the process's current working directory.
|
|
13
|
+
- Two monitors that resolve to the same job class now log a warning naming both, instead of one silently losing its pings to the other.
|
|
14
|
+
- `Configuration#enabled?` is now used consistently by `Client` and `Monitor` instead of each open-coding the same check.
|
|
15
|
+
|
|
16
|
+
## [0.2.0] - 2026-05-28
|
|
17
|
+
|
|
18
|
+
- Performance: pings are now dispatched asynchronously on a single daemon thread, so job workers no longer block on network I/O. A persistent, keep-alive HTTPS connection is reused for all pings (no more TCP/TLS handshake per ping).
|
|
19
|
+
- Performance: switch to `Process.clock_gettime(CLOCK_MONOTONIC)` for duration measurement — no `Time` object allocation per job, and immune to wall-clock jumps.
|
|
20
|
+
- Performance: lazy-load parsers, hooks, middleware, and the registry — only Rails boots that have JobTick enabled pay for them.
|
|
21
|
+
- Performance: the monitor map is frozen after sync, and parser allocations are trimmed on the boot path.
|
|
22
|
+
- Add `Configuration#queue_limit` (default 1000) to bound the background ping queue; over-limit pings are dropped non-blockingly rather than back-pressuring the job thread.
|
|
23
|
+
|
|
24
|
+
### Measured impact
|
|
25
|
+
|
|
26
|
+
Benchmarked with `spec/benchmarks/monitor_bench.rb` (10,000 iterations, WebMock-stubbed endpoint so the numbers reflect gem-internal overhead, not real network latency):
|
|
27
|
+
|
|
28
|
+
| Metric (per monitored job) | v0.1.4 | v0.2.0 | Change |
|
|
29
|
+
|---|---:|---:|---:|
|
|
30
|
+
| Job-thread blocking time | 400.6 µs | 2.0 µs | **~200× faster** |
|
|
31
|
+
| Object allocations on job thread | 2,390 | 9 | **~265× fewer** |
|
|
32
|
+
| End-to-end CPU time (incl. background dispatch) | 400.6 µs | 18.1 µs | **~22× less CPU** |
|
|
33
|
+
|
|
34
|
+
In production, where each ping pays real network RTT, the job-thread speedup is significantly larger: a single 20 ms RTT × 2–3 pings per job is ~50 ms blocking under v0.1.4, versus ~2 µs under v0.2.0 (~25,000× on the worker thread). Run `bundle exec ruby spec/benchmarks/monitor_bench.rb [iterations]` to reproduce.
|
|
35
|
+
|
|
1
36
|
## [0.1.4] - 2026-05-05
|
|
2
37
|
|
|
3
38
|
- Add `prune` configuration option — when enabled, monitors absent from the latest sync payload are permanently deleted, keeping the dashboard in sync with your schedule config
|
data/README.md
CHANGED
|
@@ -58,6 +58,38 @@ That's it. On next deploy, JobTick reads your schedule config, registers a monit
|
|
|
58
58
|
|
|
59
59
|
No changes to individual job files. No manual monitor creation. No names to keep in sync.
|
|
60
60
|
|
|
61
|
+
### Environments
|
|
62
|
+
|
|
63
|
+
**JobTick is only active in production by default.** In `development`, `staging`, or any other environment it silently does nothing — no pings are sent, no monitors are registered, no errors are raised. This means you can deploy the gem and configure it without worrying about local runs polluting your monitors or counting toward your plan.
|
|
64
|
+
|
|
65
|
+
If you want to enable JobTick in a non-production environment (e.g. to test your setup on staging before going live), opt in explicitly:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# config/initializers/jobtick.rb
|
|
69
|
+
|
|
70
|
+
# Enable on staging only
|
|
71
|
+
JobTick.configure do |config|
|
|
72
|
+
config.api_key = ENV['JOBTICK_API_KEY']
|
|
73
|
+
config.enabled = Rails.env.production? || Rails.env.staging?
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# Enable everywhere — useful for a quick local smoke-test
|
|
79
|
+
JobTick.configure do |config|
|
|
80
|
+
config.api_key = ENV['JOBTICK_API_KEY']
|
|
81
|
+
config.enabled = true
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# Drive it from an env var so you can toggle without a deploy
|
|
87
|
+
JobTick.configure do |config|
|
|
88
|
+
config.api_key = ENV['JOBTICK_API_KEY']
|
|
89
|
+
config.enabled = ENV['JOBTICK_ENABLED'] == 'true'
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
61
93
|
### Removing stale monitors automatically
|
|
62
94
|
|
|
63
95
|
By default, monitors are only added — nothing is removed when you delete a job from your schedule. To have each deploy also clean up monitors that are no longer in your config, enable pruning:
|
|
@@ -71,6 +103,20 @@ end
|
|
|
71
103
|
|
|
72
104
|
With `prune` enabled, a deploy acts as the single source of truth: any monitor whose key is not present in the current payload is permanently deleted. You can also remove individual monitors manually from the JobTick dashboard at any time.
|
|
73
105
|
|
|
106
|
+
### Deploying
|
|
107
|
+
|
|
108
|
+
JobTick syncs your monitors automatically the first time a web or worker process boots after a deploy — this sync runs in the background and never blocks startup, even if the JobTick API is slow or unreachable.
|
|
109
|
+
|
|
110
|
+
To skip the wait until that first boot (or to sync from CI as an explicit deploy step), run it directly:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
bundle exec rake jobtick:sync
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
This is the one case where the sync blocks — it prints the number of monitors registered, so its output is only trustworthy if it actually waited for the API to respond.
|
|
117
|
+
|
|
118
|
+
Boot-time sync is automatically skipped for `rails console`, `rails runner`, and any rake task — none of them should pay for a sync just to boot, and `rails runner` matters especially, since Whenever's cron wrapper shells out through it on every tick. Set `config.sync_on_boot = false` to disable the automatic sync everywhere and rely solely on `rake jobtick:sync`.
|
|
119
|
+
|
|
74
120
|
---
|
|
75
121
|
|
|
76
122
|
## What gets monitored
|
|
@@ -90,6 +136,8 @@ sync_exchange_rates:
|
|
|
90
136
|
|
|
91
137
|
At boot, JobTick reads this file and registers a monitor for each entry. It then installs an `around_perform` hook into `ActiveJob::Base` so every job execution automatically sends `started`, `completed`, and `failed` pings. No changes to your job files.
|
|
92
138
|
|
|
139
|
+
Entries scheduled with `command:` instead of `class:` run a raw shell command rather than a Ruby job class, so there's no hook point for JobTick to instrument automatically — these are skipped (with a log line naming them) rather than registered as a monitor JobTick can never ping.
|
|
140
|
+
|
|
93
141
|
### Sidekiq periodic jobs
|
|
94
142
|
|
|
95
143
|
Supports both **sidekiq-cron** and **Sidekiq::Periodic**:
|
|
@@ -111,15 +159,19 @@ JobTick installs a server middleware that wraps every job execution. For native
|
|
|
111
159
|
|
|
112
160
|
### Whenever (`config/schedule.rb`)
|
|
113
161
|
|
|
114
|
-
Whenever schedules jobs as cron shell commands, so there is no Ruby hook point to instrument automatically. Add
|
|
162
|
+
Whenever schedules jobs as cron shell commands, so there is no Ruby hook point to instrument automatically. Add two lines to the top of `config/schedule.rb`:
|
|
115
163
|
|
|
116
164
|
```ruby
|
|
165
|
+
require 'jobtick/whenever_setup'
|
|
117
166
|
JobTick::WheneverSetup.install!(self)
|
|
118
167
|
```
|
|
119
168
|
|
|
120
|
-
|
|
169
|
+
`jobtick/whenever_setup` is a separate file from the main gem on purpose — `config/schedule.rb` is evaluated standalone (by `whenever --update-crontab`, and again by JobTick itself at boot to discover your jobs), not as part of a normal Rails boot, so it needs its own explicit require regardless of whether `gem 'jobtick'` is already in your Gemfile.
|
|
170
|
+
|
|
171
|
+
This overrides the built-in `runner`, `rake`, and `command` job types to wrap every execution with `curl` pings, gated by a real `if`/`then`/`else` so a network blip on the ping itself can never be mistaken for the job failing. Your existing schedule entries need no other changes:
|
|
121
172
|
|
|
122
173
|
```ruby
|
|
174
|
+
require 'jobtick/whenever_setup'
|
|
123
175
|
JobTick::WheneverSetup.install!(self)
|
|
124
176
|
|
|
125
177
|
every 1.day, at: '2:00 am' do
|
|
@@ -131,14 +183,7 @@ every :hour do
|
|
|
131
183
|
end
|
|
132
184
|
```
|
|
133
185
|
|
|
134
|
-
After adding
|
|
135
|
-
|
|
136
|
-
If jobtick is not already loaded via your Rails environment, require it first:
|
|
137
|
-
|
|
138
|
-
```ruby
|
|
139
|
-
require 'jobtick/whenever_setup'
|
|
140
|
-
JobTick::WheneverSetup.install!(self)
|
|
141
|
-
```
|
|
186
|
+
After adding these lines, run `whenever --update-crontab` as normal and all jobs will start sending heartbeats. JobTick discovers Whenever monitors by reading the same file back with the real `whenever` gem, so `whenever` must be a dependency of your app (it usually already is) — add it with `require: false` is fine, JobTick loads it itself when needed.
|
|
142
187
|
|
|
143
188
|
---
|
|
144
189
|
|
data/lib/jobtick/client.rb
CHANGED
|
@@ -1,53 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "json"
|
|
5
|
-
require "uri"
|
|
3
|
+
require_relative "dispatcher"
|
|
6
4
|
|
|
7
5
|
module JobTick
|
|
8
6
|
class Client
|
|
9
|
-
|
|
7
|
+
PING_PREFIX = "/ping/"
|
|
8
|
+
SYNC_PATH = "/monitors/sync"
|
|
10
9
|
|
|
11
10
|
def ping(monitor_key, status:, duration: nil, message: nil)
|
|
12
|
-
return unless JobTick.config.enabled
|
|
13
|
-
return if JobTick.config.api_key.nil?
|
|
11
|
+
return unless JobTick.config.enabled?
|
|
14
12
|
|
|
15
13
|
payload = { status: status }
|
|
16
14
|
payload[:duration] = duration.round(3) if duration
|
|
17
15
|
payload[:message] = message if message
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
Dispatcher.enqueue("#{PING_PREFIX}#{monitor_key}", payload)
|
|
20
18
|
end
|
|
21
19
|
|
|
22
|
-
def register(monitors, app_name: nil, prune: false)
|
|
23
|
-
return unless JobTick.config.enabled
|
|
24
|
-
return if JobTick.config.api_key.nil?
|
|
20
|
+
def register(monitors, app_name: nil, prune: false, sync: true)
|
|
21
|
+
return unless JobTick.config.enabled?
|
|
25
22
|
|
|
26
23
|
payload = { monitors: monitors }
|
|
27
24
|
payload[:app_name] = app_name if app_name && !app_name.empty?
|
|
28
25
|
payload[:prune] = true if prune
|
|
29
|
-
post("/monitors/sync", payload)
|
|
30
|
-
end
|
|
31
26
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
http.use_ssl = uri.scheme == "https"
|
|
38
|
-
http.open_timeout = TIMEOUT
|
|
39
|
-
http.read_timeout = TIMEOUT
|
|
40
|
-
|
|
41
|
-
request = Net::HTTP::Post.new(uri)
|
|
42
|
-
request["Content-Type"] = "application/json"
|
|
43
|
-
request["Authorization"] = "Bearer #{JobTick.config.api_key}"
|
|
44
|
-
request["User-Agent"] = "jobtick-ruby/#{JobTick::VERSION}"
|
|
45
|
-
request.body = body.to_json
|
|
46
|
-
|
|
47
|
-
http.request(request)
|
|
48
|
-
rescue StandardError => e
|
|
49
|
-
JobTick.logger.warn("[JobTick] HTTP request failed (#{path}): #{e.message}")
|
|
50
|
-
nil
|
|
27
|
+
if sync
|
|
28
|
+
Dispatcher.send_sync(SYNC_PATH, payload)
|
|
29
|
+
else
|
|
30
|
+
Dispatcher.enqueue(SYNC_PATH, payload)
|
|
31
|
+
end
|
|
51
32
|
end
|
|
52
33
|
end
|
|
53
34
|
end
|
|
@@ -2,13 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module JobTick
|
|
4
4
|
class Configuration
|
|
5
|
-
|
|
5
|
+
DEFAULT_QUEUE_LIMIT = 1000
|
|
6
|
+
|
|
7
|
+
attr_accessor :api_key, :endpoint, :environment, :enabled, :prune, :queue_limit,
|
|
8
|
+
:sync_on_boot, :ping_started
|
|
6
9
|
|
|
7
10
|
def initialize
|
|
8
|
-
@endpoint
|
|
9
|
-
@environment
|
|
10
|
-
@enabled
|
|
11
|
-
@prune
|
|
11
|
+
@endpoint = "https://api.jobtick.app/v1"
|
|
12
|
+
@environment = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "production"
|
|
13
|
+
@enabled = @environment == "production"
|
|
14
|
+
@prune = false
|
|
15
|
+
@queue_limit = DEFAULT_QUEUE_LIMIT
|
|
16
|
+
@sync_on_boot = true
|
|
17
|
+
@ping_started = true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enabled?
|
|
21
|
+
@enabled && !@api_key.nil?
|
|
12
22
|
end
|
|
13
23
|
end
|
|
14
24
|
end
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
module JobTick
|
|
9
|
+
# Asynchronous, single-threaded HTTP dispatcher with a persistent keep-alive
|
|
10
|
+
# connection. Job threads call .enqueue and return immediately; the dispatcher
|
|
11
|
+
# daemon thread drains the queue and posts to the JobTick API.
|
|
12
|
+
#
|
|
13
|
+
# All HTTP work (sync register + async pings) shares one Net::HTTP instance
|
|
14
|
+
# serialized by @http_mutex. The connection is reopened lazily after errors.
|
|
15
|
+
#
|
|
16
|
+
# Fork safety: .enqueue and .send_sync are always called from the thread
|
|
17
|
+
# that survives a fork (the caller's thread — a job thread, or the process
|
|
18
|
+
# that just booted). Neither the background dispatcher thread nor its
|
|
19
|
+
# Net::HTTP socket survive a fork, even though the Ruby objects referencing
|
|
20
|
+
# them do (they're just inherited memory). guard_fork! runs first on both
|
|
21
|
+
# public entry points and drops those stale references — without closing
|
|
22
|
+
# the socket, which still belongs to the parent — so each process lazily
|
|
23
|
+
# builds its own connection and dispatcher thread.
|
|
24
|
+
module Dispatcher
|
|
25
|
+
SHUTDOWN_SIGNAL = :__shutdown__
|
|
26
|
+
FLUSH_SENTINEL = :__flush__
|
|
27
|
+
HEADER_CONTENT_TYPE = "application/json"
|
|
28
|
+
USER_AGENT = "jobtick-ruby/#{JobTick::VERSION}".freeze
|
|
29
|
+
OPEN_TIMEOUT = 5
|
|
30
|
+
READ_TIMEOUT = 5
|
|
31
|
+
KEEP_ALIVE_TIMEOUT = 30
|
|
32
|
+
|
|
33
|
+
UNAUTHORIZED_CODES = %w[401 403].freeze
|
|
34
|
+
FAILURE_THRESHOLD = 3
|
|
35
|
+
CIRCUIT_INITIAL_BACKOFF = 30
|
|
36
|
+
CIRCUIT_MAX_BACKOFF = 300
|
|
37
|
+
STATUS_WARN_INTERVAL = 60
|
|
38
|
+
|
|
39
|
+
NETWORK_ERRORS = [
|
|
40
|
+
IOError, EOFError,
|
|
41
|
+
Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ECONNABORTED,
|
|
42
|
+
Errno::EPIPE, Errno::ETIMEDOUT, Errno::EHOSTUNREACH,
|
|
43
|
+
Net::OpenTimeout, Net::ReadTimeout,
|
|
44
|
+
OpenSSL::SSL::SSLError, SocketError
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
attr_accessor :synchronous
|
|
49
|
+
|
|
50
|
+
def enqueue(path, payload)
|
|
51
|
+
return send_request(path, payload) if @synchronous
|
|
52
|
+
|
|
53
|
+
guard_fork!
|
|
54
|
+
ensure_started
|
|
55
|
+
@queue.push([path, payload], true)
|
|
56
|
+
nil
|
|
57
|
+
rescue ThreadError
|
|
58
|
+
dropped_mutex.synchronize { @dropped = (@dropped || 0) + 1 }
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def send_sync(path, payload)
|
|
63
|
+
guard_fork!
|
|
64
|
+
send_request(path, payload)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def flush(timeout: 5)
|
|
68
|
+
return unless @running && @queue && @thread&.alive?
|
|
69
|
+
|
|
70
|
+
ack = Queue.new
|
|
71
|
+
begin
|
|
72
|
+
@queue.push([FLUSH_SENTINEL, ack])
|
|
73
|
+
rescue ThreadError
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
ack.pop(timeout: timeout)
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def shutdown(timeout: 2)
|
|
81
|
+
return unless @running
|
|
82
|
+
|
|
83
|
+
@running = false
|
|
84
|
+
@queue&.push(SHUTDOWN_SIGNAL)
|
|
85
|
+
@thread&.join(timeout)
|
|
86
|
+
close_http
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def dropped
|
|
91
|
+
@dropped || 0
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reset!
|
|
95
|
+
shutdown(timeout: 1) if @running
|
|
96
|
+
close_http # shutdown is a no-op if only .send_sync ever ran, so close explicitly too
|
|
97
|
+
@queue = nil
|
|
98
|
+
@thread = nil
|
|
99
|
+
@dropped = 0
|
|
100
|
+
@endpoint_uri = nil
|
|
101
|
+
@at_exit_registered = false
|
|
102
|
+
@synchronous = false
|
|
103
|
+
@pid = nil
|
|
104
|
+
@consecutive_failures = 0
|
|
105
|
+
@circuit_until = nil
|
|
106
|
+
@unauthorized_warned = false
|
|
107
|
+
@status_warned_at = nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Atomic under fork_mutex so a thread that loses the race can never null
|
|
113
|
+
# out @queue/@http *after* another thread has already rebuilt them for
|
|
114
|
+
# the new pid — once this returns, @pid always matches Process.pid, and
|
|
115
|
+
# that transition only ever happens once per fork.
|
|
116
|
+
def guard_fork!
|
|
117
|
+
return if @pid == Process.pid
|
|
118
|
+
|
|
119
|
+
fork_mutex.synchronize do
|
|
120
|
+
return if @pid == Process.pid
|
|
121
|
+
|
|
122
|
+
http_mutex.synchronize { @http = nil } # not #finish — the socket belongs to the parent
|
|
123
|
+
boot_mutex.synchronize do
|
|
124
|
+
@queue = nil
|
|
125
|
+
@thread = nil
|
|
126
|
+
@running = false
|
|
127
|
+
end
|
|
128
|
+
@pid = Process.pid
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def fork_mutex
|
|
133
|
+
@fork_mutex ||= Mutex.new
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def ensure_started
|
|
137
|
+
return if @running
|
|
138
|
+
|
|
139
|
+
boot_mutex.synchronize do
|
|
140
|
+
return if @running
|
|
141
|
+
|
|
142
|
+
@queue = SizedQueue.new(queue_limit)
|
|
143
|
+
@dropped = 0
|
|
144
|
+
@thread = Thread.new { run_loop }
|
|
145
|
+
@thread.name = "jobtick-dispatcher" if @thread.respond_to?(:name=)
|
|
146
|
+
@running = true
|
|
147
|
+
register_at_exit
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def boot_mutex
|
|
152
|
+
@boot_mutex ||= Mutex.new
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def http_mutex
|
|
156
|
+
@http_mutex ||= Mutex.new
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def dropped_mutex
|
|
160
|
+
@dropped_mutex ||= Mutex.new
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def queue_limit
|
|
164
|
+
JobTick.config.queue_limit || Configuration::DEFAULT_QUEUE_LIMIT
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def register_at_exit
|
|
168
|
+
return if @at_exit_registered
|
|
169
|
+
|
|
170
|
+
@at_exit_registered = true
|
|
171
|
+
at_exit { shutdown }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def run_loop
|
|
175
|
+
while (item = @queue.pop)
|
|
176
|
+
break if item == SHUTDOWN_SIGNAL
|
|
177
|
+
|
|
178
|
+
key, payload = item
|
|
179
|
+
if key.equal?(FLUSH_SENTINEL)
|
|
180
|
+
payload.push(true)
|
|
181
|
+
next
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
send_request(key, payload)
|
|
185
|
+
end
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
JobTick.logger.warn("[JobTick] Dispatcher thread crashed: #{e.message}")
|
|
188
|
+
ensure
|
|
189
|
+
close_http
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def send_request(path, payload)
|
|
193
|
+
return nil if circuit_open?
|
|
194
|
+
|
|
195
|
+
response = http_mutex.synchronize { http_connection.request(build_request(path, payload)) }
|
|
196
|
+
handle_response(path, response)
|
|
197
|
+
response
|
|
198
|
+
rescue *NETWORK_ERRORS => e
|
|
199
|
+
JobTick.logger.warn("[JobTick] HTTP request failed (#{path}): #{e.message}")
|
|
200
|
+
teardown_http
|
|
201
|
+
record_failure
|
|
202
|
+
nil
|
|
203
|
+
rescue StandardError => e
|
|
204
|
+
JobTick.logger.warn("[JobTick] HTTP request failed (#{path}): #{e.message}")
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def build_request(path, payload)
|
|
209
|
+
request = Net::HTTP::Post.new("#{endpoint_uri.path}#{path}")
|
|
210
|
+
request["Content-Type"] = HEADER_CONTENT_TYPE
|
|
211
|
+
request["Authorization"] = "Bearer #{JobTick.config.api_key}"
|
|
212
|
+
request["User-Agent"] = USER_AGENT
|
|
213
|
+
request.body = JSON.generate(payload)
|
|
214
|
+
request
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def handle_response(path, response)
|
|
218
|
+
code = response.code
|
|
219
|
+
|
|
220
|
+
if code.start_with?("2")
|
|
221
|
+
record_success
|
|
222
|
+
elsif UNAUTHORIZED_CODES.include?(code)
|
|
223
|
+
warn_unauthorized_once
|
|
224
|
+
open_circuit(CIRCUIT_MAX_BACKOFF)
|
|
225
|
+
else
|
|
226
|
+
record_failure
|
|
227
|
+
warn_status_throttled(code, path)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def circuit_open?
|
|
232
|
+
!@circuit_until.nil? && monotonic < @circuit_until
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def open_circuit(seconds)
|
|
236
|
+
@circuit_until = monotonic + seconds + rand(5)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def record_failure
|
|
240
|
+
@consecutive_failures = (@consecutive_failures || 0) + 1
|
|
241
|
+
return if @consecutive_failures < FAILURE_THRESHOLD
|
|
242
|
+
|
|
243
|
+
backoff = CIRCUIT_INITIAL_BACKOFF * (2**(@consecutive_failures - FAILURE_THRESHOLD))
|
|
244
|
+
open_circuit([backoff, CIRCUIT_MAX_BACKOFF].min)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def record_success
|
|
248
|
+
@consecutive_failures = 0
|
|
249
|
+
@circuit_until = nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def warn_unauthorized_once
|
|
253
|
+
return if @unauthorized_warned
|
|
254
|
+
|
|
255
|
+
@unauthorized_warned = true
|
|
256
|
+
JobTick.logger.warn("[JobTick] API rejected the API key (401/403); pings are being discarded")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def warn_status_throttled(code, path)
|
|
260
|
+
@status_warned_at ||= {}
|
|
261
|
+
last = @status_warned_at[code]
|
|
262
|
+
now = monotonic
|
|
263
|
+
return if last && (now - last) < STATUS_WARN_INTERVAL
|
|
264
|
+
|
|
265
|
+
@status_warned_at[code] = now
|
|
266
|
+
JobTick.logger.warn("[JobTick] API returned #{code} (#{path})")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def endpoint_uri
|
|
270
|
+
@endpoint_uri ||= URI(JobTick.config.endpoint)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def http_connection
|
|
274
|
+
return @http if @http&.started?
|
|
275
|
+
|
|
276
|
+
uri = endpoint_uri
|
|
277
|
+
@http = Net::HTTP.new(uri.host, uri.port)
|
|
278
|
+
@http.use_ssl = uri.scheme == "https"
|
|
279
|
+
@http.open_timeout = OPEN_TIMEOUT
|
|
280
|
+
@http.read_timeout = READ_TIMEOUT
|
|
281
|
+
@http.keep_alive_timeout = KEEP_ALIVE_TIMEOUT
|
|
282
|
+
@http.start
|
|
283
|
+
@http
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def teardown_http
|
|
287
|
+
return unless @http
|
|
288
|
+
|
|
289
|
+
@http.finish if @http.started?
|
|
290
|
+
rescue StandardError
|
|
291
|
+
nil
|
|
292
|
+
ensure
|
|
293
|
+
@http = nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def close_http
|
|
297
|
+
http_mutex.synchronize { teardown_http }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def monotonic
|
|
301
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
data/lib/jobtick/monitor.rb
CHANGED
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module JobTick
|
|
4
4
|
class Monitor
|
|
5
|
+
MONOTONIC = Process::CLOCK_MONOTONIC
|
|
6
|
+
|
|
5
7
|
def self.run(key)
|
|
6
|
-
|
|
8
|
+
config = JobTick.config
|
|
9
|
+
return yield unless config.enabled?
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
client = JobTick.client
|
|
12
|
+
client.ping(key, status: :started) if config.ping_started
|
|
13
|
+
started = Process.clock_gettime(MONOTONIC)
|
|
14
|
+
result = yield
|
|
15
|
+
duration = Process.clock_gettime(MONOTONIC) - started
|
|
16
|
+
client.ping(key, status: :completed, duration: duration)
|
|
13
17
|
result
|
|
14
18
|
rescue StandardError => e
|
|
15
19
|
JobTick.client.ping(key, status: :failed, message: e.message)
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../parsers"
|
|
4
|
+
|
|
3
5
|
module JobTick
|
|
4
6
|
module Parsers
|
|
5
|
-
def self.slugify(str)
|
|
6
|
-
str.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
|
|
7
|
-
end
|
|
8
|
-
|
|
9
7
|
class Sidekiq
|
|
10
8
|
def self.parse
|
|
11
9
|
return [] unless defined?(::Sidekiq)
|
|
@@ -8,28 +8,78 @@ module JobTick
|
|
|
8
8
|
RECURRING_FILE = "config/recurring.yml"
|
|
9
9
|
|
|
10
10
|
def self.parse
|
|
11
|
-
return [] unless File.exist?(
|
|
11
|
+
return [] unless File.exist?(recurring_path)
|
|
12
12
|
|
|
13
|
-
yaml = YAML.load_file(
|
|
14
|
-
|
|
13
|
+
yaml = YAML.load_file(recurring_path, aliases: true)
|
|
14
|
+
return [] unless yaml.is_a?(Hash)
|
|
15
15
|
|
|
16
|
-
tasks = yaml
|
|
17
|
-
return []
|
|
16
|
+
tasks = resolve_tasks(yaml)
|
|
17
|
+
return [] if tasks.empty?
|
|
18
18
|
|
|
19
|
-
tasks.
|
|
20
|
-
|
|
19
|
+
class_tasks, command_only = tasks.partition { |_, config| task_class?(config) }
|
|
20
|
+
warn_command_only(command_only) if command_only.any?
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
key: "solid_queue.#{key}",
|
|
24
|
-
schedule: config["schedule"],
|
|
25
|
-
source: "solid_queue",
|
|
26
|
-
task: config["class"]
|
|
27
|
-
}
|
|
28
|
-
end.compact
|
|
22
|
+
class_tasks.map { |key, config| monitor_for(key, config) }
|
|
29
23
|
rescue StandardError => e
|
|
30
24
|
JobTick.logger.warn("[JobTick] Solid Queue parser failed: #{e.message}")
|
|
31
25
|
[]
|
|
32
26
|
end
|
|
27
|
+
|
|
28
|
+
# recurring.yml is either flat (task name => task config at the top
|
|
29
|
+
# level) or environment-scoped (environment name => { task name =>
|
|
30
|
+
# task config }). Distinguish by whether any top-level value actually
|
|
31
|
+
# looks like a task, rather than assuming the current environment is
|
|
32
|
+
# always present — a document scoped to "production"/"development"
|
|
33
|
+
# read under "staging" used to fall through to `yaml` itself and
|
|
34
|
+
# register every *environment name* as an unpingable monitor.
|
|
35
|
+
def self.resolve_tasks(yaml)
|
|
36
|
+
return yaml if yaml.any? { |_, v| task?(v) }
|
|
37
|
+
|
|
38
|
+
env = JobTick.config.environment
|
|
39
|
+
scoped = yaml[env] || yaml["default"]
|
|
40
|
+
return scoped if scoped.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
JobTick.logger.warn(
|
|
43
|
+
"[JobTick] #{RECURRING_FILE} is environment-scoped but has no entry for " \
|
|
44
|
+
"\"#{env}\" (or \"default\"); no Solid Queue monitors registered"
|
|
45
|
+
)
|
|
46
|
+
{}
|
|
47
|
+
end
|
|
48
|
+
private_class_method :resolve_tasks
|
|
49
|
+
|
|
50
|
+
def self.task?(value)
|
|
51
|
+
value.is_a?(Hash) && (value.key?("class") || value.key?("command"))
|
|
52
|
+
end
|
|
53
|
+
private_class_method :task?
|
|
54
|
+
|
|
55
|
+
def self.task_class?(value)
|
|
56
|
+
value.is_a?(Hash) && value.key?("class")
|
|
57
|
+
end
|
|
58
|
+
private_class_method :task_class?
|
|
59
|
+
|
|
60
|
+
def self.monitor_for(key, config)
|
|
61
|
+
{ key: "solid_queue.#{key}", schedule: config["schedule"], source: "solid_queue", task: config["class"] }
|
|
62
|
+
end
|
|
63
|
+
private_class_method :monitor_for
|
|
64
|
+
|
|
65
|
+
# command: tasks run a raw shell command rather than a Ruby job class,
|
|
66
|
+
# so there's no ActiveJob/Sidekiq hook to ping them from — unlike
|
|
67
|
+
# Whenever, Solid Queue gives us no shell wrapping point either.
|
|
68
|
+
# Registering them anyway would create monitors that alert as
|
|
69
|
+
# permanently down, so we skip them and say why once.
|
|
70
|
+
def self.warn_command_only(command_only)
|
|
71
|
+
keys = command_only.map(&:first).join(", ")
|
|
72
|
+
JobTick.logger.warn(
|
|
73
|
+
"[JobTick] #{RECURRING_FILE} declares command-based task(s) (#{keys}) which have no " \
|
|
74
|
+
"Ruby class to hook into; JobTick cannot monitor them automatically"
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
private_class_method :warn_command_only
|
|
78
|
+
|
|
79
|
+
def self.recurring_path
|
|
80
|
+
File.expand_path(RECURRING_FILE, JobTick.root)
|
|
81
|
+
end
|
|
82
|
+
private_class_method :recurring_path
|
|
33
83
|
end
|
|
34
84
|
end
|
|
35
85
|
end
|
|
@@ -1,34 +1,77 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../parsers"
|
|
4
|
+
|
|
3
5
|
module JobTick
|
|
4
6
|
module Parsers
|
|
5
7
|
class Whenever
|
|
6
8
|
SCHEDULE_FILE = "config/schedule.rb"
|
|
9
|
+
JOBTICK_KEY_RE = /JOBTICK_KEY=(\S+)/
|
|
7
10
|
|
|
11
|
+
# Whenever::JobList has no public reader for its parsed jobs (only
|
|
12
|
+
# attr_reader :roles), and its internal @jobs is a private, nested
|
|
13
|
+
# [mailto][time_scope] => [Job] structure with no [] accessor on Job
|
|
14
|
+
# either. The only stable, public surface is #generate_cron_output —
|
|
15
|
+
# the same text Whenever writes to the crontab. We scan that instead
|
|
16
|
+
# of reaching into private internals.
|
|
17
|
+
#
|
|
18
|
+
# This only finds anything once JobTick::WheneverSetup.install!(self)
|
|
19
|
+
# has been added to config/schedule.rb: that's what stamps a literal
|
|
20
|
+
# JOBTICK_KEY=<key> into each job's command, which is what we key off
|
|
21
|
+
# of here. That also guarantees the registered key and the pinged key
|
|
22
|
+
# can never drift apart — they're the same literal.
|
|
8
23
|
def self.parse
|
|
9
|
-
return [] unless
|
|
10
|
-
return [] unless File.exist?(
|
|
11
|
-
|
|
12
|
-
schedule = ::Whenever::JobList.new(file:
|
|
13
|
-
schedule.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
schedule: period.to_s,
|
|
18
|
-
source: "whenever",
|
|
19
|
-
task: job[:task].to_s.strip
|
|
20
|
-
}
|
|
21
|
-
end
|
|
22
|
-
end
|
|
24
|
+
return [] unless whenever_available?
|
|
25
|
+
return [] unless File.exist?(schedule_path)
|
|
26
|
+
|
|
27
|
+
schedule = ::Whenever::JobList.new(file: schedule_path)
|
|
28
|
+
monitors = schedule.generate_cron_output.to_s.each_line.filter_map { |line| monitor_from_line(line) }
|
|
29
|
+
|
|
30
|
+
warn_if_not_installed if monitors.empty?
|
|
31
|
+
monitors
|
|
23
32
|
rescue StandardError => e
|
|
24
33
|
JobTick.logger.warn("[JobTick] Whenever parser failed: #{e.message}")
|
|
25
34
|
[]
|
|
26
35
|
end
|
|
27
36
|
|
|
28
|
-
def self.
|
|
29
|
-
|
|
37
|
+
def self.monitor_from_line(line)
|
|
38
|
+
key = line[JOBTICK_KEY_RE, 1]
|
|
39
|
+
return nil unless key
|
|
40
|
+
|
|
41
|
+
{ key: key, schedule: cron_fields(line), source: "whenever", task: nil }
|
|
42
|
+
end
|
|
43
|
+
private_class_method :monitor_from_line
|
|
44
|
+
|
|
45
|
+
def self.cron_fields(line)
|
|
46
|
+
line = line.strip
|
|
47
|
+
return line[/\A@\S+/] if line.start_with?("@")
|
|
48
|
+
|
|
49
|
+
line.split(/\s+/, 6).first(5).join(" ")
|
|
50
|
+
end
|
|
51
|
+
private_class_method :cron_fields
|
|
52
|
+
|
|
53
|
+
def self.whenever_available?
|
|
54
|
+
return true if defined?(::Whenever::JobList)
|
|
55
|
+
|
|
56
|
+
require "whenever"
|
|
57
|
+
true
|
|
58
|
+
rescue LoadError
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
private_class_method :whenever_available?
|
|
62
|
+
|
|
63
|
+
def self.warn_if_not_installed
|
|
64
|
+
JobTick.logger.warn(
|
|
65
|
+
"[JobTick] #{SCHEDULE_FILE} found but JobTick::WheneverSetup.install!(self) " \
|
|
66
|
+
"is not installed there; no Whenever monitors registered"
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
private_class_method :warn_if_not_installed
|
|
70
|
+
|
|
71
|
+
def self.schedule_path
|
|
72
|
+
File.expand_path(SCHEDULE_FILE, JobTick.root)
|
|
30
73
|
end
|
|
31
|
-
private_class_method :
|
|
74
|
+
private_class_method :schedule_path
|
|
32
75
|
end
|
|
33
76
|
end
|
|
34
77
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobTick
|
|
4
|
+
module Parsers
|
|
5
|
+
SLUG_RE = /[^a-z0-9]+/
|
|
6
|
+
SLUG_TRIM_RE = /\A_+|_+\z/
|
|
7
|
+
|
|
8
|
+
def self.slugify(str)
|
|
9
|
+
str.downcase.gsub(SLUG_RE, "_").gsub(SLUG_TRIM_RE, "")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Shared by Parsers::Whenever (which reads it back out of the generated
|
|
13
|
+
# crontab) and WheneverSetup (which injects it as a job option). Keeping
|
|
14
|
+
# both sides derived from this one method is what guarantees the key a
|
|
15
|
+
# monitor is registered under matches the key its pings are sent to.
|
|
16
|
+
def self.whenever_key(task)
|
|
17
|
+
"whenever.#{slugify(task.to_s.strip)}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/jobtick/railtie.rb
CHANGED
|
@@ -2,11 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module JobTick
|
|
4
4
|
class Railtie < Rails::Railtie
|
|
5
|
+
# Processes where a boot-time sync would be pure waste: `rails console`,
|
|
6
|
+
# any rake task, and `rails runner` — the latter matters most, since
|
|
7
|
+
# WheneverSetup shells out through `bundle exec rails runner` on every
|
|
8
|
+
# cron tick, so without this a minutely job would pay a full sync POST
|
|
9
|
+
# every minute just to boot.
|
|
10
|
+
ONE_OFF_PROCESSES = %w[rake].freeze
|
|
11
|
+
|
|
5
12
|
initializer "jobtick.sync_registry" do
|
|
6
13
|
ActiveSupport.on_load(:after_initialize) do
|
|
7
14
|
next unless JobTick.config.enabled
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
require_relative "parsers/whenever"
|
|
17
|
+
require_relative "parsers/solid_queue"
|
|
18
|
+
require_relative "parsers/sidekiq"
|
|
19
|
+
require_relative "registry"
|
|
20
|
+
require_relative "hooks/active_job"
|
|
21
|
+
require_relative "middleware/sidekiq"
|
|
22
|
+
|
|
23
|
+
JobTick::Registry.sync(sync: false) if JobTick::Railtie.sync_on_boot?
|
|
10
24
|
|
|
11
25
|
::ActiveJob::Base.include(JobTick::Hooks::ActiveJob) if defined?(::ActiveJob::Base)
|
|
12
26
|
JobTick::Middleware::Sidekiq.install if defined?(::Sidekiq)
|
|
@@ -16,5 +30,24 @@ module JobTick
|
|
|
16
30
|
rake_tasks do
|
|
17
31
|
load File.expand_path("../tasks/jobtick.rake", __dir__)
|
|
18
32
|
end
|
|
33
|
+
|
|
34
|
+
def self.sync_on_boot?
|
|
35
|
+
return false unless JobTick.config.sync_on_boot
|
|
36
|
+
|
|
37
|
+
!one_off_process?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `Rails::Console` / `Rails::Command::RunnerCommand` / `Rails::Command::RakeCommand`
|
|
41
|
+
# are only defined when boot was reached via `bin/rails console|runner|<task>` — the
|
|
42
|
+
# command file that defines each constant has to be loaded before boot can start.
|
|
43
|
+
# A plain `bundle exec rake <task>` never loads railties' command layer at all, so
|
|
44
|
+
# it's caught by the $PROGRAM_NAME basename check instead.
|
|
45
|
+
def self.one_off_process?
|
|
46
|
+
return true if defined?(Rails::Console)
|
|
47
|
+
return true if defined?(Rails::Command::RunnerCommand)
|
|
48
|
+
return true if defined?(Rails::Command::RakeCommand)
|
|
49
|
+
|
|
50
|
+
ONE_OFF_PROCESSES.include?(File.basename($PROGRAM_NAME.to_s, ".*"))
|
|
51
|
+
end
|
|
19
52
|
end
|
|
20
53
|
end
|
data/lib/jobtick/registry.rb
CHANGED
|
@@ -2,24 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
module JobTick
|
|
4
4
|
class Registry
|
|
5
|
-
|
|
5
|
+
# sync: true blocks the caller until the register POST completes (used by
|
|
6
|
+
# `rake jobtick:sync`, whose printed count must be truthful). The railtie
|
|
7
|
+
# boot hook passes sync: false so a slow or unreachable API cannot delay
|
|
8
|
+
# every process boot.
|
|
9
|
+
def self.sync(sync: true)
|
|
6
10
|
monitors = [
|
|
7
11
|
Parsers::Whenever.parse,
|
|
8
12
|
Parsers::SolidQueue.parse,
|
|
9
13
|
Parsers::Sidekiq.parse
|
|
10
14
|
].flatten.compact
|
|
11
15
|
|
|
12
|
-
JobTick.monitor_map = monitors
|
|
13
|
-
map[m[:task]] = m[:key] if m[:task]
|
|
14
|
-
end
|
|
16
|
+
JobTick.monitor_map = build_monitor_map(monitors)
|
|
15
17
|
|
|
16
18
|
return [] if monitors.empty?
|
|
17
19
|
|
|
18
20
|
app_name = Rails.application.class.module_parent_name if defined?(Rails)
|
|
19
|
-
options = { app_name: app_name }
|
|
21
|
+
options = { app_name: app_name, sync: sync }
|
|
20
22
|
options[:prune] = true if JobTick.config.prune
|
|
21
23
|
JobTick.client.register(monitors, **options)
|
|
22
24
|
monitors
|
|
23
25
|
end
|
|
26
|
+
|
|
27
|
+
# Builds the class-name => monitor-key map the ActiveJob hook and Sidekiq
|
|
28
|
+
# middleware use to find a job's monitor. Two monitors can legitimately
|
|
29
|
+
# target the same class (e.g. the same recurring job class scheduled
|
|
30
|
+
# twice with different arguments) — that's a real config ambiguity, not a
|
|
31
|
+
# crash, so we log it and keep the first mapping rather than silently
|
|
32
|
+
# letting the second overwrite it.
|
|
33
|
+
def self.build_monitor_map(monitors)
|
|
34
|
+
map = {}
|
|
35
|
+
|
|
36
|
+
monitors.each do |monitor|
|
|
37
|
+
task = monitor[:task]
|
|
38
|
+
next unless task
|
|
39
|
+
|
|
40
|
+
if map.key?(task)
|
|
41
|
+
JobTick.logger.warn(
|
|
42
|
+
"[JobTick] Multiple monitors target #{task} (#{map[task]} and #{monitor[:key]}); " \
|
|
43
|
+
"only #{map[task]} will receive pings for it"
|
|
44
|
+
)
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
map[task] = monitor[:key]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
map.freeze
|
|
52
|
+
end
|
|
53
|
+
private_class_method :build_monitor_map
|
|
24
54
|
end
|
|
25
55
|
end
|
data/lib/jobtick/version.rb
CHANGED
|
@@ -11,27 +11,48 @@ module JobTick
|
|
|
11
11
|
#
|
|
12
12
|
# This replaces the :runner, :rake, and :command job types so that every
|
|
13
13
|
# scheduled job automatically sends started/completed/failed heartbeats without
|
|
14
|
-
# any per-job configuration.
|
|
14
|
+
# any per-job configuration. The monitor key is injected as a literal job
|
|
15
|
+
# option (:jobtick_key) rather than recomputed in shell, so it always matches
|
|
16
|
+
# the key Parsers::Whenever reads back out of the generated crontab.
|
|
15
17
|
module WheneverSetup
|
|
18
|
+
TEMPLATES = {
|
|
19
|
+
runner: "cd :path && bundle exec rails runner ':task' :output",
|
|
20
|
+
rake: "cd :path && bundle exec rake :task :output",
|
|
21
|
+
command: ":task :output"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
KEY_INJECTOR = Module.new do
|
|
25
|
+
TEMPLATES.each_key do |type|
|
|
26
|
+
define_method(type) do |task, *args|
|
|
27
|
+
opts = args[0].is_a?(Hash) ? args[0].dup : {}
|
|
28
|
+
opts[:jobtick_key] = JobTick::Parsers.whenever_key(task)
|
|
29
|
+
super(task, opts)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
private_constant :KEY_INJECTOR
|
|
34
|
+
|
|
16
35
|
def self.install!(schedule)
|
|
17
36
|
endpoint = JobTick.config.endpoint
|
|
18
37
|
|
|
19
|
-
schedule.job_type
|
|
20
|
-
|
|
21
|
-
|
|
38
|
+
TEMPLATES.each { |type, inner| schedule.job_type(type, wrap(endpoint, inner)) }
|
|
39
|
+
|
|
40
|
+
# Prepended after job_type defines the singleton methods above, but order
|
|
41
|
+
# doesn't matter for resolution: a prepended module always sits ahead of
|
|
42
|
+
# the singleton class in the ancestor chain, so this always wins and
|
|
43
|
+
# `super` always reaches the method job_type just defined.
|
|
44
|
+
schedule.singleton_class.prepend(KEY_INJECTOR)
|
|
22
45
|
end
|
|
23
46
|
|
|
24
47
|
def self.wrap(endpoint, inner_cmd)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"#{key_assign} ; " \
|
|
31
|
-
"
|
|
32
|
-
"#{
|
|
33
|
-
"curl -sf \"#{endpoint}/ping/$JOBTICK_KEY/completed\" || " \
|
|
34
|
-
"curl -sf \"#{endpoint}/ping/$JOBTICK_KEY/failed\""
|
|
48
|
+
key_assign = "JOBTICK_KEY=:jobtick_key"
|
|
49
|
+
started = %(curl -sf --max-time 10 "#{endpoint}/ping/$JOBTICK_KEY/started")
|
|
50
|
+
completed = %(curl -sf --max-time 10 "#{endpoint}/ping/$JOBTICK_KEY/completed")
|
|
51
|
+
failed = %(curl -sf --max-time 10 "#{endpoint}/ping/$JOBTICK_KEY/failed")
|
|
52
|
+
|
|
53
|
+
"#{key_assign} ; #{started} ; " \
|
|
54
|
+
"if #{inner_cmd} ; then rc=0 ; #{completed} ; " \
|
|
55
|
+
"else rc=$? ; #{failed} ; fi ; exit $rc"
|
|
35
56
|
end
|
|
36
57
|
private_class_method :wrap
|
|
37
58
|
end
|
data/lib/jobtick.rb
CHANGED
|
@@ -5,15 +5,12 @@ require_relative "jobtick/version"
|
|
|
5
5
|
require_relative "jobtick/configuration"
|
|
6
6
|
require_relative "jobtick/client"
|
|
7
7
|
require_relative "jobtick/monitor"
|
|
8
|
-
require_relative "jobtick/parsers
|
|
9
|
-
require_relative "jobtick/parsers/solid_queue"
|
|
10
|
-
require_relative "jobtick/parsers/sidekiq"
|
|
11
|
-
require_relative "jobtick/registry"
|
|
12
|
-
require_relative "jobtick/hooks/active_job"
|
|
13
|
-
require_relative "jobtick/middleware/sidekiq"
|
|
8
|
+
require_relative "jobtick/parsers"
|
|
14
9
|
require_relative "jobtick/railtie" if defined?(Rails::Railtie)
|
|
15
10
|
|
|
16
11
|
module JobTick
|
|
12
|
+
EMPTY_MAP = {}.freeze
|
|
13
|
+
|
|
17
14
|
class Error < StandardError; end
|
|
18
15
|
|
|
19
16
|
class << self
|
|
@@ -29,12 +26,24 @@ module JobTick
|
|
|
29
26
|
@client ||= Client.new
|
|
30
27
|
end
|
|
31
28
|
|
|
29
|
+
# The app's root directory, used to resolve schedule/recurring config
|
|
30
|
+
# files. Falls back to the process's working directory when Rails isn't
|
|
31
|
+
# loaded (or hasn't set a root yet) so discovery doesn't silently depend
|
|
32
|
+
# on the caller's cwd.
|
|
33
|
+
def root
|
|
34
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
35
|
+
Rails.root.to_s
|
|
36
|
+
else
|
|
37
|
+
Dir.pwd
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
32
41
|
def logger
|
|
33
|
-
defined?(Rails)
|
|
42
|
+
(defined?(Rails) && Rails.logger) || (@fallback_logger ||= Logger.new($stdout))
|
|
34
43
|
end
|
|
35
44
|
|
|
36
45
|
def monitor_map
|
|
37
|
-
@monitor_map ||=
|
|
46
|
+
@monitor_map ||= EMPTY_MAP
|
|
38
47
|
end
|
|
39
48
|
|
|
40
49
|
attr_writer :monitor_map
|
|
@@ -44,9 +53,10 @@ module JobTick
|
|
|
44
53
|
end
|
|
45
54
|
|
|
46
55
|
def reset!
|
|
56
|
+
Dispatcher.reset! if defined?(Dispatcher)
|
|
47
57
|
@config = nil
|
|
48
58
|
@client = nil
|
|
49
|
-
@monitor_map =
|
|
59
|
+
@monitor_map = EMPTY_MAP
|
|
50
60
|
end
|
|
51
61
|
end
|
|
52
62
|
end
|
data/lib/tasks/jobtick.rake
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
namespace :jobtick do
|
|
4
4
|
desc "Sync discovered jobs with jobtick.app"
|
|
5
5
|
task sync: :environment do
|
|
6
|
+
require "jobtick/parsers/whenever"
|
|
7
|
+
require "jobtick/parsers/solid_queue"
|
|
8
|
+
require "jobtick/parsers/sidekiq"
|
|
9
|
+
require "jobtick/registry"
|
|
10
|
+
|
|
6
11
|
monitors = JobTick::Registry.sync
|
|
7
12
|
count = monitors&.length || 0
|
|
8
13
|
puts "[JobTick] Synced #{count} monitor(s)"
|
data/sig/jobtick.rbs
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jobtick
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Clearstack Labs
|
|
@@ -38,9 +38,11 @@ files:
|
|
|
38
38
|
- lib/jobtick.rb
|
|
39
39
|
- lib/jobtick/client.rb
|
|
40
40
|
- lib/jobtick/configuration.rb
|
|
41
|
+
- lib/jobtick/dispatcher.rb
|
|
41
42
|
- lib/jobtick/hooks/active_job.rb
|
|
42
43
|
- lib/jobtick/middleware/sidekiq.rb
|
|
43
44
|
- lib/jobtick/monitor.rb
|
|
45
|
+
- lib/jobtick/parsers.rb
|
|
44
46
|
- lib/jobtick/parsers/sidekiq.rb
|
|
45
47
|
- lib/jobtick/parsers/solid_queue.rb
|
|
46
48
|
- lib/jobtick/parsers/whenever.rb
|