cronwatch 0.8.0 → 0.10.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.
@@ -35,6 +35,14 @@ module Cronwatch
35
35
  [ms, MAX_DURATION_MS].min
36
36
  end
37
37
 
38
+ # When a silence of `ms` from `now` ends: a whole millisecond, never past
39
+ # MAX_DURATION_MS (2**53 - 1), however long the silence asked for. Every
40
+ # port sharing the store reads it back unchanged, where a larger number
41
+ # could wrap to a time long past and send alerts during the silence.
42
+ def silence_end(now, ms)
43
+ [now + (ms >= MAX_DURATION_MS ? MAX_DURATION_MS : ms.floor), MAX_DURATION_MS].min
44
+ end
45
+
38
46
  # The version a stored state counts as for compare_and_set_state: its
39
47
  # `version` when that is a whole number from 0 to MAX_DURATION_MS
40
48
  # (2**53 - 1), else 0, as when it is absent. The SQL stores read it the
@@ -48,28 +56,137 @@ module Cronwatch
48
56
  version.to_i
49
57
  end
50
58
 
59
+ # The failures in a row a stored state counts as: its `consecutive_failures`
60
+ # when that is a whole number, held at MAX_DURATION_MS (2**53 - 1), and 0
61
+ # when it is negative or not a whole number. A foreign row's count past
62
+ # 2**53 stays at the top, as the SDK holds it, and a 1.5, "3" or -1
63
+ # counts as none.
64
+ def failure_count(state)
65
+ count = state&.consecutive_failures
66
+ return 0 unless count.is_a?(Integer) || (count.is_a?(Float) && count.finite? && count == count.floor)
67
+ return 0 unless count.positive?
68
+
69
+ [count, MAX_DURATION_MS].min.to_i
70
+ end
71
+
51
72
  def empty_state(job)
52
73
  JobState.new(job: job, open: {}, consecutive_failures: 0, silenced_until: nil, last_alert_at: nil,
53
74
  pending_recovery: [], undelivered: [])
54
75
  end
55
76
 
56
- # A stored state with every field present, or a fresh one. State written by an older version lacks the newer fields.
77
+ # A stored state with every field present, or a fresh one. State written
78
+ # by an older version lacks the newer fields. `sending` is the exception:
79
+ # it is there only while it holds an alert (see hold_alerts).
57
80
  def normalize_state(state, job)
58
81
  return empty_state(job) if state.nil?
59
82
 
60
83
  state = JobState.from_h(state)
84
+ sending = state.sending
61
85
  JobState.new(
62
86
  job: state.job.nil? ? job : state.job,
63
87
  open: (state.open || {}).dup,
64
- consecutive_failures: state.consecutive_failures.nil? ? 0 : state.consecutive_failures,
88
+ consecutive_failures: failure_count(state),
65
89
  silenced_until: state.silenced_until,
66
90
  last_alert_at: state.last_alert_at,
67
91
  pending_recovery: (state.pending_recovery || []).dup,
68
92
  undelivered: (state.undelivered || []).dup,
69
93
  version: state.version,
94
+ sending: sending.is_a?(Array) && !sending.empty? ? sending.dup : nil,
70
95
  )
71
96
  end
72
97
 
