cronwatch 0.7.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 +4 -4
- data/README.md +7 -7
- data/lib/cronwatch/alerts/provider.rb +3 -2
- data/lib/cronwatch/client.rb +31 -8
- data/lib/cronwatch/duration.rb +17 -0
- data/lib/cronwatch/evaluate.rb +44 -2
- data/lib/cronwatch/format.rb +4 -1
- data/lib/cronwatch/output.rb +10 -0
- data/lib/cronwatch/pg_cron.rb +57 -12
- data/lib/cronwatch/schedule.rb +53 -9
- data/lib/cronwatch/stores/active_record.rb +37 -12
- data/lib/cronwatch/stores/memory.rb +21 -6
- data/lib/cronwatch/triage/anthropic.rb +7 -2
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/html.rb +3 -1
- data/lib/cronwatch/web/timeline.rb +9 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6df945edfb266ca737de6343276860554a4d3d740331091e1167d00fe93888a4
|
|
4
|
+
data.tar.gz: ec33c3140fb5f453de2cd0263b74a894b590408394f9f1aade76f1be2485e190
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba15e440f49e390715b9668a24c0ee2b1c0cf35dac907a461555d18181fc95572b0ba3fee50b00237893733ca5b864b9f35b6a4e30b0f0bee64e6eefa1f3c5b0
|
|
7
|
+
data.tar.gz: 71e579e3ebba4a279cfef5de79d611157dcc2fc8ad176fac77cc737e7e0750c9ecd3cfefed383b2ac51f2f7aef5a5a24a24a472b6b739e90c1bd007a5814f0f5
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Cron and scheduled-job monitoring that lives inside your Ruby or Rails app. Wrap a job once; every run is recorded in a database you already have, and you are told when a run is missed, fails, gets stuck, runs slow or goes over budget. No server to run, no account to make.
|
|
4
4
|
|
|
5
|
-
This is the Ruby port of [`@cronwatch/sdk`](https://www.npmjs.com/package/@cronwatch/sdk): the same rules, the same alert text and the same stored rows, so a Ruby process
|
|
5
|
+
This is the Ruby port of [`@cronwatch/sdk`](https://www.npmjs.com/package/@cronwatch/sdk): the same rules, the same alert text and the same stored rows, so a Ruby process can share one database with a Node, Python, PHP, Go or Rust process, and [`@cronwatch/mcp`](https://www.npmjs.com/package/@cronwatch/mcp) works against any of them.
|
|
6
6
|
|
|
7
7
|
Docs: [cronwatch.dev/docs/rails](https://cronwatch.dev/docs/rails/) and [cronwatch.dev/docs/ruby](https://cronwatch.dev/docs/ruby/)
|
|
8
8
|
|
|
@@ -20,13 +20,13 @@ Ruby 3.2 or newer; the Rails integration is tested on Rails 7.2, 8.0 and 8.1. Th
|
|
|
20
20
|
| `cronwatch` | the client, the memory store, and the Slack, Discord, webhook, console, email (Resend, Postmark, SendGrid, Mailgun, SES), Twilio and error tracker (Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, New Relic) channels | |
|
|
21
21
|
| `cronwatch/pg_cron` | `Cronwatch::Sources::PgCron`, which watches pg_cron jobs through an ActiveRecord or pg connection | |
|
|
22
22
|
| `cronwatch/active_record` | the ActiveRecord store | `activerecord` |
|
|
23
|
-
| `cronwatch/rails` | the Railtie, `Cronwatch::ActiveJob`, `Cronwatch::CheckJob`, the `cronwatch:check` task, the install generator | `railties`, `activejob` |
|
|
23
|
+
| `cronwatch/rails` | the Railtie, `Cronwatch::ActiveJob`, `Cronwatch::CheckJob`, `Cronwatch::Web`, the `cronwatch:check` task, the install generator | `railties`, `activejob` |
|
|
24
24
|
| `cronwatch/sidekiq` | `Cronwatch::Sidekiq` for `Sidekiq::Job` classes, its server middleware, `Cronwatch::Sidekiq::CheckWorker` | `sidekiq` 7 or newer |
|
|
25
25
|
| `cronwatch/scheduler` | schedules read from Solid Queue's or sidekiq-cron's config, `Cronwatch.declare_from_scheduler!` | |
|
|
26
26
|
| `cronwatch/web` | the dashboard and JSON API as a Rack app | `rack` |
|
|
27
27
|
| `cronwatch/triage/anthropic` | Claude triage | `anthropic` |
|
|
28
28
|
|
|
29
|
-
In a Rails app, `gem "cronwatch"` also loads `cronwatch/rails` and `cronwatch/scheduler` (Bundler requires it after Rails), `cronwatch/sidekiq` when Sidekiq is in the bundle, and the ActiveRecord store on first use. `cronwatch/
|
|
29
|
+
In a Rails app, `gem "cronwatch"` also loads `cronwatch/rails`, `cronwatch/web` and `cronwatch/scheduler` (Bundler requires it after Rails), `cronwatch/sidekiq` when Sidekiq is in the bundle, and the ActiveRecord store on first use. `cronwatch/triage/anthropic` is always required by hand, and so is `cronwatch/web` outside Rails.
|
|
30
30
|
|
|
31
31
|
## Plain Ruby
|
|
32
32
|
|
|
@@ -64,7 +64,7 @@ run.finish # or run.fail(error); finish("text") and fin
|
|
|
64
64
|
|
|
65
65
|
Neither raises for the store: failures go to `on_error`, and a finish of a run already finished, not found, or of another job records nothing and returns nil. A run is judged once, however many processes finish it at once, and a store that fails during `finish` leaves the handle active to finish again. A run never finished is marked stuck after the job's `timeout`.
|
|
66
66
|
|
|
67
|
-
Client options: `store`, `alerts`, `triage`, `cron_secret`, `retention` (default `"30d"`), `defaults`, `redact` (default: blank values that look like secrets; `false` keeps output as logged, or pass a callable), `deliver` (`:now` by default; `:check` queues alerts for another process's check to send, for a worker that cannot reach Slack), `on_error` and `now`. Methods: `job`, `run`, `resume_run`, `check`, `start`/`stop`, `silence(name, for: "2h")`/`unsilence`, `forget`, `jobs`, `jobs_with_runs`, `job_summary`, `runs`, `get_run`, `defined_jobs`, `close`. [cronwatch.dev/docs/ruby](https://cronwatch.dev/docs/ruby/#api) has each one.
|
|
67
|
+
Client options: `store`, `alerts`, `triage`, `cron_secret`, `retention` (default `"30d"`), `defaults`, `sources` (where runs the process does not wrap come from, such as pg_cron), `redact` (default: blank values that look like secrets; `false` keeps output as logged, or pass a callable), `deliver` (`:now` by default; `:check` queues alerts for another process's check to send, for a worker that cannot reach Slack), `on_error` and `now`. Methods: `job`, `run`, `resume_run`, `record_run`, `check`, `start`/`stop`, `silence(name, for: "2h")`/`unsilence`, `forget`, `jobs`, `jobs_with_runs`, `job_summary`, `runs`, `get_run`, `defined_jobs`, `close`. [cronwatch.dev/docs/ruby](https://cronwatch.dev/docs/ruby/#api) has each one.
|
|
68
68
|
|
|
69
69
|
## Rails
|
|
70
70
|
|
|
@@ -130,7 +130,7 @@ reads the class's one entry in Solid Queue's `config/recurring.yml` (the section
|
|
|
130
130
|
|
|
131
131
|
### Scheduling the check
|
|
132
132
|
|
|
133
|
-
Failures are caught as they happen, but a run that never started or never finished can only be noticed by looking. `Cronwatch::CheckJob` looks: it loads `app/jobs` (and `app/workers`) when the app does not eager load, declares every monitored job and runs the check. Run it every five minutes.
|
|
133
|
+
Failures are caught as they happen, but a run that never started or never finished can only be noticed by looking. `Cronwatch::CheckJob` looks: it loads `app/jobs` (and `app/workers` and `app/sidekiq`) when the app does not eager load, declares every monitored job and runs the check. Run it every five minutes.
|
|
134
134
|
|
|
135
135
|
Solid Queue:
|
|
136
136
|
|
|
@@ -168,7 +168,7 @@ Rails.application.routes.draw do
|
|
|
168
168
|
end
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
-
Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while `RAILS_ENV` or `RACK_ENV` is `development` or `test`, it makes a token of its own and prints a sign-in link to standard output on its first request (open it once; unless `origin:` is set or the request's host is loopback, the link leaves the host out, since a client chooses it); anywhere else it answers 503. To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is what Rack reports and so already follows `X-Forwarded-Host` and `X-Forwarded-Proto`; pass `origin: "https://app.example.com"` to pin it instead. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
|
|
171
|
+
Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while the environment (`Rails.env`, else `RAILS_ENV` or `RACK_ENV`) is `development` or `test`, it makes a token of its own and prints a sign-in link to standard output on its first request (open it once; unless `origin:` is set or the request's host is loopback, the link leaves the host out, since a client chooses it); anywhere else it answers 503. To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is what Rack reports and so already follows `X-Forwarded-Host` and `X-Forwarded-Proto`; pass `origin: "https://app.example.com"` to pin it instead. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
|
|
172
172
|
|
|
173
173
|
There is no `handler()` as in the TypeScript SDK: for a job triggered over HTTP, wrap the controller action's body in `CW.job(...).run` (declared once, at boot) and check the bearer in the controller.
|
|
174
174
|
|
|
@@ -225,7 +225,7 @@ Adds two to four sentences from Claude (likely cause, first thing to check) to e
|
|
|
225
225
|
|
|
226
226
|
## Sharing a database with a Node app
|
|
227
227
|
|
|
228
|
-
The ActiveRecord store writes the same three tables as `@cronwatch/sdk/postgres` and `@cronwatch/sdk/sqlite`: same names, columns and indexes, epoch milliseconds in the time columns, the SDK's camelCase JSON in the JSON columns. [`test/active_record/node_compat_test.rb`](https://github.com/phillips-jon/cronwatch/blob/main/packages/ruby/test/active_record/node_compat_test.rb) runs the SDK's stores in Node beside this one, on SQLite and Postgres, and checks that each reads what the other wrote, that the tables are the same whoever creates them, and that the rows are the same bytes. Use the same prefix on both sides and give each job a name only one side uses. Then one dashboard, Rails or Node, shows every job, and one MCP server reads them all.
|
|
228
|
+
The ActiveRecord store writes the same three tables as `@cronwatch/sdk/postgres` and `@cronwatch/sdk/sqlite`: same names, columns and indexes, epoch milliseconds in the time columns, the SDK's camelCase JSON in the JSON columns. [`test/active_record/node_compat_test.rb`](https://github.com/phillips-jon/cronwatch/blob/main/packages/ruby/test/active_record/node_compat_test.rb) runs the SDK's stores in Node beside this one, on SQLite and Postgres, and checks that each reads what the other wrote, that the tables are the same whoever creates them, and that the rows are the same bytes. Use the same prefix on both sides and give each job a name only one side uses. Then one dashboard, Rails or Node, shows every job, and one MCP server reads them all. The Python, PHP, Go and Rust ports keep the same tables too, so a process in any of them can share the database the same way.
|
|
229
229
|
|
|
230
230
|
## Kept in step with the TypeScript SDK
|
|
231
231
|
|
|
@@ -93,13 +93,14 @@ module Cronwatch
|
|
|
93
93
|
JS.head16(text, max)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
# The run fields worth attaching to a tracker event.
|
|
96
|
+
# The run fields worth attaching to a tracker event. A start before the
|
|
97
|
+
# year 1 or after 9999 is nil.
|
|
97
98
|
def run_summary(alert)
|
|
98
99
|
run = alert.run
|
|
99
100
|
return nil unless run
|
|
100
101
|
|
|
101
102
|
{
|
|
102
|
-
"id" => run.id, "status" => run.status.to_s, "startedAt" =>
|
|
103
|
+
"id" => run.id, "status" => run.status.to_s, "startedAt" => Duration.iso_time(run.started_at),
|
|
103
104
|
"durationMs" => run.duration_ms, "trigger" => run.trigger,
|
|
104
105
|
}
|
|
105
106
|
end
|
data/lib/cronwatch/client.rb
CHANGED
|
@@ -229,7 +229,7 @@ module Cronwatch
|
|
|
229
229
|
|
|
230
230
|
finished_at = now
|
|
231
231
|
run.finished_at = finished_at
|
|
232
|
-
run.duration_ms =
|
|
232
|
+
run.duration_ms = Evaluate.run_duration(started_at, finished_at)
|
|
233
233
|
run.metrics = recorder.metrics
|
|
234
234
|
returned = result.is_a?(String) ? Output.utf8(result) : nil
|
|
235
235
|
run.output = recorder.output || (returned && Output.cap(returned))
|
|
@@ -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
|
-
|
|
659
|
+
name = definition.name
|
|
660
|
+
return if @registry.synchronize { @synced.include?(name) }
|
|
648
661
|
|
|
649
|
-
|
|
650
|
-
@registry.synchronize { @
|
|
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 }
|
|
@@ -687,7 +708,7 @@ module Cronwatch
|
|
|
687
708
|
state, result = yield(current)
|
|
688
709
|
break [current, result] if same_state?(state, current)
|
|
689
710
|
|
|
690
|
-
version = current
|
|
711
|
+
version = Evaluate.state_version(current)
|
|
691
712
|
following = state.dup
|
|
692
713
|
following.version = version + 1
|
|
693
714
|
break [following, result] if write_state(following, version)
|
|
@@ -938,7 +959,7 @@ module Cronwatch
|
|
|
938
959
|
run = from.dup
|
|
939
960
|
run.status = :running
|
|
940
961
|
run.finished_at = finished_at
|
|
941
|
-
run.duration_ms =
|
|
962
|
+
run.duration_ms = Evaluate.run_duration(from.started_at, finished_at)
|
|
942
963
|
run.error = nil
|
|
943
964
|
run.output = join_output(from.output, added)
|
|
944
965
|
run.metrics = (from.metrics || {}).merge(recorder.metrics)
|
|
@@ -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}\", " \
|
|
@@ -1063,7 +1086,7 @@ module Cronwatch
|
|
|
1063
1086
|
timeout = Evaluate.timeout_ms(definition)
|
|
1064
1087
|
run.status = :timeout
|
|
1065
1088
|
run.finished_at = at
|
|
1066
|
-
run.duration_ms =
|
|
1089
|
+
run.duration_ms = Evaluate.run_duration(run.started_at, at)
|
|
1067
1090
|
run.error = "Still running after #{Duration.format(timeout)}; marked as timed out"
|
|
1068
1091
|
# Only over a row still running: a finish that landed meanwhile wins.
|
|
1069
1092
|
next unless write_run_if(run, [:running])
|
data/lib/cronwatch/duration.rb
CHANGED
|
@@ -83,5 +83,22 @@ module Cronwatch
|
|
|
83
83
|
text = format(abs)
|
|
84
84
|
diff.negative? ? "#{text} ago" : "in #{text}"
|
|
85
85
|
end
|
|
86
|
+
|
|
87
|
+
# The first millisecond written as a date: 0001-01-01T00:00:00.000Z.
|
|
88
|
+
FIRST_DATE_MS = -62_135_596_800_000
|
|
89
|
+
# The last millisecond written as a date: 9999-12-31T23:59:59.999Z.
|
|
90
|
+
LAST_DATE_MS = 253_402_300_799_999
|
|
91
|
+
|
|
92
|
+
# "2026-01-05T09:30:00.000Z", or nil for a time before the year 1 or
|
|
93
|
+
# after 9999. A start read from another process's row, or a damaged one,
|
|
94
|
+
# can be any number; outside those years it is not written as a date.
|
|
95
|
+
def iso_time(at)
|
|
96
|
+
at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? JS.iso(at) : nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The words that stand in for a time iso_time does not write.
|
|
100
|
+
def beyond_dates(at)
|
|
101
|
+
at > LAST_DATE_MS ? "after 9999-12-31 23:59:59 UTC" : "before 0001-01-01 00:00:00 UTC"
|
|
102
|
+
end
|
|
86
103
|
end
|
|
87
104
|
end
|
data/lib/cronwatch/evaluate.rb
CHANGED
|
@@ -18,8 +18,49 @@ module Cronwatch
|
|
|
18
18
|
CheckEvaluation = Struct.new(:state, :alerts, :next_expected_at, :due_at, keyword_init: true)
|
|
19
19
|
SlowThreshold = Struct.new(:threshold_ms, :basis, keyword_init: true)
|
|
20
20
|
|
|
21
|
+
# The longest duration written: the largest integer JavaScript holds
|
|
22
|
+
# exactly, which every port and store reads back unchanged.
|
|
23
|
+
MAX_DURATION_MS = 9_007_199_254_740_991
|
|
24
|
+
|
|
21
25
|
module_function
|
|
22
26
|
|
|
27
|
+
# How long a run took, from `started_at` to `finished_at`: 0 when it
|
|
28
|
+
# started later, and never more than MAX_DURATION_MS. A foreign row's
|
|
29
|
+
# start near a 64-bit limit must not make a duration no store can write.
|
|
30
|
+
def run_duration(started_at, finished_at)
|
|
31
|
+
ms = finished_at - started_at
|
|
32
|
+
return 0 unless ms.is_a?(Numeric) && ms.positive?
|
|
33
|
+
|
|
34
|
+
ms = ms.to_i if ms.is_a?(Float) && ms.finite? && ms == ms.floor
|
|
35
|
+
[ms, MAX_DURATION_MS].min
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The version a stored state counts as for compare_and_set_state: its
|
|
39
|
+
# `version` when that is a whole number from 0 to MAX_DURATION_MS
|
|
40
|
+
# (2**53 - 1), else 0, as when it is absent. The SQL stores read it the
|
|
41
|
+
# same way, so a foreign row's 1.5, "x" or -1 is written over by the next
|
|
42
|
+
# update instead of refusing every compare-and-set of its job for good.
|
|
43
|
+
def state_version(state)
|
|
44
|
+
version = state&.version
|
|
45
|
+
return 0 unless version.is_a?(Integer) || (version.is_a?(Float) && version.finite? && version == version.floor)
|
|
46
|
+
return 0 unless version >= 0 && version <= MAX_DURATION_MS
|
|
47
|
+
|
|
48
|
+
version.to_i
|
|
49
|
+
end
|
|
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
|
+
|
|
23
64
|
def empty_state(job)
|
|
24
65
|
JobState.new(job: job, open: {}, consecutive_failures: 0, silenced_until: nil, last_alert_at: nil,
|
|
25
66
|
pending_recovery: [], undelivered: [])
|
|
@@ -33,7 +74,7 @@ module Cronwatch
|
|
|
33
74
|
JobState.new(
|
|
34
75
|
job: state.job.nil? ? job : state.job,
|
|
35
76
|
open: (state.open || {}).dup,
|
|
36
|
-
consecutive_failures: state
|
|
77
|
+
consecutive_failures: failure_count(state),
|
|
37
78
|
silenced_until: state.silenced_until,
|
|
38
79
|
last_alert_at: state.last_alert_at,
|
|
39
80
|
pending_recovery: (state.pending_recovery || []).dup,
|
|
@@ -205,7 +246,8 @@ module Cronwatch
|
|
|
205
246
|
end
|
|
206
247
|
|
|
207
248
|
# failed or timeout
|
|
208
|
-
|
|
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
|
|
209
251
|
close_condition(next_state, :missed)
|
|
210
252
|
threshold = [1, definition.failures_before_alert || 1].max
|
|
211
253
|
condition = run.status == :timeout ? :stuck : :failed
|
data/lib/cronwatch/format.rb
CHANGED
|
@@ -9,7 +9,10 @@ module Cronwatch
|
|
|
9
9
|
def at_time(at, now)
|
|
10
10
|
return "never" if at.nil?
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
iso = Duration.iso_time(at)
|
|
13
|
+
return Duration.beyond_dates(at) if iso.nil?
|
|
14
|
+
|
|
15
|
+
"#{iso.tr("T", " ")[0, 19]} UTC (#{Duration.relative(at, now)})"
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def first_lines(text, n)
|
data/lib/cronwatch/output.rb
CHANGED
|
@@ -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
|
data/lib/cronwatch/pg_cron.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
|
@@ -161,7 +165,7 @@ module Cronwatch
|
|
|
161
165
|
finish = done ? [started_at, finished_at || started_at].max : nil
|
|
162
166
|
Run.new(
|
|
163
167
|
id: "#{id_prefix}#{row["runid"]}", job: job, status: status, started_at: started_at, finished_at: finish,
|
|
164
|
-
duration_ms: finish.nil? ? nil :
|
|
168
|
+
duration_ms: finish.nil? ? nil : Evaluate.run_duration(started_at, finish),
|
|
165
169
|
error: status == :failed ? (message || "pg_cron reported the run as failed") : nil,
|
|
166
170
|
output: status == :ok ? message : nil, metrics: {}, trigger: "pg_cron",
|
|
167
171
|
)
|
|
@@ -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
|
-
|
|
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
|
|
320
|
-
# Returns [{ jobid => name }, { jobid => definition as declared }].
|
|
321
|
-
def declare(host,
|
|
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
|
|
326
|
-
|
|
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)"}",
|
data/lib/cronwatch/schedule.rb
CHANGED
|
@@ -15,6 +15,15 @@ module Cronwatch
|
|
|
15
15
|
# How early a run may start and still count for the fire it was meant for.
|
|
16
16
|
EARLY_SLACK_MS = 60_000
|
|
17
17
|
|
|
18
|
+
# Four hundred Gregorian years: 146,097 days, a whole number of weeks,
|
|
19
|
+
# after which the calendar repeats date for date and weekday for weekday.
|
|
20
|
+
CYCLE_MS = 146_097 * 86_400_000
|
|
21
|
+
# The years the walker searches in, as croner does: a time before 400 is
|
|
22
|
+
# asked a cycle or more later (croner misreads a year below 100), and one
|
|
23
|
+
# from 2800 a cycle or more earlier (croner finds no fire past 3000).
|
|
24
|
+
WALK_FIRST_MS = -49_544_438_400_000 # 0400-01-01T00:00:00Z
|
|
25
|
+
WALK_LAST_MS = 26_192_246_400_000 # 2800-01-01T00:00:00Z
|
|
26
|
+
|
|
18
27
|
Parsed = Struct.new(:kind, :source, :timezone, :every_ms, keyword_init: true) do
|
|
19
28
|
include Serializable
|
|
20
29
|
|
|
@@ -80,12 +89,15 @@ module Cronwatch
|
|
|
80
89
|
def fire_after(parsed, from)
|
|
81
90
|
raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
|
|
82
91
|
|
|
83
|
-
|
|
92
|
+
start = count_from(from)
|
|
93
|
+
return nil if start.nil?
|
|
94
|
+
|
|
95
|
+
probe = start
|
|
84
96
|
4.times do
|
|
85
97
|
runs = next_runs(parsed, 8, probe)
|
|
86
98
|
return nil if runs.empty?
|
|
87
99
|
|
|
88
|
-
found = runs.find { |t| t >
|
|
100
|
+
found = runs.find { |t| t > start }
|
|
89
101
|
return found if found
|
|
90
102
|
|
|
91
103
|
probe += 3_600_000
|
|
@@ -101,8 +113,11 @@ module Cronwatch
|
|
|
101
113
|
raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
|
|
102
114
|
|
|
103
115
|
out = []
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
start = count_from(from)
|
|
117
|
+
return out if start.nil?
|
|
118
|
+
|
|
119
|
+
probe = start
|
|
120
|
+
last = start
|
|
106
121
|
1000.times do
|
|
107
122
|
batch = next_runs(parsed, [limit + 1 - out.length, 24].min, probe)
|
|
108
123
|
return out if batch.empty?
|
|
@@ -121,15 +136,39 @@ module Cronwatch
|
|
|
121
136
|
out
|
|
122
137
|
end
|
|
123
138
|
|
|
124
|
-
#
|
|
139
|
+
# A stored time as a cron's fires are counted from it. A start read from
|
|
140
|
+
# a foreign or damaged row can be any number: one before the year 1
|
|
141
|
+
# counts from just before its first millisecond, so the first fire of the
|
|
142
|
+
# year 1 is the next one, and one at or after the last millisecond of 9999
|
|
143
|
+
# has no fire after it at all (nil). Ported from countFrom in schedule.ts.
|
|
144
|
+
def count_from(from)
|
|
145
|
+
return nil if from >= Duration::LAST_DATE_MS
|
|
146
|
+
|
|
147
|
+
from >= Duration::FIRST_DATE_MS ? from : Duration::FIRST_DATE_MS - 1
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Up to `count` fires, each found from the one before, as croner's
|
|
151
|
+
# nextRuns, and none after 9999. A time outside the years the walker
|
|
152
|
+
# searches is moved by whole 400-year cycles into them, and its fires
|
|
153
|
+
# moved back, as runsAfter in schedule.ts asks croner: a time before 400
|
|
154
|
+
# goes forward, into the same local mean time every zone kept then, and
|
|
155
|
+
# one from 2800 goes back, to where the zone's present rules already hold.
|
|
125
156
|
def next_runs(parsed, count, from)
|
|
157
|
+
from = count_from(from)
|
|
158
|
+
return [] if from.nil?
|
|
159
|
+
|
|
160
|
+
shift =
|
|
161
|
+
if from < WALK_FIRST_MS then -((from - WALK_FIRST_MS).div(CYCLE_MS)) * CYCLE_MS
|
|
162
|
+
elsif from >= WALK_LAST_MS then -((from - WALK_LAST_MS).div(CYCLE_MS) + 1) * CYCLE_MS
|
|
163
|
+
else 0
|
|
164
|
+
end
|
|
126
165
|
runs = []
|
|
127
|
-
at = from
|
|
166
|
+
at = from + shift
|
|
128
167
|
count.times do
|
|
129
168
|
at = parsed.pattern.next_after(at, parsed.timezone)
|
|
130
|
-
break if at.nil?
|
|
169
|
+
break if at.nil? || at - shift > Duration::LAST_DATE_MS
|
|
131
170
|
|
|
132
|
-
runs << at
|
|
171
|
+
runs << (at - shift)
|
|
133
172
|
end
|
|
134
173
|
runs
|
|
135
174
|
end
|
|
@@ -159,7 +198,9 @@ module Cronwatch
|
|
|
159
198
|
due_at.nil? ? nil : Expectation.new(due_at: due_at, deadline: due_at + grace_ms)
|
|
160
199
|
end
|
|
161
200
|
|
|
162
|
-
# The first fire that a run starting at `started_at` does not cover.
|
|
201
|
+
# The first fire that a run starting at `started_at` does not cover. A
|
|
202
|
+
# start before the year 1 covers none of them, so the first fire of the
|
|
203
|
+
# year 1 is due; after 9999 there is none (see count_from).
|
|
163
204
|
def due_after_run(parsed, started_at)
|
|
164
205
|
# A fire at or before the start is covered by the run itself.
|
|
165
206
|
following_fire = fire_after(parsed, started_at)
|
|
@@ -189,6 +230,9 @@ module Cronwatch
|
|
|
189
230
|
# ran early by up to an hour.
|
|
190
231
|
def in_spring_forward_gap?(parsed, started_at, fire_at)
|
|
191
232
|
lookback = 3 * 3_600_000
|
|
233
|
+
# Every zone kept its local mean time, with no clock change, in the year 1.
|
|
234
|
+
return false if fire_at - lookback < Duration::FIRST_DATE_MS
|
|
235
|
+
|
|
192
236
|
after = utc_offset(fire_at, parsed.timezone)
|
|
193
237
|
before = utc_offset(fire_at - lookback, parsed.timezone)
|
|
194
238
|
gap = after - before
|
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
265
|
+
state_text = json_text(state.to_h)
|
|
266
266
|
changed =
|
|
267
|
-
if expected_version.zero? then write(:cas_insert, [state.job,
|
|
268
|
-
else write(:cas_update, [
|
|
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
|
|
@@ -344,8 +344,20 @@ module Cronwatch
|
|
|
344
344
|
seq = pg ? "seq" : "rowid"
|
|
345
345
|
# Byte order on both, so names sort the same whatever the database's collation.
|
|
346
346
|
by_name = pg ? 'name COLLATE "C"' : "name"
|
|
347
|
-
# The version inside a state's JSON,
|
|
348
|
-
|
|
347
|
+
# The version inside a state's JSON, as Evaluate.state_version reads
|
|
348
|
+
# it: a whole number from 0 to 2**53 - 1, else 0 (none, or a foreign
|
|
349
|
+
# 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
|
+
version = lambda do |column|
|
|
352
|
+
if pg
|
|
353
|
+
v = "(#{column}->>'version')::numeric"
|
|
354
|
+
next "CASE WHEN jsonb_typeof(#{column}->'version') <> 'number' THEN 0 " \
|
|
355
|
+
"WHEN #{v} % 1 = 0 AND #{v} BETWEEN 0 AND 9007199254740991 THEN #{v}::bigint ELSE 0 END"
|
|
356
|
+
end
|
|
357
|
+
v = "json_extract(#{column}, '$.version')"
|
|
358
|
+
"CASE WHEN json_type(#{column}, '$.version') NOT IN ('integer', 'real') THEN 0 " \
|
|
359
|
+
"WHEN #{v} = CAST(#{v} AS INTEGER) AND #{v} BETWEEN 0 AND 9007199254740991 THEN CAST(#{v} AS INTEGER) ELSE 0 END"
|
|
360
|
+
end
|
|
349
361
|
sql = {
|
|
350
362
|
upsert_job: "INSERT INTO #{p}jobs (name, definition, created_at, updated_at) VALUES (?, ?, ?, ?)\n " \
|
|
351
363
|
"ON CONFLICT (name) DO UPDATE SET definition = excluded.definition, updated_at = excluded.updated_at",
|
|
@@ -383,6 +395,19 @@ module Cronwatch
|
|
|
383
395
|
|
|
384
396
|
# SQLite hands back JSON as TEXT; Postgres drivers may hand back JSONB
|
|
385
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
|
+
|
|
386
411
|
def json(value)
|
|
387
412
|
js_numbers(value.is_a?(String) ? JS.parse(value) : value)
|
|
388
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:
|
|
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] =
|
|
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] =
|
|
128
|
+
sync { @states[state.job] = kept(state, JobState) }
|
|
129
129
|
nil
|
|
130
130
|
end
|
|
131
131
|
|
|
@@ -134,9 +134,9 @@ module Cronwatch
|
|
|
134
134
|
# wrote. See JobState#version.
|
|
135
135
|
def compare_and_set_state(state, expected_version)
|
|
136
136
|
sync do
|
|
137
|
-
next false unless (@states[state.job]
|
|
137
|
+
next false unless Evaluate.state_version(@states[state.job]) == expected_version
|
|
138
138
|
|
|
139
|
-
@states[state.job] =
|
|
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 =
|
|
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
|
|
@@ -92,6 +92,11 @@ module Cronwatch
|
|
|
92
92
|
"<job_data>\n#{text.gsub(TAG, "<_job_data")}\n</job_data>"
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
# "2026-01-05T09:30:00.000Z", or the words for a time before the year 1 or after 9999.
|
|
96
|
+
def self.stamp(at)
|
|
97
|
+
Duration.iso_time(at) || Duration.beyond_dates(at)
|
|
98
|
+
end
|
|
99
|
+
|
|
95
100
|
# The prompt: the alert, the definition, the triggering run and up to five earlier ones.
|
|
96
101
|
def self.describe(triage_context)
|
|
97
102
|
alert = triage_context.alert
|
|
@@ -104,7 +109,7 @@ module Cronwatch
|
|
|
104
109
|
lines << "Job definition: #{JS.json(definition.respond_to?(:to_h) ? definition.to_h : definition)}"
|
|
105
110
|
if run
|
|
106
111
|
lines << ""
|
|
107
|
-
lines << "Triggering run: status #{run.status}, started #{
|
|
112
|
+
lines << "Triggering run: status #{run.status}, started #{stamp(run.started_at)}, duration #{duration(run)}, trigger #{run.trigger}"
|
|
108
113
|
lines << "Metrics: #{JS.json(run.metrics)}" if run.metrics && !run.metrics.empty?
|
|
109
114
|
lines << "Error:\n#{data(JS.head16(run.error, 3000))}" if present?(run.error)
|
|
110
115
|
lines << "Output (tail):\n#{data(JS.tail16(run.output, 3000))}" if present?(run.output)
|
|
@@ -116,7 +121,7 @@ module Cronwatch
|
|
|
116
121
|
earlier.each do |r|
|
|
117
122
|
error = present?(r.error) ? ", error: #{data(JS.head16(r.error.split("\n", -1).first.to_s, 160))}" : ""
|
|
118
123
|
metrics = r.metrics && !r.metrics.empty? ? ", metrics #{JS.json(r.metrics)}" : ""
|
|
119
|
-
lines << "- #{r.status}, #{
|
|
124
|
+
lines << "- #{r.status}, #{stamp(r.started_at)}, #{duration(r)}#{error}#{metrics}"
|
|
120
125
|
end
|
|
121
126
|
end
|
|
122
127
|
lines.join("\n")
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/web/html.rb
CHANGED
|
@@ -329,7 +329,9 @@ module Cronwatch
|
|
|
329
329
|
def stamp(at, now)
|
|
330
330
|
return %(<span class="muted">never</span>) if at.nil?
|
|
331
331
|
|
|
332
|
-
iso =
|
|
332
|
+
iso = Duration.iso_time(at.to_i)
|
|
333
|
+
return %(<span class="nowrap">#{Duration.beyond_dates(at.to_i)}</span>) if iso.nil?
|
|
334
|
+
|
|
333
335
|
%(<time class="nowrap" datetime="#{iso}" title="#{iso.sub("T", " ")[0, 19]} UTC">#{h(Duration.relative(at, now))}</time>)
|
|
334
336
|
end
|
|
335
337
|
|
|
@@ -86,12 +86,19 @@ module Cronwatch
|
|
|
86
86
|
"#{WEEKDAYS[d.wday]} #{d.day} #{MONTHS[d.month - 1]}"
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
# "22:42" on the same UTC day as `now`, otherwise "25 Sep 22:42"
|
|
89
|
+
# "22:42" on the same UTC day as `now`, otherwise "25 Sep 22:42", and
|
|
90
|
+
# "1 Jan 0001 02:00" in another UTC year. A time before the year 1 or
|
|
91
|
+
# after 9999 (a start read from a foreign or damaged row) is "before 1
|
|
92
|
+
# Jan 0001 00:00" or "after 31 Dec 9999 23:59".
|
|
90
93
|
def when_at(t, now)
|
|
94
|
+
return "after 31 Dec 9999 23:59" if t > Duration::LAST_DATE_MS
|
|
95
|
+
return "before 1 Jan 0001 00:00" if t < Duration::FIRST_DATE_MS
|
|
96
|
+
|
|
91
97
|
return clock(t) if t.div(DAY) == now.div(DAY)
|
|
92
98
|
|
|
93
99
|
d = utc(t)
|
|
94
|
-
|
|
100
|
+
year = d.year == utc(now).year ? "" : Kernel.format(" %04d", d.year)
|
|
101
|
+
"#{d.day} #{MONTHS[d.month - 1]}#{year} #{clock(t)}"
|
|
95
102
|
end
|
|
96
103
|
|
|
97
104
|
# The job's schedule, parsed, or nil when it has none or it no longer parses.
|