cronwatch 0.8.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8af9eac18f781dcf622c274e6f3e44370a4373d0721215ba2928f133d3b36bd
4
- data.tar.gz: 20577e3909253de5b132c4fba48654b9176a593efc8c5c86caf85845fe16c1c7
3
+ metadata.gz: 6df945edfb266ca737de6343276860554a4d3d740331091e1167d00fe93888a4
4
+ data.tar.gz: ec33c3140fb5f453de2cd0263b74a894b590408394f9f1aade76f1be2485e190
5
5
  SHA512:
6
- metadata.gz: 3f29e87d9cd6b09776a1c744c147b29ac20f6311072e2f7eb7d16b5ba4eaf4f29b3cbb204567badb4f655af12224fb308923a7e6b1f8e4189fd9b42db2df2079
7
- data.tar.gz: 8c3e6d1326f545a5fa453235a385814351c0575927a024ed0fc341cb6a2e639e996856ec4ca96eb2f4e674b0d90a70789a431514b9c4e7f140cb917efd12de0d
6
+ metadata.gz: ba15e440f49e390715b9668a24c0ee2b1c0cf35dac907a461555d18181fc95572b0ba3fee50b00237893733ca5b864b9f35b6a4e30b0f0bee64e6eefa1f3c5b0
7
+ data.tar.gz: 71e579e3ebba4a279cfef5de79d611157dcc2fc8ad176fac77cc737e7e0750c9ecd3cfefed383b2ac51f2f7aef5a5a24a24a472b6b739e90c1bd007a5814f0f5
@@ -326,6 +326,9 @@ module Cronwatch
326
326
  input = run.is_a?(Run) ? run : Run.from_h(run.is_a?(Hash) ? run.to_h { |k, v| [Naming.camel(k), v] } : run)
327
327
  declared = @registry.synchronize { @definitions[input.job] }
328
328
  raise ArgumentError, "record_run: job \"#{input.job}\" is not declared; call job first" unless declared
329
+ if input.id.to_s.include?("\0")
330
+ raise ArgumentError, "record_run: run ids cannot contain a NUL character (job \"#{input.job}\")"
331
+ end
329
332
 
330
333
  sync(declared)
331
334
  run = input.dup
@@ -537,6 +540,8 @@ module Cronwatch
537
540
  def reset_process_state
538
541
  @pid = Process.pid
539
542
  @locks = {}
543
+ # Each job's turn to write its declaration. See sync.
544
+ @syncing = {}
540
545
  @check_lock = Mutex.new
541
546
  @checking = nil
542
547
  @ticker_lock = Mutex.new
@@ -642,18 +647,34 @@ module Cronwatch
642
647
  end
643
648
  end
644
649
 
650
+ # Writes the declaration of `definition`'s name as it stands, unless the
651
+ # store has it. A handle kept from an earlier declaration writes the one
652
+ # that replaced it, never its own over it, and one forgotten since writes
653
+ # its own. The writes of one name take turns (the name's lock in
654
+ # @syncing, held across the write), so one still under way cannot land
655
+ # after a later one; and a name declared again while its write was under
656
+ # way is still to be written.
645
657
  def sync(definition)
646
658
  ensure_ready
647
- return if @registry.synchronize { @synced.include?(definition.name) }
659
+ name = definition.name
660
+ return if @registry.synchronize { @synced.include?(name) }
648
661
 
649
- @store.upsert_job(Serialize.to_stored(definition), now)
650
- @registry.synchronize { @synced << definition.name }
662
+ after_fork_check
663
+ turn = @registry.synchronize { @syncing[name] ||= Mutex.new }
664
+ turn.synchronize do
665
+ standing = @registry.synchronize { @synced.include?(name) ? nil : @definitions.fetch(name, definition) }
666
+ next if standing.nil?
667
+
668
+ @store.upsert_job(Serialize.to_stored(standing), now)
669
+ @registry.synchronize { @synced << name if @definitions.fetch(name, standing).equal?(standing) }
670
+ end
651
671
  end
652
672
 
653
673
  # Runs the block while holding the job's lock, so two runs (or a run and a
654
674
  # check) in this process never read and write the job's state over each
655
675
  # other. Other processes are coordinated by update_state instead. Only
656
- # store reads and writes happen inside; alerts are sent outside it.
676
+ # store reads and writes happen inside; alerts are sent outside it. sync
677
+ # gives a job's declaration writes turns the same way, on a lock of their own.
657
678
  def serial(job, &block)