98
+ # ---------------------------------------------------------------- delivery
99
+
100
+ # Alerts kept per job for retry, and per job being sent; past it the oldest go.
101
+ MAX_UNDELIVERED = 20
102
+
103
+ # How long an alert in `sending` is left to the process sending it.
104
+ # Longer than any send takes: at most three alerts go out together, each
105
+ # with 25 seconds of triage and 15 of channels.
106
+ SEND_LEASE_MS = 5 * 60_000
107
+
108
+ # A state and how many alerts the queue let go to make it.
109
+ Held = Struct.new(:state, :dropped, keyword_init: true) do
110
+ def to_h = { "state" => state.to_h, "dropped" => dropped }
111
+ end
112
+
113
+ # Identifies an alert across retries, and in `sending`. `at` is printed
114
+ # as JavaScript prints a number.
115
+ def alert_key(alert)
116
+ at = alert.at
117
+ "#{alert.type}|#{at.is_a?(Numeric) ? JS.number(at) : at}|#{alert.run&.id}"
118
+ end
119
+
120
+ # `alerts` added to the undelivered queue: one with the same key as a
121
+ # queued alert replaces it where it stands, the rest go at the end, and
122
+ # only the newest MAX_UNDELIVERED stay. `dropped` counts those let go.
123
+ def queue_undelivered(state, alerts)
124
+ following = clone_state(state)
125
+ by_key = alerts.to_h { |alert| [alert_key(alert), alert] }
126
+ queue = following.undelivered.map { |alert| by_key.fetch(alert_key(alert), alert) }
127
+ known = queue.to_set { |alert| alert_key(alert) }
128
+ queue.concat(alerts.reject { |alert| known.include?(alert_key(alert)) })
129
+ following.undelivered = queue.last(MAX_UNDELIVERED)
130
+ Held.new(state: following, dropped: [0, queue.length - MAX_UNDELIVERED].max)
131
+ end
132
+
133
+ # The outbox. Alerts just composed are written with the state that opens
134
+ # their condition, before any is sent, so a process that stops part way
135
+ # does not lose them: into `sending`, each with its lease ending at
136
+ # `until_at`, when this process sends them, or (deliver: :check) straight
137
+ # into the undelivered queue for a check elsewhere. `dropped` counts
138
+ # alerts let go past MAX_UNDELIVERED.
139
+ def hold_alerts(state, alerts, until_at, deferred)
140
+ return Held.new(state: state, dropped: 0) if alerts.empty?
141
+ return queue_undelivered(state, alerts) if deferred
142
+
143
+ following = clone_state(state)
144
+ entries = (following.sending || []) + alerts.map { |alert| { "until" => until_at, "alert" => alert } }
145
+ following.sending = entries.last(MAX_UNDELIVERED)
146
+ Held.new(state: following, dropped: [0, entries.length - MAX_UNDELIVERED].max)
147
+ end
148
+
149
+ # Alerts in `sending` whose lease ran out by `now`: the process sending
150
+ # them stopped before it recorded how the send went. They go to the
151
+ # undelivered queue, where the retry sends them (with triage, which is
152
+ # never stored with them here) or drops them as stale. An entry that is
153
+ # not a Hash with an alert is dropped; one without a numeric `until`
154
+ # counts as run out.
155
+ def release_sending(state, now)
156
+ sending = state.sending.is_a?(Array) ? state.sending : []
157
+ lapsed, held = sending.partition { |entry| !(entry.is_a?(Hash) && entry["until"].is_a?(Numeric) && entry["until"] > now) }
158
+ return Held.new(state: state, dropped: 0) if lapsed.empty?
159
+
160
+ kept = state.dup
161
+ kept.sending = held
162
+ queue_undelivered(kept, lapsed.filter_map { |entry| sending_alert(entry) })
163
+ end
164
+
165
+ # How a send went. Delivered and stale alerts leave the queue; failed
166
+ # ones replace their queued copy, so a triage made on this attempt is
167
+ # kept, or join the queue. Every one of them leaves `sending`.
168
+ # last_alert_at moves only on a delivery. `dropped` counts alerts let go
169
+ # past MAX_UNDELIVERED.
170
+ def record_sent(state, delivered, failed, stale, now)
171
+ following = clone_state(state)
172
+ done = (delivered + stale).to_set { |alert| alert_key(alert) }
173
+ following.undelivered = following.undelivered.reject { |alert| done.include?(alert_key(alert)) }
174
+ sent = (delivered + failed + stale).to_set { |alert| alert_key(alert) }
175
+ held = (following.sending || []).reject do |entry|
176
+ alert = sending_alert(entry)
177
+ alert && sent.include?(alert_key(alert))
178
+ end
179
+ following.sending = held.empty? ? nil : held
180
+ following.last_alert_at = now if delivered.any?
181
+ queue_undelivered(following, failed)
182
+ end
183
+
184
+ # The alert of an entry of `sending`, or nil when it has none, or one
185
+ # that did not parse as an alert (which could not be sent either).
186
+ def sending_alert(entry)
187
+ entry.is_a?(Hash) && entry["alert"].is_a?(Alert) ? entry["alert"] : nil
188
+ end
189
+
73
190
  def clone_state(state)
