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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -11
  3. data/lib/cronwatch/alerts/datadog.rb +1 -0
  4. data/lib/cronwatch/alerts/discord.rb +34 -2
  5. data/lib/cronwatch/alerts/email.rb +2 -0
  6. data/lib/cronwatch/alerts/honeybadger.rb +1 -0
  7. data/lib/cronwatch/alerts/postmark.rb +1 -0
  8. data/lib/cronwatch/alerts/provider.rb +2 -0
  9. data/lib/cronwatch/alerts/resend.rb +1 -0
  10. data/lib/cronwatch/alerts/rollbar.rb +1 -0
  11. data/lib/cronwatch/alerts/sentry.rb +1 -0
  12. data/lib/cronwatch/alerts/sigv4.rb +2 -0
  13. data/lib/cronwatch/alerts/slack.rb +1 -0
  14. data/lib/cronwatch/alerts/twilio.rb +1 -0
  15. data/lib/cronwatch/alerts/webhook.rb +18 -5
  16. data/lib/cronwatch/client.rb +275 -110
  17. data/lib/cronwatch/cron_pattern.rb +17 -8
  18. data/lib/cronwatch/deprecation.rb +18 -0
  19. data/lib/cronwatch/duration.rb +2 -0
  20. data/lib/cronwatch/environment.rb +109 -7
  21. data/lib/cronwatch/evaluate.rb +145 -10
  22. data/lib/cronwatch/flight.rb +6 -0
  23. data/lib/cronwatch/format.rb +1 -0
  24. data/lib/cronwatch/http.rb +21 -5
  25. data/lib/cronwatch/job.rb +7 -2
  26. data/lib/cronwatch/js.rb +74 -1
  27. data/lib/cronwatch/monitored.rb +6 -1
  28. data/lib/cronwatch/output.rb +28 -1
  29. data/lib/cronwatch/pg_cron.rb +29 -6
  30. data/lib/cronwatch/rails/active_job.rb +2 -2
  31. data/lib/cronwatch/schedule.rb +25 -1
  32. data/lib/cronwatch/scheduler.rb +79 -11
  33. data/lib/cronwatch/serialize.rb +1 -0
  34. data/lib/cronwatch/stats.rb +1 -0
  35. data/lib/cronwatch/stores/active_record.rb +37 -11
  36. data/lib/cronwatch/ticker.rb +14 -0
  37. data/lib/cronwatch/triage/anthropic.rb +2 -0
  38. data/lib/cronwatch/types.rb +199 -27
  39. data/lib/cronwatch/version.rb +1 -1
  40. data/lib/cronwatch/walker.rb +2 -0
  41. data/lib/cronwatch/web/app.rb +114 -37
  42. data/lib/cronwatch/web/html.rb +9 -5
  43. data/lib/cronwatch/web/origin.rb +2 -0
  44. data/lib/cronwatch/web/pwa.rb +2 -0
  45. data/lib/cronwatch/web/timeline.rb +2 -0
  46. data/lib/cronwatch/web.rb +1 -2
  47. data/lib/cronwatch/zone.rb +2 -0
  48. data/lib/cronwatch.rb +2 -0
  49. data/lib/generators/cronwatch/install/install_generator.rb +52 -8
  50. metadata +2 -1
@@ -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
- "[earlier output trimmed]\n#{JS.tail16(text, CAP)}"
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.
@@ -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)`). sync returns the alerts
17
- # recording sent, or anything else for none.
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? || !Array(definition.tags).include?("pg_cron")
471
+ next if definition.schedule.nil? || definition.schedule.to_s.empty? || !definition.tags.include?("pg_cron")
452
472
 
453
- match = DESCRIBED.match(definition.description.to_s)
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
- unless name
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 "active_job"; `cronwatch` in the job is the run's context, for
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 = "active_job"
37
+ TRIGGER = Scheduler::ACTIVE_JOB_TRIGGER
38
38
 
39
39
  included do
40
40
  around_perform :cronwatch_perform
@@ -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
- # Parsed once per (schedule, timezone) pair and cached. Without a timezone
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)
@@ -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
- config = read
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
- return [] unless config.is_a?(Hash)
178
+ tasks(config)
179
+ end
156
180
 
157
- config.filter_map do |key, options|
158
- next unless options.is_a?(Hash) && options.key?(:schedule)
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
- Entry.new(source: self, key: key.to_s, class_name: present(options[:class]), command: present(options[:command]),
161
- schedule: options[:schedule], description: present(options[:description]), disabled: false)
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. Raises Error when it has none, or more than one.
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
- matches = found.flat_map(&:entries).select { |entry| !entry.disabled && same_class?(entry.class_name, name) }
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, "active_job", job) { block.call }
630
+ Cronwatch::Monitored.record(declaration, ACTIVE_JOB_TRIGGER, job) { block.call }
563
631
  else
564
632
  block.call
565
633
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cronwatch
4
+ # @api private
4
5
  module Serialize
5
6
  module_function
6
7
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cronwatch
4
+ # @api private
4
5
  module Stats
5
6
  module_function
6
7
 
@@ -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(json(row["state"]))
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). Each CASE tests the JSON type before any cast.
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
- "CASE WHEN json_type(#{column}, '$.version') NOT IN ('integer', 'real') THEN 0 " \
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
- def int(value)
429
- value.nil? ? nil : Integer(value)
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(json(row["definition"])),
434
- created_at: int(row["created_at"]), updated_at: int(row["updated_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
- metrics = row["metrics"].nil? ? {} : json(row["metrics"])
439
- Run.new(id: row["id"], job: row["job"], status: row["status"].to_sym, started_at: int(row["started_at"]),
440
- finished_at: int(row["finished_at"]), duration_ms: int(row["duration_ms"]), error: row["error"],
441
- output: row["output"], metrics: metrics, trigger: row["trigger"])
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
@@ -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