658
679
  after_fork_check
659
680
  lock = @registry.synchronize { @locks[job] ||= Monitor.new }
@@ -991,6 +1012,8 @@ module Cronwatch
991
1012
  got = id.is_a?(String) ? "#{JS.length16(id)} characters" : id.class.to_s
992
1013
  raise ArgumentError, "job \"#{job}\": #{method}() needs a run id of 1 to 200 characters (got #{got})"
993
1014
  end
1015
+ # Postgres refuses NUL in text, so no store could hold such an id.
1016
+ raise ArgumentError, "job \"#{job}\": #{method}() cannot take a run id containing a NUL character" if id.include?("\0")
994
1017
  return unless id.start_with?(RESERVED_RUN_ID_PREFIX)
995
1018
 
996
1019
  raise ArgumentError, "job \"#{job}\": #{method}() cannot take a run id starting with \"#{RESERVED_RUN_ID_PREFIX}\", " \
@@ -48,6 +48,19 @@ module Cronwatch
48
48
  version.to_i
49
49
  end
50
50
 
51
+ # The failures in a row a stored state counts as: its `consecutive_failures`
52
+ # when that is a whole number, held at MAX_DURATION_MS (2**53 - 1), and 0
53
+ # when it is negative or not a whole number. A foreign row's count past
54
+ # 2**53 stays at the top, as the SDK holds it, and a 1.5, "3" or -1
55
+ # counts as none.
56
+ def failure_count(state)
57
+ count = state&.consecutive_failures
58
+ return 0 unless count.is_a?(Integer) || (count.is_a?(Float) && count.finite? && count == count.floor)
59
+ return 0 unless count.positive?
60
+
61
+ [count, MAX_DURATION_MS].min.to_i
62
+ end
63
+
51
64
  def empty_state(job)
52
65
  JobState.new(job: job, open: {}, consecutive_failures: 0, silenced_until: nil, last_alert_at: nil,
53
66
  pending_recovery: [], undelivered: [])
@@ -61,7 +74,7 @@ module Cronwatch
61
74
  JobState.new(
62
75
  job: state.job.nil? ? job : state.job,
63
76
  open: (state.open || {}).dup,
64
- consecutive_failures: state.consecutive_failures.nil? ? 0 : state.consecutive_failures,
77
+ consecutive_failures: failure_count(state),
65
78
  silenced_until: state.silenced_until,
66
79
  last_alert_at: state.last_alert_at,
67
80
  pending_recovery: (state.pending_recovery || []).dup,
@@ -233,7 +246,8 @@ module Cronwatch
233
246
  end
234
247
 
235
248
  # failed or timeout
236
- next_state.consecutive_failures += 1
249
+ # Held at the top, as the SDK holds it: a count at the limit stays there.
250
+ next_state.consecutive_failures = [next_state.consecutive_failures + 1, MAX_DURATION_MS].min
237
251
  close_condition(next_state, :missed)
238
252
  threshold = [1, definition.failures_before_alert || 1].max
239
253
  condition = run.status == :timeout ? :stuck : :failed
@@ -137,6 +137,16 @@ module Cronwatch
137
137
  text.include?("\0") ? text.delete("\0") : text
138
138
  end
139
139
 
140
+ # Removes every U+0000 from JSON text, keys and strings alike, by dropping
141
+ # each \u0000 escape (a NUL can appear in JSON no other way). Escapes are
142
+ # read left to right in pairs, so an escaped backslash followed by "u0000"
143
+ # is left as it is.
144
+ def strip_json_nul(json)
145
+ return json unless json.include?("\\u0000")
146
+
147
+ json.gsub(/\\(u0000|.)/m) { Regexp.last_match(1) == "u0000" ? "" : Regexp.last_match(0) }
148
+ end
149
+
140
150
  # "Name: message" and the first five backtrace lines, each written as
141
151
  # " at <line>" like the frames of a JavaScript stack, capped like output.
142
152
  # An exception that stops the thread rather than reporting a problem
@@ -41,8 +41,10 @@ module Cronwatch
41
41
  # prefix: put before every job name, to keep them apart from your own ("db:"). Also keeps run ids apart.
42
42
  # job_name: a callable giving the CronWatch name for a Job. Default its jobname with anything other than
43
43
  # 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
44
+ # The prefix goes in front either way. One that raises or returns no name, like a `jobs` or
45
+ # `options` callable that raises, is reported once and fails only that job, which keeps its last
46
+ # declaration until the callable works again.
47
+ # options: grace, timeout, max_duration, expect and the rest, for every job (a hash) or per job (a callable
46
48
  # given a Job). The schedule and timezone always come from pg_cron.
47
49
  # timezone: the timezone pg_cron reads its cron expressions in. Default the server's cron.timezone, read from
48
50
  # pg_settings, which shows it only to roles with pg_read_all_settings; UTC (pg_cron's default) is
@@ -110,6 +112,8 @@ module Cronwatch
110
112
  @retired = Set.new
111
113
  @scanned = false
112
114
  @warned = Set.new
115
+ # Jobids whose callback failed, reported once until it works again.
116
+ @failing = Set.new
113
117
  @name = "pg_cron"
114
118
  end
115
119
 
@@ -269,8 +273,7 @@ module Cronwatch
269
273
  "scheduled: connect as that role, or give this one BYPASSRLS.")