74
191
  normalize_state(state, state.job)
75
192
  end
@@ -233,7 +350,8 @@ module Cronwatch
233
350
  end
234
351
 
235
352
  # failed or timeout
236
- next_state.consecutive_failures += 1
353
+ # Held at the top, as the SDK holds it: a count at the limit stays there.
354
+ next_state.consecutive_failures = [next_state.consecutive_failures + 1, MAX_DURATION_MS].min
237
355
  close_condition(next_state, :missed)
238
356
  threshold = [1, definition.failures_before_alert || 1].max
239
357
  condition = run.status == :timeout ? :stuck : :failed
@@ -4,7 +4,11 @@ module Cronwatch
4
4
  class Client
5
5
  # One shared check: the first caller runs it, the others wait for its result.
6
6
  class Flight
7
+ # The thread running the check.
8
+ attr_reader :owner
9
+
7
10
  def initialize
11
+ @owner = Thread.current
8
12
  @lock = Mutex.new
9
13
  @done = ConditionVariable.new
10
14
  @finished = false
data/lib/cronwatch/job.rb CHANGED
@@ -97,7 +97,7 @@ module Cronwatch
97
97
  # Collects a run's output and metrics while its block runs.
98
98
  class RunRecorder
99
99
  # Lines are dropped from the front once the output is well past the cap;
100
- # Output.cap trims it exactly at the end.
100
+ # Output.redact_and_cap trims it exactly at the end.
101
101
  KEEP = 64 * 1024
102
102
 
103
103
  attr_reader :context, :signal
@@ -136,8 +136,11 @@ module Cronwatch
136
136
  @lock.synchronize { @metrics[name] = value }
137
137
  end
138
138
 
139
+ # The lines still held (past 64 KB the oldest are let go), joined and not
140
+ # yet capped: the client redacts them first, then caps them
141
+ # (Output.redact_and_cap).
139
142
  def output
140
- @lock.synchronize { @lines.empty? ? nil : Output.cap(@lines.join("\n")) }
143
+ @lock.synchronize { @lines.empty? ? nil : @lines.join("\n") }
141
144
  end
142
145
 
143
146
  # What an expect rule is checked against: everything logged, or when that
@@ -129,7 +129,30 @@ module Cronwatch
129
129
  text = strip_nul(utf8(text))
130
130
  return text if JS.length16(text) <= CAP
131
131
 
132
- "[earlier output trimmed]\n#{JS.tail16(text, CAP)}"
132
+ "#{TRIMMED}#{JS.tail16(text, CAP)}"
133
+ end
134
+
135
+ TRIMMED = "[earlier output trimmed]\n"
136
+
137
+ # How much text before the kept tail redaction reads, and never keeps:
138
+ # three times the longest secret a default pattern can match (a PEM
139
+ # key's 16 KB body with its header and footer, under CAP + 1024), since a
140
+ # replacement grows what it replaces at most threefold.
141
+ REDACT_EDGE = 3 * (CAP + 1024)
142
+
143
+ # Output or an error as it is stored: redacted, then capped like cap, so
144
+ # the cut cannot fall inside a secret and keep what follows its label.
145
+ # Text of at most CAP + REDACT_EDGE units is redacted whole. Longer text
146
+ # is cut to that many units from its end first, and after redacting, the
147
+ # first REDACT_EDGE units are never kept: a secret whose label fell before
148
+ # that cut is left out with them. NULs go before and after `redact`.
149
+ def redact_and_cap(text, redact)
150
+ clean = strip_nul(utf8(text))
151
+ return cap(redact.call(clean)) if JS.length16(clean) <= CAP + REDACT_EDGE
152
+
153
+ redacted = strip_nul(utf8(redact.call(JS.tail16(clean, CAP + REDACT_EDGE))))
154
+ length = JS.length16(redacted)
155
+ "#{TRIMMED}#{JS.tail16(redacted, [[CAP, length - REDACT_EDGE].min, 0].max)}"
133
156
  end
134
157
 
135
158
  # Removes every U+0000.
@@ -137,8 +160,19 @@ module Cronwatch
137
160
  text.include?("\0") ? text.delete("\0") : text
138
161
  end
139
162
 
163
+ # Removes every U+0000 from JSON text, keys and strings alike, by dropping
164
+ # each \u0000 escape (a NUL can appear in JSON no other way). Escapes are
165
+ # read left to right in pairs, so an escaped backslash followed by "u0000"
166
+ # is left as it is.
167
+ def strip_json_nul(json)
168
+ return json unless json.include?("\\u0000")
169
+
170
+ json.gsub(/\\(u0000|.)/m) { Regexp.last_match(1) == "u0000" ? "" : Regexp.last_match(0) }
171
+ end
172
+
140
173
  # "Name: message" and the first five backtrace lines, each written as
141
174
  # " at <line>" like the frames of a JavaScript stack, capped like output.
175
+ # The client stores describe_error through redact_and_cap instead.
142
176
  # An exception that stops the thread rather than reporting a problem
143
177
  # (Interrupt, SystemExit, Sidekiq::Shutdown, a Timeout) is written
144
178
  # "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
@@ -41,8 +42,10 @@ module Cronwatch
41
42
  # prefix: put before every job name, to keep them apart from your own ("db:"). Also keeps run ids apart.
42
43
  # job_name: a callable giving the CronWatch name for a Job. Default its jobname with anything other than
43
44
  # letters, digits, ".", "_", ":" and "-" turned into "-", or "pg_cron:<jobid>" when it has none.
44
- # The prefix goes in front either way.
45
- # options: grace, timeout, max_duration, expect and the rest, for every job (a hash) or per job (a callable
45
+ # The prefix goes in front either way. One that raises or returns no name, like a `jobs` or
46
+ # `options` callable that raises, is reported once and fails only that job, which keeps its last
47
+ # declaration until the callable works again.
48
+ # options: grace, timeout, max_duration, expect and the rest, for every job (a hash) or per job (a callable
46
49
  # given a Job). The schedule and timezone always come from pg_cron.
47
50
  # timezone: the timezone pg_cron reads its cron expressions in. Default the server's cron.timezone, read from
48
51
  # pg_settings, which shows it only to roles with pg_read_all_settings; UTC (pg_cron's default) is
@@ -110,6 +113,8 @@ module Cronwatch
110
113
  @retired = Set.new
111
114
  @scanned = false
112
115
  @warned = Set.new
116
+ # Jobids whose callback failed, reported once until it works again.
117
+ @failing = Set.new
113
118
  @name = "pg_cron"
114
119
  end
115
120
 
@@ -269,8 +274,7 @@ module Cronwatch
269
274
  "scheduled: connect as that role, or give this one BYPASSRLS.")
270
275
  end
271
276
  all = rows.map { |r| job_from(r) }
272
- jobs = all.select { |job| picks?(job) }
273
- names, definitions = declare(host, jobs, timezone, recording)
277
+ names, definitions = declare(host, all, timezone, recording)
274
278
  retire_unused(host, names, definitions, all)
275
279
  return [] if !recording || names.empty?
276
280
 
@@ -316,18 +320,63 @@ module Cronwatch
316
320
  /\A\d{1,15}\z/.match?(rest) ? rest.to_i : nil
317
321
  end
318
322
 
319
- # Declares each job. A paused one (active = false) keeps its failures but loses its schedule, so it is not missed.
320
- # Returns [{ jobid => name }, { jobid => definition as declared }].
321
- def declare(host, jobs, timezone, recording)
323
+ # Declares each job picked. A paused one (active = false) keeps its failures but loses its schedule, so it
324
+ # is not missed. Returns [{ jobid => name }, { jobid => definition as declared }].
325
+ def declare(host, all, timezone, recording)
326
+ # One forgotten since it was declared (the dashboard's forget) is declared again, though unchanged:
327
+ # record_run takes runs only of a declared job.
328
+ live = host.respond_to?(:defined_jobs) ? host.defined_jobs.to_set(&:name) : nil
322
329
  names = {}
323
330
  definitions = {}
324
331
  used = Set.new
325
- jobs.each do |job|
326
- name = @prefix + (@job_name ? @job_name.call(job).to_s : PgCron.job_name(job))
332
+ # A callback of the app's (jobs, job_name, options) that raised, or a job_name that gave no name, fails
333
+ # only its job, as a bad row does: reported once until it works again, and the job carries on as last
334
+ # declared (skipped when it never was), so its runs are still copied.
335
+ trouble = lambda do |job, what|
336
+ unless @failing.include?(job.jobid)
337
+ @failing << job.jobid
338
+ host.on_error(RuntimeError.new("pg_cron job #{job.jobid}: #{what}; it keeps its last declaration until that works"),
339
+ "source pg_cron")
340
+ end
341
+ last = @known[job.jobid]
342
+ next if last.nil? || used.include?(last[:name])
343
+
344
+ names[job.jobid] = last[:name]
345
+ definitions[job.jobid] = last[:definition]
346
+ used << last[:name]
347
+ end
348
+ all.each do |job|
349
+ begin
350
+ picked = picks?(job)
351
+ rescue StandardError => e
352
+ trouble.call(job, "the jobs callback raised #{e.class}: #{e.message}")
353
+ next
354
+ end
355
+ unless picked
356
+ @failing.delete(job.jobid)
357
+ next
358
+ end
359
+ begin
360
+ base = @job_name ? @job_name.call(job) : PgCron.job_name(job)
361
+ rescue StandardError => e
362
+ trouble.call(job, "job_name raised #{e.class}: #{e.message}")
363
+ next
364
+ end
365
+ unless base.is_a?(String) || base.is_a?(Symbol)
366
+ trouble.call(job, "job_name returned #{base.nil? ? "nil" : base.class}, not a name")
367
+ next
368
+ end
369
+ begin
370
+ extra = (@options.respond_to?(:call) ? @options.call(job) : @options) || {}
371
+ extra = extra.to_h.transform_keys(&:to_sym).except(*SCHEDULE_ONLY)
372
+ rescue StandardError => e
373
+ trouble.call(job, "the options callback raised #{e.class}: #{e.message}")
374
+ next
375
+ end
376
+ @failing.delete(job.jobid)
377
+ name = @prefix + base.to_s
327
378
  name = "#{name}:#{job.jobid}" if used.include?(name)
328
379
  used << name
329
- extra = (@options.respond_to?(:call) ? @options.call(job) : @options) || {}
330
- extra = extra.to_h.transform_keys(&:to_sym).except(*SCHEDULE_ONLY)
331
380
  schedule = job.active && recording ? PgCron.schedule(job.schedule) : nil
332
381
  definition = {
333
382
  description: "pg_cron job #{job.jobid} in #{job.database} as #{job.username}#{job.active ? "" : " (paused)"}",
@@ -336,7 +385,7 @@ module Cronwatch
336
385
  definition.merge!(schedule: schedule, timezone: timezone) if schedule
337
386
  begin
338
387
  key = definition_key(definition)
339
- if @declared[name] != key
388
+ if @declared[name] != key || (live && !live.include?(name))
340
389
  begin
341
390
  host.job(name, **definition)
342
391
  rescue StandardError => e
@@ -76,6 +76,18 @@ module Cronwatch
76
76
  raise NotImplementedError
77
77
  end
78
78
 
79
+ # The entries a class's own schedule (schedule: :from_scheduler) is
80
+ # looked up in: #entries, unless a source says otherwise.
81
+ def schedule_entries
82
+ entries
83
+ end
84
+
85
+ # Entries for other environments than the one read, which say a class
86
+ # is scheduled, only not here. None, unless a source says otherwise.
87
+ def elsewhere_entries
88
+ []
89
+ end
90
+
79
91
  private
80
92
 
81
93
  # The config: the Hash or Array given, or the file after ERB, as both
@@ -146,19 +158,32 @@ module Cronwatch
146
158
  def entries
147
159
  return [] if skip_recurring?
148
160
 
149
- config = read
161
+ schedule_entries
162
+ end
163
+
164
+ # The tasks, read even where SOLID_QUEUE_SKIP_RECURRING is set: the
165
+ # skip means only that this process enqueues none, as when one of
166
+ # several bin/jobs schedules and the others still perform what it
167
+ # enqueues, so a class's own schedule is still the file's.
168
+ def schedule_entries
169
+ config = parsed
150
170
  return [] if config.nil?
151
- raise Error, "cronwatch: #{label} is not a map of recurring tasks" unless config.is_a?(Hash)
152
171
 
153
- config = symbolize(config)
154
172
  config = config[env.to_sym] if config[env.to_sym]
155
- return [] unless config.is_a?(Hash)
173
+ tasks(config)
174
+ end
156
175
 
157
- config.filter_map do |key, options|
158
- next unless options.is_a?(Hash) && options.key?(:schedule)
176
+ # The tasks of the file's other environments' sections (Rails 8's
177
+ # recurring.yml has only production:, so development and test read
178
+ # none), for a class scheduled there and not here.
179
+ def elsewhere_entries
180
+ config = parsed
181
+ return [] if config.nil?
159
182
 
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)
183
+ config.flat_map do |key, section|
184
+ next [] if key == env.to_sym || !section.is_a?(Hash) || section.key?(:schedule)
185
+
186
+ tasks(section)
162
187
  end
163
188
  end
164
189
 
@@ -241,6 +266,27 @@ module Cronwatch
241
266
  def present(value)
242
267
  value.nil? || value.to_s.strip.empty? ? nil : value.to_s
243
268
  end
269
+
270
+ # The file's map, keys as symbols, or nil when there is no file.
271
+ def parsed
272
+ config = read
273
+ return nil if config.nil?
274
+ raise Error, "cronwatch: #{label} is not a map of recurring tasks" unless config.is_a?(Hash)
275
+
276
+ symbolize(config)
277
+ end
278
+
279
+ # The tasks of one map of them: each with a schedule is an entry.
280
+ def tasks(config)
281
+ return [] unless config.is_a?(Hash)
282
+
283
+ config.filter_map do |key, options|
284
+ next unless options.is_a?(Hash) && options.key?(:schedule)
285
+
286
+ Entry.new(source: self, key: key.to_s, class_name: present(options[:class]), command: present(options[:command]),
287
+ schedule: options[:schedule], description: present(options[:description]), disabled: false)
288
+ end
289
+ end
244
290
  end
245
291
 
246
292
  # sidekiq-cron's schedule: config/schedule.yml (or the file its
@@ -370,7 +416,13 @@ module Cronwatch
370
416
  end
371
417
 
372
418
  # { schedule:, timezone: } for the class's one entry in the scheduler's
373
- # config. Raises Error when it has none, or more than one.
419
+ # config, read even in a process told to schedule nothing
420
+ # (SOLID_QUEUE_SKIP_RECURRING), since another process schedules it.
421
+ # { schedule: nil } for a class scheduled only in another environment's
422
+ # section of the file (Rails 8's recurring.yml has only production:), so
423
+ # it loads in development and test, declared without a schedule, as
424
+ # nothing schedules it there. Raises Error when no environment has it,
425
+ # or this one has it more than once.
374
426
  def schedule_for(klass)
375
427
  name = klass.is_a?(Module) ? klass.name : klass.to_s
376
428
  found = sources
@@ -379,8 +431,11 @@ module Cronwatch
379
431
  "set Cronwatch::Scheduler.sources to say where the schedule is"
380
432
  end
381
433
 
382
- matches = found.flat_map(&:entries).select { |entry| !entry.disabled && same_class?(entry.class_name, name) }
434
+ of_class = ->(entry) { !entry.disabled && same_class?(entry.class_name, name) }
435
+ matches = found.flat_map { |source| read_entries(source, :schedule_entries) }.select(&of_class)
383
436
  if matches.empty?
437
+ return { schedule: nil } if found.flat_map { |source| read_entries(source, :elsewhere_entries) }.any?(&of_class)
438
+
384
439
  raise Error, "cronwatch: #{name} uses schedule: :from_scheduler, but no enabled entry in " \
385
440
  "#{found.map(&:label).join(" or ")} has class #{name}"
386
441
  end
@@ -392,6 +447,14 @@ module Cronwatch
392
447
  matches.first.convert
393
448
  end
394
449
 
450
+ # A source's entries of that kind, for a source of the app's own that
451
+ # has only #entries.
452
+ def read_entries(source, kind)
453
+ return source.public_send(kind) if source.respond_to?(kind)
454
+
455
+ kind == :schedule_entries ? source.entries : []
456
+ end
457
+
395
458
  # Declares a job for every enabled entry once the app has booted, so a
396
459
  # check reports one that never runs: a class that calls `cronwatch`
397
460
  # declares itself, any other class is named as `cronwatch` would name
@@ -180,7 +180,7 @@ module Cronwatch
180
180
 
181
181
  def upsert_job(definition, now)
182
182
  definition = JobDefinition.from_h(definition)
183
- write(:upsert_job, [definition.name, JS.json(definition.to_h), now, now])
183
+ write(:upsert_job, [definition.name, json_text(definition.to_h), now, now])
184
184
  nil
185
185
  end
186
186
 
@@ -205,14 +205,14 @@ module Cronwatch
205
205
 
206
206
  def insert_run(run)
207
207
  write(:insert_run, [run.id, run.job, run.status.to_s, run.started_at, run.finished_at, run.duration_ms,
208
- run.error, run.output, JS.json(run.metrics || {}), run.trigger])
208
+ text(run.error), text(run.output), json_text(run.metrics || {}), text(run.trigger)])
209
209
  nil
210
210
  end
211
211
 
212
212
  # A run that is gone (its job was forgotten) stays gone.
213
213
  def update_run(run)
214
- write(:update_run, [run.status.to_s, run.finished_at, run.duration_ms, run.error, run.output,
215
- JS.json(run.metrics || {}), run.id])
214
+ write(:update_run, [run.status.to_s, run.finished_at, run.duration_ms, text(run.error), text(run.output),
215
+ json_text(run.metrics || {}), run.id])
216
216
  nil
217
217
  end
218
218
 
@@ -222,8 +222,8 @@ module Cronwatch
222
222
  statuses = Array(from_statuses).map(&:to_s)
223
223
  return false if statuses.empty?
224
224
 
225
- binds = [run.status.to_s, run.finished_at, run.duration_ms, run.error, run.output,
226
- JS.json(run.metrics || {}), run.id, *statuses]
225
+ binds = [run.status.to_s, run.finished_at, run.duration_ms, text(run.error), text(run.output),
226
+ json_text(run.metrics || {}), run.id, *statuses]
227
227
  changed = with_connection { |conn| conn.exec_update(update_run_if_sql(conn, statuses.length), NAME, binds) }
228
228
  changed.to_i.positive?
229
229
  end
@@ -253,7 +253,7 @@ module Cronwatch
253
253
  end
254
254
 
255
255
  def set_state(state)
256
- write(:set_state, [state.job, JS.json(state.to_h)])
256
+ write(:set_state, [state.job, json_text(state.to_h)])
257
257
  nil
258
258
  end
259
259
 
@@ -262,10 +262,10 @@ module Cronwatch
262
262
  # Returns whether it wrote. This is what keeps two processes sharing the
263
263
  # database (Ruby or Node) from overwriting each other's updates.
264
264
  def compare_and_set_state(state, expected_version)
265
- text = JS.json(state.to_h)
265
+ state_text = json_text(state.to_h)
266
266
  changed =
267
- if expected_version.zero? then write(:cas_insert, [state.job, text])
268
- else write(:cas_update, [text, state.job, expected_version])
267
+ if expected_version.zero? then write(:cas_insert, [state.job, state_text])
268
+ else write(:cas_update, [state_text, state.job, expected_version])
269
269
  end
270
270
  changed.to_i.positive?
271
271
  end
@@ -395,6 +395,19 @@ module Cronwatch
395
395
 
396
396
  # SQLite hands back JSON as TEXT; Postgres drivers may hand back JSONB
397
397
  # parsed or as text, and BIGINT as a string.
398
+ # Postgres refuses U+0000 in TEXT and JSONB, and a refused write loses
399
+ # the whole row, so every dialect writes text without it: a run's
400
+ # trigger, output, error and metric names, and every key and string of
401
+ # a definition and a state. Identifiers (a job's name, a run's id) are
402
+ # written as given; the client refuses one with a NUL before it gets here.
403
+ def text(value)
404
+ value.nil? ? nil : Output.strip_nul(value.to_s)
405
+ end
406
+
407
+ def json_text(value)
408
+ Output.strip_json_nul(JS.json(value))
409
+ end
410
+
398
411
  def json(value)
399
412
  js_numbers(value.is_a?(String) ? JS.parse(value) : value)
400
413
  end
@@ -417,12 +430,20 @@ module Cronwatch
417
430
  end
418
431
 
419
432
  def row_to_job(row)
420
- StoredJob.new(name: row["name"], definition: JobDefinition.from_h(json(row["definition"])),
433
+ StoredJob.new(name: row["name"], definition: JobDefinition.from_h(definition_json(row["definition"])),
421
434
  created_at: int(row["created_at"]), updated_at: int(row["updated_at"]))
422
435
  end
423
436
 
437
+ # A definition that does not parse (nested past JSON.parse's limit, say)
438
+ # reads as nil, which JobDefinition.from_h marks unreadable.
439
+ def definition_json(value)
440
+ json(value)
441
+ rescue ::JSON::ParserError
442
+ nil
443
+ end
444
+
424
445
  def row_to_run(row)
425
- metrics = row["metrics"].nil? ? {} : json(row["metrics"])
446
+ metrics = row["metrics"].nil? ? {} : Run.metrics_from(json(row["metrics"]))
426
447
  Run.new(id: row["id"], job: row["job"], status: row["status"].to_sym, started_at: int(row["started_at"]),
427
448
  finished_at: int(row["finished_at"]), duration_ms: int(row["duration_ms"]), error: row["error"],
428
449
  output: row["output"], metrics: metrics, trigger: row["trigger"])
@@ -28,7 +28,7 @@ module Cronwatch
28
28
  existing = @jobs[definition.name]
29
29
  @jobs[definition.name] = StoredJob.new(
30
30
  name: definition.name,
31
- definition: clone(definition, JobDefinition),
31
+ definition: kept(definition, JobDefinition),
32
32
  created_at: existing ? existing.created_at : now,
33
33
  updated_at: now,
34
34
  )
@@ -62,7 +62,7 @@ module Cronwatch
62
62
  sync do
63
63
  raise "run #{run.id} already exists" if @runs.key?(run.id)
64
64
 
65
- @runs[run.id] = clone(run, Run)
65
+ @runs[run.id] = kept_run(run)
66
66
  @order[run.id] = (@seq += 1)
67
67
  end
68
68
  nil
@@ -125,7 +125,7 @@ module Cronwatch
125
125
  end
126
126
 
127
127
  def set_state(state)
128
- sync { @states[state.job] = clone(state, JobState) }
128
+ sync { @states[state.job] = kept(state, JobState) }
129
129
  nil
130
130
  end
131
131
 
@@ -136,7 +136,7 @@ module Cronwatch
136
136
  sync do
137
137
  next false unless Evaluate.state_version(@states[state.job]) == expected_version
138
138
 
139
- @states[state.job] = clone(state, JobState)
139
+ @states[state.job] = kept(state, JobState)
140
140
  true
141
141
  end
142
142
  end
@@ -161,7 +161,7 @@ module Cronwatch
161
161
 
162
162
  # `existing` with the fields an update writes taken from `run`.
163
163
  def finished_fields(existing, run)
164
- copy = clone(run, Run)
164
+ copy = kept_run(run)
165
165
  existing.dup.tap do |r|
166
166
  r.status = copy.status
167
167
  r.finished_at = copy.finished_at
@@ -181,6 +181,21 @@ module Cronwatch
181
181
  def clone(value, type)
182
182
  type.from_h(JS.parse(JS.json(value.to_h)))
183
183
  end
184
+
185
+ # Text is held as the SQL stores write it, without U+0000, so every
186
+ # store reads back the same. Identifiers are held as given.
187
+ def kept(value, type)
188
+ type.from_h(JS.parse(Output.strip_json_nul(JS.json(value.to_h))))
189
+ end
190
+
191
+ def kept_run(run)
192
+ clone(run, Run).tap do |copy|
193
+ copy.trigger = Output.strip_nul(copy.trigger.to_s)
194
+ copy.output = copy.output && Output.strip_nul(copy.output)
195
+ copy.error = copy.error && Output.strip_nul(copy.error)
196
+ copy.metrics = JS.parse(Output.strip_json_nul(JS.json(copy.metrics || {})))
197
+ end
198
+ end
184
199
  end
185
200
  end
186
201
  end