cronwatch 0.9.0 → 0.11.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 +20 -11
- data/lib/cronwatch/alerts/datadog.rb +1 -0
- data/lib/cronwatch/alerts/discord.rb +34 -2
- data/lib/cronwatch/alerts/email.rb +2 -0
- data/lib/cronwatch/alerts/honeybadger.rb +1 -0
- data/lib/cronwatch/alerts/postmark.rb +1 -0
- data/lib/cronwatch/alerts/provider.rb +2 -0
- data/lib/cronwatch/alerts/resend.rb +1 -0
- data/lib/cronwatch/alerts/rollbar.rb +1 -0
- data/lib/cronwatch/alerts/sentry.rb +1 -0
- data/lib/cronwatch/alerts/sigv4.rb +2 -0
- data/lib/cronwatch/alerts/slack.rb +1 -0
- data/lib/cronwatch/alerts/twilio.rb +1 -0
- data/lib/cronwatch/alerts/webhook.rb +18 -5
- data/lib/cronwatch/client.rb +275 -110
- data/lib/cronwatch/cron_pattern.rb +17 -8
- data/lib/cronwatch/deprecation.rb +18 -0
- data/lib/cronwatch/duration.rb +2 -0
- data/lib/cronwatch/environment.rb +109 -7
- data/lib/cronwatch/evaluate.rb +145 -10
- data/lib/cronwatch/flight.rb +6 -0
- data/lib/cronwatch/format.rb +1 -0
- data/lib/cronwatch/http.rb +21 -5
- data/lib/cronwatch/job.rb +7 -2
- data/lib/cronwatch/js.rb +74 -1
- data/lib/cronwatch/monitored.rb +6 -1
- data/lib/cronwatch/output.rb +28 -1
- data/lib/cronwatch/pg_cron.rb +29 -6
- data/lib/cronwatch/rails/active_job.rb +2 -2
- data/lib/cronwatch/schedule.rb +25 -1
- data/lib/cronwatch/scheduler.rb +79 -11
- data/lib/cronwatch/serialize.rb +1 -0
- data/lib/cronwatch/stats.rb +1 -0
- data/lib/cronwatch/stores/active_record.rb +37 -11
- data/lib/cronwatch/ticker.rb +14 -0
- data/lib/cronwatch/triage/anthropic.rb +2 -0
- data/lib/cronwatch/types.rb +199 -27
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/walker.rb +2 -0
- data/lib/cronwatch/web/app.rb +114 -37
- data/lib/cronwatch/web/html.rb +9 -5
- data/lib/cronwatch/web/origin.rb +2 -0
- data/lib/cronwatch/web/pwa.rb +2 -0
- data/lib/cronwatch/web/timeline.rb +2 -0
- data/lib/cronwatch/web.rb +1 -2
- data/lib/cronwatch/zone.rb +2 -0
- data/lib/cronwatch.rb +2 -0
- data/lib/generators/cronwatch/install/install_generator.rb +52 -8
- metadata +2 -1
data/lib/cronwatch/output.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Cronwatch
|
|
4
|
+
# A run's output and error on their way to the store: redaction, the cap,
|
|
5
|
+
# NUL and encoding. `redact_secrets` is public, the default `redact:` and
|
|
6
|
+
# the one to call from a redact of your own; the rest is @api private.
|
|
4
7
|
module Output
|
|
5
8
|
# Output is capped so a chatty job cannot fill the store. The tail is kept.
|
|
6
9
|
# Counted in UTF-16 code units, as the SDK counts it.
|
|
@@ -129,7 +132,30 @@ module Cronwatch
|
|
|
129
132
|
text = strip_nul(utf8(text))
|
|
130
133
|
return text if JS.length16(text) <= CAP
|
|
131
134
|
|
|
132
|
-
"
|
|
135
|
+
"#{TRIMMED}#{JS.tail16(text, CAP)}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
TRIMMED = "[earlier output trimmed]\n"
|
|
139
|
+
|
|
140
|
+
# How much text before the kept tail redaction reads, and never keeps:
|
|
141
|
+
# three times the longest secret a default pattern can match (a PEM
|
|
142
|
+
# key's 16 KB body with its header and footer, under CAP + 1024), since a
|
|
143
|
+
# replacement grows what it replaces at most threefold.
|
|
144
|
+
REDACT_EDGE = 3 * (CAP + 1024)
|
|
145
|
+
|
|
146
|
+
# Output or an error as it is stored: redacted, then capped like cap, so
|
|
147
|
+
# the cut cannot fall inside a secret and keep what follows its label.
|
|
148
|
+
# Text of at most CAP + REDACT_EDGE units is redacted whole. Longer text
|
|
149
|
+
# is cut to that many units from its end first, and after redacting, the
|
|
150
|
+
# first REDACT_EDGE units are never kept: a secret whose label fell before
|
|
151
|
+
# that cut is left out with them. NULs go before and after `redact`.
|
|
152
|
+
def redact_and_cap(text, redact)
|
|
153
|
+
clean = strip_nul(utf8(text))
|
|
154
|
+
return cap(redact.call(clean)) if JS.length16(clean) <= CAP + REDACT_EDGE
|
|
155
|
+
|
|
156
|
+
redacted = strip_nul(utf8(redact.call(JS.tail16(clean, CAP + REDACT_EDGE))))
|
|
157
|
+
length = JS.length16(redacted)
|
|
158
|
+
"#{TRIMMED}#{JS.tail16(redacted, [[CAP, length - REDACT_EDGE].min, 0].max)}"
|
|
133
159
|
end
|
|
134
160
|
|
|
135
161
|
# Removes every U+0000.
|
|
@@ -149,6 +175,7 @@ module Cronwatch
|
|
|
149
175
|
|
|
150
176
|
# "Name: message" and the first five backtrace lines, each written as
|
|
151
177
|
# " at <line>" like the frames of a JavaScript stack, capped like output.
|
|
178
|
+
# The client stores describe_error through redact_and_cap instead.
|
|
152
179
|
# An exception that stops the thread rather than reporting a problem
|
|
153
180
|
# (Interrupt, SystemExit, Sidekiq::Shutdown, a Timeout) is written
|
|
154
181
|
# "Interrupted: <class>", with its message when it says more.
|
data/lib/cronwatch/pg_cron.rb
CHANGED
|
@@ -13,8 +13,9 @@ module Cronwatch
|
|
|
13
13
|
# with `name` and `sync(host)`: on every check the client calls sync with
|
|
14
14
|
# itself as the host, and the source declares jobs (`host.job`), reads the
|
|
15
15
|
# store (`host.store`), records the runs it found (`host.record_run`) and
|
|
16
|
-
# reports problems (`host.on_error(error, where)`).
|
|
17
|
-
#
|
|
16
|
+
# reports problems (`host.on_error(error, where)`). A host with
|
|
17
|
+
# `defined_jobs` lets a source declare again a job forgotten since. sync
|
|
18
|
+
# returns the alerts recording sent, or anything else for none.
|
|
18
19
|
module Sources
|
|
19
20
|
# Watches pg_cron jobs, which run inside Postgres where nothing can wrap
|
|
20
21
|
# them (the SDK's sources/pgcron.ts). As a source, on every check it
|
|
@@ -25,6 +26,8 @@ module Cronwatch
|
|
|
25
26
|
# A job that is renamed, unscheduled or no longer picked keeps its old
|
|
26
27
|
# name's runs and history, and that name is declared again without a
|
|
27
28
|
# schedule, so it is never reported missed. Its description says why.
|
|
29
|
+
# Forgetting that old name from the dashboard while a run of it is still
|
|
30
|
+
# open lets the run go: it is not recorded, and no error is reported.
|
|
28
31
|
#
|
|
29
32
|
# require "cronwatch/pg_cron"
|
|
30
33
|
# Cronwatch.configure do |c|
|
|
@@ -85,6 +88,8 @@ module Cronwatch
|
|
|
85
88
|
# The options of a definition that are declared again, without its schedule, for a name no longer in use.
|
|
86
89
|
UNSCHEDULED = %i[description tags grace timeout max_duration budget failures_before_alert].freeze
|
|
87
90
|
DESCRIBED = /\Apg_cron job (\d+) in /
|
|
91
|
+
private_constant :BACKFILL, :COLUMNS, :DESCRIBED, :JOBS_SQL, :MAX_PAGES, :NEWEST_SQL, :PAGE, :REBOOT, :RUNS_SQL,
|
|
92
|
+
:SCHEDULE_ONLY, :SECONDS, :SETTING_SQL, :UNSCHEDULED, :UTC
|
|
88
93
|
|
|
89
94
|
attr_reader :name
|
|
90
95
|
|
|
@@ -110,6 +115,8 @@ module Cronwatch
|
|
|
110
115
|
@declared = {}
|
|
111
116
|
# Names declared again without a schedule by retire, whose open runs are still read.
|
|
112
117
|
@retired = Set.new
|
|
118
|
+
# The names the host declares, read on each sync after the retires (nil for a host without defined_jobs).
|
|
119
|
+
@declared_now = nil
|
|
113
120
|
@scanned = false
|
|
114
121
|
@warned = Set.new
|
|
115
122
|
# Jobids whose callback failed, reported once until it works again.
|
|
@@ -203,6 +210,8 @@ module Cronwatch
|
|
|
203
210
|
end
|
|
204
211
|
|
|
205
212
|
# Queries through the pg gem's PG::Connection#exec_params.
|
|
213
|
+
#
|
|
214
|
+
# @api private
|
|
206
215
|
class PGConnection
|
|
207
216
|
def initialize(connection)
|
|
208
217
|
@connection = connection
|
|
@@ -216,6 +225,8 @@ module Cronwatch
|
|
|
216
225
|
|
|
217
226
|
# Queries through an ActiveRecord connection's exec_query, checking one
|
|
218
227
|
# out of the pool for each query when given a class or a pool.
|
|
228
|
+
#
|
|
229
|
+
# @api private
|
|
219
230
|
class ActiveRecordConnection
|
|
220
231
|
def initialize(source)
|
|
221
232
|
@source = source
|
|
@@ -277,6 +288,10 @@ module Cronwatch
|
|
|
277
288
|
retire_unused(host, names, definitions, all)
|
|
278
289
|
return [] if !recording || names.empty?
|
|
279
290
|
|
|
291
|
+
# The names declared now, after the retires above. A run copied under
|
|
292
|
+
# a retired name that was then forgotten (the dashboard's forget) has
|
|
293
|
+
# no job to go to: it is let go, never recorded and never read again.
|
|
294
|
+
@declared_now = host.respond_to?(:defined_jobs) ? host.defined_jobs.to_set(&:name) : nil
|
|
280
295
|
alerts = []
|
|
281
296
|
start_cursors(host, names, alerts, now)
|
|
282
297
|
read_new(host, names, alerts, now)
|
|
@@ -322,6 +337,9 @@ module Cronwatch
|
|
|
322
337
|
# Declares each job picked. A paused one (active = false) keeps its failures but loses its schedule, so it
|
|
323
338
|
# is not missed. Returns [{ jobid => name }, { jobid => definition as declared }].
|
|
324
339
|
def declare(host, all, timezone, recording)
|
|
340
|
+
# One forgotten since it was declared (the dashboard's forget) is declared again, though unchanged:
|
|
341
|
+
# record_run takes runs only of a declared job.
|
|
342
|
+
live = host.respond_to?(:defined_jobs) ? host.defined_jobs.to_set(&:name) : nil
|
|
325
343
|
names = {}
|
|
326
344
|
definitions = {}
|
|
327
345
|
used = Set.new
|
|
@@ -381,7 +399,7 @@ module Cronwatch
|
|
|
381
399
|
definition.merge!(schedule: schedule, timezone: timezone) if schedule
|
|
382
400
|
begin
|
|
383
401
|
key = definition_key(definition)
|
|
384
|
-
if @declared[name] != key
|
|
402
|
+
if @declared[name] != key || (live && !live.include?(name))
|
|
385
403
|
begin
|
|
386
404
|
host.job(name, **definition)
|
|
387
405
|
rescue StandardError => e
|
|
@@ -447,10 +465,13 @@ module Cronwatch
|
|
|
447
465
|
visible = all.map(&:jobid).to_set
|
|
448
466
|
host.store.list_jobs.each do |stored|
|
|
449
467
|
definition = stored.definition
|
|
468
|
+
# A foreign or damaged definition (not an object, tags not a list) is not one of ours.
|
|
469
|
+
next if !definition.is_a?(JobDefinition) || definition.unreadable? || !definition.tags.is_a?(Array)
|
|
450
470
|
next if !stored.name.start_with?(@prefix) || in_use.include?(stored.name)
|
|
451
|
-
next if definition.schedule.nil? || definition.schedule.to_s.empty? || !
|
|
471
|
+
next if definition.schedule.nil? || definition.schedule.to_s.empty? || !definition.tags.include?("pg_cron")
|
|
452
472
|
|
|
453
|
-
|
|
473
|
+
description = definition.description
|
|
474
|
+
match = DESCRIBED.match(description.is_a?(String) ? description : "")
|
|
454
475
|
next unless match
|
|
455
476
|
|
|
456
477
|
jobid = match[1].to_i
|
|
@@ -473,8 +494,10 @@ module Cronwatch
|
|
|
473
494
|
runid = Integer(row["runid"])
|
|
474
495
|
jobid = Integer(row["jobid"])
|
|
475
496
|
name = @pending[runid] || names[jobid]
|
|
476
|
-
|
|
497
|
+
if name.nil? || (@declared_now && !names.value?(name) && !@declared_now.include?(name))
|
|
498
|
+
@pending.delete(runid)
|
|
477
499
|
@held.delete(runid)
|
|
500
|
+
@retired.delete(name) if name
|
|
478
501
|
return
|
|
479
502
|
end
|
|
480
503
|
if row["start_time"].nil? && !PgCron.finished_status?(row["status"])
|
|
@@ -7,7 +7,7 @@ require_relative "../scheduler"
|
|
|
7
7
|
|
|
8
8
|
module Cronwatch
|
|
9
9
|
# Monitors an ActiveJob class. Each perform is a recorded run with the
|
|
10
|
-
# trigger "
|
|
10
|
+
# trigger "active-job"; `cronwatch` in the job is the run's context, for
|
|
11
11
|
# log and metric. A perform that raises is recorded as failed and then
|
|
12
12
|
# raises as before, so retry_on, discard_on and error reporters see it
|
|
13
13
|
# unchanged.
|
|
@@ -34,7 +34,7 @@ module Cronwatch
|
|
|
34
34
|
extend ActiveSupport::Concern
|
|
35
35
|
include Cronwatch::Monitored
|
|
36
36
|
|
|
37
|
-
TRIGGER =
|
|
37
|
+
TRIGGER = Scheduler::ACTIVE_JOB_TRIGGER
|
|
38
38
|
|
|
39
39
|
included do
|
|
40
40
|
around_perform :cronwatch_perform
|
data/lib/cronwatch/schedule.rb
CHANGED
|
@@ -11,6 +11,8 @@ module Cronwatch
|
|
|
11
11
|
# is accepted and how a fire lands on a clock that jumps (daylight saving),
|
|
12
12
|
# so both are ported here and a Node and a Ruby process sharing one store
|
|
13
13
|
# agree on every due time.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
14
16
|
module Schedule
|
|
15
17
|
# How early a run may start and still count for the fire it was meant for.
|
|
16
18
|
EARLY_SLACK_MS = 60_000
|
|
@@ -29,6 +31,8 @@ module Cronwatch
|
|
|
29
31
|
|
|
30
32
|
# The compiled cron fields, kept out of the public shape.
|
|
31
33
|
attr_accessor :pattern
|
|
34
|
+
# Whether the cron names a date no month has ("0 0 30 2 *"), and so never fires.
|
|
35
|
+
attr_accessor :never
|
|
32
36
|
|
|
33
37
|
def cron? = kind == :cron
|
|
34
38
|
def interval? = kind == :interval
|
|
@@ -47,12 +51,21 @@ module Cronwatch
|
|
|
47
51
|
def to_h = { "dueAt" => due_at, "deadline" => deadline }
|
|
48
52
|
end
|
|
49
53
|
|
|
54
|
+
# Where a cron is asked once whether it ever fires: more than a 400-year
|
|
55
|
+
# cycle of the calendar before the year 3000, as in schedule.ts.
|
|
56
|
+
PROBE_FROM_MS = 946_684_800_000 # 2000-01-01T00:00:00Z
|
|
57
|
+
# Text that starts like an ISO date and time, which croner would take for a one-time date.
|
|
58
|
+
ONE_TIME_DATE = /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/
|
|
59
|
+
|
|
50
60
|
@cache = {}
|
|
51
61
|
@lock = Mutex.new
|
|
52
62
|
|
|
53
63
|
module_function
|
|
54
64
|
|
|
55
|
-
#
|
|
65
|
+
# Text with a colon after its first character, which croner would take
|
|
66
|
+
# for a one-time date, is refused. A cron that names a date no month has
|
|
67
|
+
# ("0 0 30 2 *") is read, and never fires: nothing is ever due, so it is
|
|
68
|
+
# never missed. Parsed once per (schedule, timezone) pair and cached. Without a timezone
|
|
56
69
|
# the expression is read in the process timezone, like crontab. Vercel and
|
|
57
70
|
# GitHub Actions run their crons in UTC, so pass timezone: "UTC" for those.
|
|
58
71
|
def parse(schedule, timezone = nil)
|
|
@@ -68,6 +81,13 @@ module Cronwatch
|
|
|
68
81
|
|
|
69
82
|
parsed = Parsed.new(kind: :interval, source: text, every_ms: every_ms)
|
|
70
83
|
else
|
|
84
|
+
# croner reads text with a colon after its first character as a
|
|
85
|
+
# one-time date to fire at, not as a cron expression. A cron monitor
|
|
86
|
+
# has no use for one, so every such text is refused, as in the SDK.
|
|
87
|
+
if text[1..].to_s.include?(":")
|
|
88
|
+
reason = ONE_TIME_DATE.match?(text) ? "CronPattern: a one-time date is not supported" : "Invalid ISO8601 passed to timezone parser."
|
|
89
|
+
raise ArgumentError, "schedule \"#{schedule}\" is not a cron expression or \"every <duration>\": #{reason}"
|
|
90
|
+
end
|
|
71
91
|
begin
|
|
72
92
|
pattern = CronPattern.compile(text)
|
|
73
93
|
rescue ArgumentError => e
|
|
@@ -75,6 +95,8 @@ module Cronwatch
|
|
|
75
95
|
end
|
|
76
96
|
parsed = Parsed.new(kind: :cron, source: text, timezone: timezone.nil? || timezone == "" ? nil : timezone)
|
|
77
97
|
parsed.pattern = pattern
|
|
98
|
+
# The calendar alone decides it, so it is asked in UTC.
|
|
99
|
+
parsed.never = pattern.next_after(PROBE_FROM_MS, "UTC").nil?
|
|
78
100
|
end
|
|
79
101
|
parsed.freeze
|
|
80
102
|
@lock.synchronize { @cache[key] = parsed }
|
|
@@ -88,6 +110,7 @@ module Cronwatch
|
|
|
88
110
|
# stepped over an hour at a time. Ported from fireAfter in schedule.ts.
|
|
89
111
|
def fire_after(parsed, from)
|
|
90
112
|
raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
|
|
113
|
+
return nil if parsed.never
|
|
91
114
|
|
|
92
115
|
start = count_from(from)
|
|
93
116
|
return nil if start.nil?
|
|
@@ -111,6 +134,7 @@ module Cronwatch
|
|
|
111
134
|
# any that do not move forward (see fire_after).
|
|
112
135
|
def fires_between(parsed, from, to, limit)
|
|
113
136
|
raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
|
|
137
|
+
return [] if parsed.never
|
|
114
138
|
|
|
115
139
|
out = []
|
|
116
140
|
start = count_from(from)
|
data/lib/cronwatch/scheduler.rb
CHANGED
|
@@ -40,6 +40,11 @@ module Cronwatch
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# The trigger of a run an ActiveJob perform records, through
|
|
44
|
+
# Cronwatch::ActiveJob or a scheduler's entry. Runs recorded before 1.0
|
|
45
|
+
# carry "active_job".
|
|
46
|
+
ACTIVE_JOB_TRIGGER = "active-job"
|
|
47
|
+
|
|
43
48
|
# How far ahead the daylight saving check looks, and how far either side
|
|
44
49
|
# of each clock change it compares the scheduler's runs with CronWatch's.
|
|
45
50
|
HORIZON_YEARS = 5
|
|
@@ -76,6 +81,18 @@ module Cronwatch
|
|
|
76
81
|
raise NotImplementedError
|
|
77
82
|
end
|
|
78
83
|
|
|
84
|
+
# The entries a class's own schedule (schedule: :from_scheduler) is
|
|
85
|
+
# looked up in: #entries, unless a source says otherwise.
|
|
86
|
+
def schedule_entries
|
|
87
|
+
entries
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Entries for other environments than the one read, which say a class
|
|
91
|
+
# is scheduled, only not here. None, unless a source says otherwise.
|
|
92
|
+
def elsewhere_entries
|
|
93
|
+
[]
|
|
94
|
+
end
|
|
95
|
+
|
|
79
96
|
private
|
|
80
97
|
|
|
81
98
|
# The config: the Hash or Array given, or the file after ERB, as both
|
|
@@ -146,19 +163,32 @@ module Cronwatch
|
|
|
146
163
|
def entries
|
|
147
164
|
return [] if skip_recurring?
|
|
148
165
|
|
|
149
|
-
|
|
166
|
+
schedule_entries
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The tasks, read even where SOLID_QUEUE_SKIP_RECURRING is set: the
|
|
170
|
+
# skip means only that this process enqueues none, as when one of
|
|
171
|
+
# several bin/jobs schedules and the others still perform what it
|
|
172
|
+
# enqueues, so a class's own schedule is still the file's.
|
|
173
|
+
def schedule_entries
|
|
174
|
+
config = parsed
|
|
150
175
|
return [] if config.nil?
|
|
151
|
-
raise Error, "cronwatch: #{label} is not a map of recurring tasks" unless config.is_a?(Hash)
|
|
152
176
|
|
|
153
|
-
config = symbolize(config)
|
|
154
177
|
config = config[env.to_sym] if config[env.to_sym]
|
|
155
|
-
|
|
178
|
+
tasks(config)
|
|
179
|
+
end
|
|
156
180
|
|
|
157
|
-
|
|
158
|
-
|
|
181
|
+
# The tasks of the file's other environments' sections (Rails 8's
|
|
182
|
+
# recurring.yml has only production:, so development and test read
|
|
183
|
+
# none), for a class scheduled there and not here.
|
|
184
|
+
def elsewhere_entries
|
|
185
|
+
config = parsed
|
|
186
|
+
return [] if config.nil?
|
|
159
187
|
|
|
160
|
-
|
|
161
|
-
|
|
188
|
+
config.flat_map do |key, section|
|
|
189
|
+
next [] if key == env.to_sym || !section.is_a?(Hash) || section.key?(:schedule)
|
|
190
|
+
|
|
191
|
+
tasks(section)
|
|
162
192
|
end
|
|
163
193
|
end
|
|
164
194
|
|
|
@@ -241,6 +271,27 @@ module Cronwatch
|
|
|
241
271
|
def present(value)
|
|
242
272
|
value.nil? || value.to_s.strip.empty? ? nil : value.to_s
|
|
243
273
|
end
|
|
274
|
+
|
|
275
|
+
# The file's map, keys as symbols, or nil when there is no file.
|
|
276
|
+
def parsed
|
|
277
|
+
config = read
|
|
278
|
+
return nil if config.nil?
|
|
279
|
+
raise Error, "cronwatch: #{label} is not a map of recurring tasks" unless config.is_a?(Hash)
|
|
280
|
+
|
|
281
|
+
symbolize(config)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The tasks of one map of them: each with a schedule is an entry.
|
|
285
|
+
def tasks(config)
|
|
286
|
+
return [] unless config.is_a?(Hash)
|
|
287
|
+
|
|
288
|
+
config.filter_map do |key, options|
|
|
289
|
+
next unless options.is_a?(Hash) && options.key?(:schedule)
|
|
290
|
+
|
|
291
|
+
Entry.new(source: self, key: key.to_s, class_name: present(options[:class]), command: present(options[:command]),
|
|
292
|
+
schedule: options[:schedule], description: present(options[:description]), disabled: false)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
244
295
|
end
|
|
245
296
|
|
|
246
297
|
# sidekiq-cron's schedule: config/schedule.yml (or the file its
|
|
@@ -370,7 +421,13 @@ module Cronwatch
|
|
|
370
421
|
end
|
|
371
422
|
|
|
372
423
|
# { schedule:, timezone: } for the class's one entry in the scheduler's
|
|
373
|
-
# config
|
|
424
|
+
# config, read even in a process told to schedule nothing
|
|
425
|
+
# (SOLID_QUEUE_SKIP_RECURRING), since another process schedules it.
|
|
426
|
+
# { schedule: nil } for a class scheduled only in another environment's
|
|
427
|
+
# section of the file (Rails 8's recurring.yml has only production:), so
|
|
428
|
+
# it loads in development and test, declared without a schedule, as
|
|
429
|
+
# nothing schedules it there. Raises Error when no environment has it,
|
|
430
|
+
# or this one has it more than once.
|
|
374
431
|
def schedule_for(klass)
|
|
375
432
|
name = klass.is_a?(Module) ? klass.name : klass.to_s
|
|
376
433
|
found = sources
|
|
@@ -379,8 +436,11 @@ module Cronwatch
|
|
|
379
436
|
"set Cronwatch::Scheduler.sources to say where the schedule is"
|
|
380
437
|
end
|
|
381
438
|
|
|
382
|
-
|
|
439
|
+
of_class = ->(entry) { !entry.disabled && same_class?(entry.class_name, name) }
|
|
440
|
+
matches = found.flat_map { |source| read_entries(source, :schedule_entries) }.select(&of_class)
|
|
383
441
|
if matches.empty?
|
|
442
|
+
return { schedule: nil } if found.flat_map { |source| read_entries(source, :elsewhere_entries) }.any?(&of_class)
|
|
443
|
+
|
|
384
444
|
raise Error, "cronwatch: #{name} uses schedule: :from_scheduler, but no enabled entry in " \
|
|
385
445
|
"#{found.map(&:label).join(" or ")} has class #{name}"
|
|
386
446
|
end
|
|
@@ -392,6 +452,14 @@ module Cronwatch
|
|
|
392
452
|
matches.first.convert
|
|
393
453
|
end
|
|
394
454
|
|
|
455
|
+
# A source's entries of that kind, for a source of the app's own that
|
|
456
|
+
# has only #entries.
|
|
457
|
+
def read_entries(source, kind)
|
|
458
|
+
return source.public_send(kind) if source.respond_to?(kind)
|
|
459
|
+
|
|
460
|
+
kind == :schedule_entries ? source.entries : []
|
|
461
|
+
end
|
|
462
|
+
|
|
395
463
|
# Declares a job for every enabled entry once the app has booted, so a
|
|
396
464
|
# check reports one that never runs: a class that calls `cronwatch`
|
|
397
465
|
# declares itself, any other class is named as `cronwatch` would name
|
|
@@ -559,7 +627,7 @@ module Cronwatch
|
|
|
559
627
|
::ActiveJob::Base.around_perform do |job, block|
|
|
560
628
|
declaration = Cronwatch::Scheduler.declaration_for_active_job(job)
|
|
561
629
|
if declaration
|
|
562
|
-
Cronwatch::Monitored.record(declaration,
|
|
630
|
+
Cronwatch::Monitored.record(declaration, ACTIVE_JOB_TRIGGER, job) { block.call }
|
|
563
631
|
else
|
|
564
632
|
block.call
|
|
565
633
|
end
|
data/lib/cronwatch/serialize.rb
CHANGED
data/lib/cronwatch/stats.rb
CHANGED
|
@@ -247,9 +247,10 @@ module Cronwatch
|
|
|
247
247
|
read(:running_runs, []).map { |row| row_to_run(row) }
|
|
248
248
|
end
|
|
249
249
|
|
|
250
|
+
# A state whose text does not parse, or is not an object, reads as none.
|
|
250
251
|
def get_state(job)
|
|
251
252
|
row = read(:get_state, [job]).first
|
|
252
|
-
row && JobState.from_h(
|
|
253
|
+
row && JobState.from_h(lenient_json(row["state"]))
|
|
253
254
|
end
|
|
254
255
|
|
|
255
256
|
def set_state(state)
|
|
@@ -347,7 +348,8 @@ module Cronwatch
|
|
|
347
348
|
# The version inside a state's JSON, as Evaluate.state_version reads
|
|
348
349
|
# it: a whole number from 0 to 2**53 - 1, else 0 (none, or a foreign
|
|
349
350
|
# row's 1.5 or "x", which must neither fail the statement nor refuse
|
|
350
|
-
# every write for good
|
|
351
|
+
# every write for good; on SQLite, also text that is not JSON). Each
|
|
352
|
+
# CASE tests the JSON type before any cast.
|
|
351
353
|
version = lambda do |column|
|
|
352
354
|
if pg
|
|
353
355
|
v = "(#{column}->>'version')::numeric"
|
|
@@ -355,7 +357,8 @@ module Cronwatch
|
|
|
355
357
|
"WHEN #{v} % 1 = 0 AND #{v} BETWEEN 0 AND 9007199254740991 THEN #{v}::bigint ELSE 0 END"
|
|
356
358
|
end
|
|
357
359
|
v = "json_extract(#{column}, '$.version')"
|
|
358
|
-
|
|
360
|
+
# Text that is not JSON at all (SQLite holds any) counts as 0 too, before json_type could fail on it.
|
|
361
|
+
"CASE WHEN NOT json_valid(#{column}) THEN 0 WHEN json_type(#{column}, '$.version') NOT IN ('integer', 'real') THEN 0 " \
|
|
359
362
|
"WHEN #{v} = CAST(#{v} AS INTEGER) AND #{v} BETWEEN 0 AND 9007199254740991 THEN CAST(#{v} AS INTEGER) ELSE 0 END"
|
|
360
363
|
end
|
|
361
364
|
sql = {
|
|
@@ -425,20 +428,43 @@ module Cronwatch
|
|
|
425
428
|
end
|
|
426
429
|
end
|
|
427
430
|
|
|
428
|
-
|
|
429
|
-
|
|
431
|
+
# Rows are read leniently: a foreign, hand-edited or damaged row (SQLite
|
|
432
|
+
# keeps whatever type it is given, in any column) must affect only its
|
|
433
|
+
# own job, never every read. JSON text that does not parse (or nests
|
|
434
|
+
# past JSON.parse's limit) reads as nil, which the client takes as no
|
|
435
|
+
# state, or as an unreadable definition it reports.
|
|
436
|
+
def lenient_json(value)
|
|
437
|
+
json(value)
|
|
438
|
+
rescue ::JSON::ParserError
|
|
439
|
+
nil
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# A time that must be there: one that is not a finite number reads as 0.
|
|
443
|
+
def time(value)
|
|
444
|
+
JS.finite_number(value) || 0
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# A time or duration that may be absent: one that is not a finite number reads as nil.
|
|
448
|
+
def maybe_time(value)
|
|
449
|
+
JS.finite_number(value)
|
|
430
450
|
end
|
|
431
451
|
|
|
432
452
|
def row_to_job(row)
|
|
433
|
-
StoredJob.new(name: row["name"], definition: JobDefinition.from_h(
|
|
434
|
-
created_at:
|
|
453
|
+
StoredJob.new(name: row["name"], definition: JobDefinition.from_h(lenient_json(row["definition"]), row["name"]),
|
|
454
|
+
created_at: time(row["created_at"]), updated_at: time(row["updated_at"]))
|
|
435
455
|
end
|
|
436
456
|
|
|
457
|
+
# A start that is not a finite number reads as 0, a finish or duration
|
|
458
|
+
# as nil; an error or output that is not text as nil; metrics that do
|
|
459
|
+
# not parse to an object as {}; a trigger that is not text as "run".
|
|
437
460
|
def row_to_run(row)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
461
|
+
error = row["error"]
|
|
462
|
+
output = row["output"]
|
|
463
|
+
trigger = row["trigger"]
|
|
464
|
+
Run.new(id: row["id"], job: row["job"], status: Run.status_from(row["status"]), started_at: time(row["started_at"]),
|
|
465
|
+
finished_at: maybe_time(row["finished_at"]), duration_ms: maybe_time(row["duration_ms"]),
|
|
466
|
+
error: error.is_a?(String) ? error : nil, output: output.is_a?(String) ? output : nil,
|
|
467
|
+
metrics: Run.metrics_from(lenient_json(row["metrics"])), trigger: trigger.is_a?(String) ? trigger : "run")
|
|
442
468
|
end
|
|
443
469
|
end
|
|
444
470
|
end
|
data/lib/cronwatch/ticker.rb
CHANGED
|
@@ -6,6 +6,8 @@ module Cronwatch
|
|
|
6
6
|
class Client
|
|
7
7
|
# Calls the block after `first` seconds, then every `interval` seconds
|
|
8
8
|
# counted from the start, in a background thread, until stopped.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
9
11
|
class Ticker
|
|
10
12
|
def initialize(interval, first, &tick)
|
|
11
13
|
@lock = Mutex.new
|
|
@@ -39,6 +41,18 @@ module Cronwatch
|
|
|
39
41
|
end
|
|
40
42
|
end
|
|
41
43
|
|
|
44
|
+
# False once the thread has ended: stopped, or killed from outside.
|
|
45
|
+
def alive?
|
|
46
|
+
@thread.alive?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Waits for the thread to end: once stopped, after the tick under way.
|
|
50
|
+
# Returns at once when called from the thread itself.
|
|
51
|
+
def join
|
|
52
|
+
@thread.join unless Thread.current.equal?(@thread)
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
42
56
|
private
|
|
43
57
|
|
|
44
58
|
# False once stopped.
|
|
@@ -37,6 +37,8 @@ module Cronwatch
|
|
|
37
37
|
|
|
38
38
|
# JavaScript's /<\/?job_data/gi, spelled out: Ruby's /i folds more than ASCII.
|
|
39
39
|
TAG = %r{</?[Jj][Oo][Bb]_[Dd][Aa][Tt][Aa]}
|
|
40
|
+
private_constant :DEFAULT_EFFORT, :DEFAULT_MAX_TOKENS, :DEFAULT_MODEL, :FALLBACK_BETA, :REQUEST_TIMEOUT_MS,
|
|
41
|
+
:SYSTEM, :TAG
|
|
40
42
|
|
|
41
43
|
attr_reader :model, :effort, :max_tokens, :fallbacks, :context
|
|
42
44
|
|