270
274
  end
271
275
  all = rows.map { |r| job_from(r) }
272
- jobs = all.select { |job| picks?(job) }
273
- names, definitions = declare(host, jobs, timezone, recording)
276
+ names, definitions = declare(host, all, timezone, recording)
274
277
  retire_unused(host, names, definitions, all)
275
278
  return [] if !recording || names.empty?
276
279
 
@@ -316,18 +319,60 @@ module Cronwatch
316
319
  /\A\d{1,15}\z/.match?(rest) ? rest.to_i : nil
317
320
  end
318
321
 
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)
322
+ # Declares each job picked. A paused one (active = false) keeps its failures but loses its schedule, so it
323
+ # is not missed. Returns [{ jobid => name }, { jobid => definition as declared }].
324
+ def declare(host, all, timezone, recording)
322
325
  names = {}
323
326
  definitions = {}
324
327
  used = Set.new
325
- jobs.each do |job|
326
- name = @prefix + (@job_name ? @job_name.call(job).to_s : PgCron.job_name(job))
328
+ # A callback of the app's (jobs, job_name, options) that raised, or a job_name that gave no name, fails
329
+ # only its job, as a bad row does: reported once until it works again, and the job carries on as last
330
+ # declared (skipped when it never was), so its runs are still copied.
331
+ trouble = lambda do |job, what|
332
+ unless @failing.include?(job.jobid)
333
+ @failing << job.jobid
334
+ host.on_error(RuntimeError.new("pg_cron job #{job.jobid}: #{what}; it keeps its last declaration until that works"),
335
+ "source pg_cron")
336
+ end
337
+ last = @known[job.jobid]
338
+ next if last.nil? || used.include?(last[:name])
339
+
340
+ names[job.jobid] = last[:name]
341
+ definitions[job.jobid] = last[:definition]
342
+ used << last[:name]
343
+ end
344
+ all.each do |job|
345
+ begin
346
+ picked = picks?(job)
347
+ rescue StandardError => e
348
+ trouble.call(job, "the jobs callback raised #{e.class}: #{e.message}")
349
+ next
350
+ end
351
+ unless picked
352
+ @failing.delete(job.jobid)
353
+ next
354
+ end
355
+ begin
356
+ base = @job_name ? @job_name.call(job) : PgCron.job_name(job)
357
+ rescue StandardError => e
358
+ trouble.call(job, "job_name raised #{e.class}: #{e.message}")
359
+ next
360
+ end
361
+ unless base.is_a?(String) || base.is_a?(Symbol)
362
+ trouble.call(job, "job_name returned #{base.nil? ? "nil" : base.class}, not a name")
363
+ next
364
+ end
365
+ begin
366
+ extra = (@options.respond_to?(:call) ? @options.call(job) : @options) || {}
367
+ extra = extra.to_h.transform_keys(&:to_sym).except(*SCHEDULE_ONLY)
368
+ rescue StandardError => e
369
+ trouble.call(job, "the options callback raised #{e.class}: #{e.message}")
370
+ next
371
+ end
372
+ @failing.delete(job.jobid)
373
+ name = @prefix + base.to_s
327
374
  name = "#{name}:#{job.jobid}" if used.include?(name)
328
375
  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
376
  schedule = job.active && recording ? PgCron.schedule(job.schedule) : nil
332
377
  definition = {
333
378
  description: "pg_cron job #{job.jobid} in #{job.database} as #{job.username}#{job.active ? "" : " (paused)"}",
@@ -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
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cronwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips