monkrb 0.21.1 → 0.22.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.
@@ -0,0 +1,11 @@
1
+ module Monk
2
+ module Jobs
3
+ # What one fire_recurring call did for a recurring task's occurrence
4
+ # (docs/history/plan-scheduled-jobs.md decision 5): result is :initialized,
5
+ # :enqueued, :skipped_pending, :already_handled, :not_due or
6
+ # :reinitialized; job_id is the enqueued job's, when there is one;
7
+ # previous_checkpoint is the task's checkpoint before the call (nil
8
+ # for a new task), for the "skipped N occurrences" log line.
9
+ Firing = Data.define(:result, :job_id, :previous_checkpoint)
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module Monk
2
+ module Jobs
3
+ # One declared schedule (Monk::Jobs.recurring): the key it's stored
4
+ # under in monk_recurring_tasks, the job class to enqueue, its parsed
5
+ # Cron and the args to enqueue with. Queue, priority and max_attempts
6
+ # come from the job class, as with any enqueue.
7
+ RecurringTask = Data.define(:key, :job_class, :cron, :args, :skip_if_pending)
8
+ end
9
+ end
@@ -1,6 +1,7 @@
1
1
  require "socket"
2
2
  require_relative "../jobs"
3
3
  require_relative "worker"
4
+ require_relative "scheduler"
4
5
 
5
6
  module Monk
6
7
  module Jobs
@@ -53,6 +54,9 @@ module Monk
53
54
  def run(trap_signals: true)
54
55
  Monk.freeze!
55
56
  @adapter = Monk::Jobs.adapter
57
+ tasks = Monk::Jobs.recurring_tasks
58
+ check_recurring_table!(tasks)
59
+ @scheduler = Scheduler.new(@adapter, tasks)
56
60
  @hostname = Socket.gethostname
57
61
  @pid = Process.pid
58
62
  @process_id = @adapter.register_process(hostname: @hostname, pid: @pid)
@@ -65,7 +69,8 @@ module Monk
65
69
  @holding_port = Ractor::Port.new
66
70
  @worker_count.times { |index| spawn_worker(index) }
67
71
  Monk::Log.info(
68
- "Monk::Jobs process #{@process_id} started: #{@worker_count} workers on #{@queues.join(", ")} (pid #{@pid})",
72
+ "Monk::Jobs process #{@process_id} started: #{@worker_count} workers on #{@queues.join(", ")}, " \
73
+ "#{tasks.size} recurring task#{"s" unless tasks.size == 1} (pid #{@pid})",
69
74
  )
70
75
 
71
76
  ticker, timer = start_ticker
@@ -104,10 +109,19 @@ module Monk
104
109
  end
105
110
  end
106
111
 
112
+ # The scheduler runs after the rest and under its own rescue, so an
113
+ # error that only it hits can't stop the heartbeat: other processes
114
+ # would take this one for dead.
115
+ def tick(last_heartbeat)
116
+ last_heartbeat = housekeeping(last_heartbeat)
117
+ schedule
118
+ last_heartbeat
119
+ end
120
+
107
121
  # A database error here (Postgres restarting, a dropped connection)
108
122
  # mustn't end the process: it's logged, this Ractor's connection is
109
123
  # reset, and the next tick tries again.
110
- def tick(last_heartbeat)
124
+ def housekeeping(last_heartbeat)
111
125
  release_dead_workers_jobs
112
126
  @adapter.stage_due
113
127
  return last_heartbeat if monotonic - last_heartbeat < @heartbeat_interval
@@ -120,6 +134,28 @@ module Monk
120
134
  last_heartbeat
121
135
  end
122
136
 
137
+ # Recurring tasks (docs/history/plan-scheduled-jobs.md decision 1). An
138
+ # occurrence that errors isn't marked handled, so the next tick asks
139
+ # again.
140
+ def schedule
141
+ @scheduler.run
142
+ rescue StandardError => e
143
+ Monk::Log.error("Monk::Jobs scheduler: #{Monk::Jobs.describe_error(e)}; reconnecting")
144
+ reconnect
145
+ end
146
+
147
+ # Decision 9: a missing table is a deploy mistake, said once here
148
+ # rather than every second by the scheduler.
149
+ def check_recurring_table!(tasks)
150
+ return if tasks.empty? || @adapter.recurring_table?
151
+
152
+ raise MissingRecurringTableError,
153
+ "#{tasks.size} recurring task(s) declared (#{tasks.map(&:key).join(", ")}) but the database has no " \
154
+ "monk_recurring_tasks table. Add Monk's migration under a new timestamp in db/migrate/ " \
155
+ "(monk help jobs --file db/migrate/x_create_recurring_tables.up.sql, and .down.sql), then bin/migrate. " \
156
+ "The Monk 0.22 upgrade guide has the full steps"
157
+ end
158
+
123
159
  # A job stays on the list until its release has gone through (or found
124
160
  # the job no longer held), so a database error just retries it next tick.
125
161
  def release_dead_workers_jobs
@@ -0,0 +1,91 @@
1
+ require "json"
2
+ require_relative "../log"
3
+
4
+ module Monk
5
+ module Jobs
6
+ # The supervisor's per-tick step for recurring tasks
7
+ # (docs/history/plan-scheduled-jobs.md decisions 5-7): asks the database about
8
+ # each task's most recent occurrence, which enqueues it at most once
9
+ # across every job process. Lives in the supervisor and isn't shared:
10
+ # the occurrence each task last handled is this process's own memory,
11
+ # so a tick reaches the database once per occurrence, not once a second.
12
+ class Scheduler
13
+ # Past this many, a catch-up log line stops counting.
14
+ MAX_SKIPPED_COUNT = 10_000
15
+
16
+ def initialize(adapter, tasks)
17
+ @adapter = adapter
18
+ @tasks = tasks
19
+ @handled = {} # task key => the occurrence last handled here
20
+ end
21
+
22
+ # One pass as if the clock read `now`. clock: replaces the database
23
+ # clock too (Monk::Jobs.run_scheduler!); without it, the database
24
+ # decides whether an occurrence has arrived. Returns each task asked
25
+ # about this pass, key => Firing#result.
26
+ def run(now = Time.now, clock: nil)
27
+ @tasks.each_with_object({}) do |task, results|
28
+ occurrence = task.cron.previous(now)
29
+ next if @handled[task.key] == occurrence
30
+
31
+ firing = fire(task, occurrence, clock)
32
+ @handled[task.key] = occurrence unless firing.result == :not_due
33
+ report(task, occurrence, firing)
34
+ results[task.key] = firing.result
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def fire(task, occurrence, clock)
41
+ job = task.job_class
42
+ @adapter.fire_recurring(
43
+ key: task.key, cron: task.cron.expression, occurrence: occurrence, skip_if_pending: task.skip_if_pending,
44
+ job_class: job.name, queue: job.queue, priority: job.priority, max_attempts: job.max_attempts,
45
+ args: JSON.generate(task.args), clock: clock,
46
+ )
47
+ end
48
+
49
+ def report(task, occurrence, firing)
50
+ prefix = "Monk::Jobs recurring #{task.key}:"
51
+ case firing.result
52
+ when :initialized
53
+ Monk::Log.info("#{prefix} new task, first run at #{stamp(task.cron.next_after(occurrence))}")
54
+ when :reinitialized
55
+ Monk::Log.info("#{prefix} cron changed to #{task.cron.expression.inspect}, " \
56
+ "first run at #{stamp(task.cron.next_after(occurrence))}")
57
+ when :enqueued
58
+ Monk::Log.info("#{prefix} enqueued job #{firing.job_id} for #{stamp(occurrence)}")
59
+ when :skipped_pending
60
+ Monk::Log.info("#{prefix} skipped #{stamp(occurrence)}, the previous run is still pending")
61
+ end
62
+ report_catch_up(task, occurrence, firing, prefix)
63
+ end
64
+
65
+ # Decision 6: after downtime only the latest occurrence fires.
66
+ def report_catch_up(task, occurrence, firing, prefix)
67
+ return unless %i[enqueued skipped_pending].include?(firing.result) && firing.previous_checkpoint
68
+
69
+ skipped = skipped_between(task.cron, firing.previous_checkpoint, occurrence)
70
+ return if skipped.zero?
71
+
72
+ count = skipped >= MAX_SKIPPED_COUNT ? "#{MAX_SKIPPED_COUNT}+" : skipped
73
+ Monk::Log.warn(
74
+ "#{prefix} caught up from #{stamp(firing.previous_checkpoint)}, skipped #{count} occurrences",
75
+ )
76
+ end
77
+
78
+ def skipped_between(cron, from, to)
79
+ count = 0
80
+ time = cron.next_after(from)
81
+ while time < to && count < MAX_SKIPPED_COUNT
82
+ count += 1
83
+ time = cron.next_after(time)
84
+ end
85
+ count
86
+ end
87
+
88
+ def stamp(time) = time.utc.iso8601
89
+ end
90
+ end
91
+ end
data/lib/monk/jobs.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  require_relative "../monk"
4
4
  require_relative "jobs/errors"
5
5
  require_relative "jobs/args"
6
+ require_relative "jobs/cron"
7
+ require_relative "jobs/recurring_task"
8
+ require_relative "jobs/scheduler"
6
9
  require "json"
7
10
 
8
11
  module Monk
@@ -53,7 +56,7 @@ module Monk
53
56
  #
54
57
  # SendReceipt.enqueue(order_id) is the same call.
55
58
  def enqueue(job_class, *args, wait: nil, at: nil, conn: nil)
56
- check_enqueueable!(job_class)
59
+ check_enqueueable!(job_class, "Monk::Jobs.enqueue")
57
60
  Args.check!(args)
58
61
  check_schedule!(wait, at)
59
62
 
@@ -63,6 +66,51 @@ module Monk
63
66
  )
64
67
  end
65
68
 
69
+ # A job enqueued on a schedule by bin/jobs, typically in
70
+ # config/recurring.rb (loaded after app/jobs/, so the class exists):
71
+ #
72
+ # Monk::Jobs.recurring "purge_sessions", PurgeExpiredSessions, cron: "0 3 * * *"
73
+ # Monk::Jobs.recurring "weekly_digest", SendDigest, cron: "0 9 * * 1",
74
+ # args: ["weekly"], skip_if_pending: true
75
+ #
76
+ # The key is the task's identity in monk_recurring_tasks, so renaming
77
+ # the job class keeps its place in the schedule. cron: is UTC (Cron).
78
+ # skip_if_pending: true skips an occurrence while the job enqueued for
79
+ # the previous one hasn't finished. Everything is checked here, so a
80
+ # mistake fails at boot (docs/history/plan-scheduled-jobs.md decision 2).
81
+ def recurring(key, job_class, cron:, args: [], skip_if_pending: false)
82
+ if @recurring_registry
83
+ raise RegistryFrozenError,
84
+ "Monk::Jobs.recurring(#{key.inspect}) after the job registry is frozen -- declare recurring " \
85
+ "tasks while the app loads (config/recurring.rb), before Monk.boot or bin/jobs starts"
86
+ end
87
+ unless key.is_a?(String) && !key.empty?
88
+ raise ArgumentError, "a recurring task's key must be a non-empty String, got #{key.inspect}"
89
+ end
90
+ if recurring_list.any? { |task| task.key == key }
91
+ raise ArgumentError, "recurring task #{key.inspect} is already declared"
92
+ end
93
+
94
+ check_enqueueable!(job_class, "Monk::Jobs.recurring")
95
+ Args.check!(args)
96
+ unless [true, false].include?(skip_if_pending)
97
+ raise ArgumentError, "skip_if_pending: must be true or false, got #{skip_if_pending.inspect}"
98
+ end
99
+
100
+ task = RecurringTask.new(
101
+ key: key.dup.freeze, job_class: job_class, cron: Cron.parse(cron),
102
+ args: Ractor.make_shareable(Marshal.load(Marshal.dump(args))), skip_if_pending: skip_if_pending,
103
+ )
104
+ recurring_list << task
105
+ task
106
+ end
107
+
108
+ # The declared recurring tasks, in declaration order. Once the
109
+ # registry is frozen, a shareable Array any Ractor can read.
110
+ def recurring_tasks
111
+ @recurring_registry || recurring_list.dup.freeze
112
+ end
113
+
66
114
  # Called by Monk::Job.inherited, in the main Ractor as the app's job
67
115
  # files load. Only recorded here: names are resolved when the
68
116
  # registry is frozen, so a class that gets its constant name after
@@ -94,6 +142,7 @@ module Monk
94
142
  # without a name a job can never be found again once enqueued.
95
143
  def freeze_registry!
96
144
  @registry = Ractor.make_shareable(classes.filter_map { |job| [job.name, job] if job.name }.to_h)
145
+ @recurring_registry = Ractor.make_shareable(recurring_list.dup)
97
146
  end
98
147
 
99
148
  # A failed job back to available, with its attempts reset; true if it
@@ -150,9 +199,32 @@ module Monk
150
199
  ran
151
200
  end
152
201
 
202
+ # Runs one scheduler pass as if the clock read `at`, for an app's
203
+ # tests of its recurring tasks; returns key => result (:initialized,
204
+ # :enqueued, ...). `at` stands in for the database clock as well, so
205
+ # it only runs with MONK_ENV=test. The first pass sets a task's
206
+ # checkpoint without enqueueing, as a deploy would:
207
+ #
208
+ # Monk::Jobs.run_scheduler!(at: Time.utc(2026, 10, 12, 8, 59))
209
+ # Monk::Jobs.run_scheduler!(at: Time.utc(2026, 10, 12, 9)) # => {"weekly_digest" => :enqueued}
210
+ # Monk::Jobs.drain!
211
+ #
212
+ # Calls Monk.freeze! first, as bin/jobs does.
213
+ def run_scheduler!(at: Time.now)
214
+ unless Monk.env.test?
215
+ raise RunSchedulerOutsideTestsError,
216
+ "Monk::Jobs.run_scheduler! fires schedules at a made-up time, so it only runs with MONK_ENV=test " \
217
+ "(this is #{Monk.env})"
218
+ end
219
+
220
+ Monk.freeze!
221
+ Scheduler.new(adapter, recurring_tasks).run(at, clock: at)
222
+ end
223
+
153
224
  # Empties the queue: every job, failed ones included, and every job
154
- # process row. For an app's tests, between one test and the next --
155
- # and so it refuses to run unless MONK_ENV is test.
225
+ # process row, and every recurring task's checkpoint. For an app's
226
+ # tests, between one test and the next -- and so it refuses to run
227
+ # unless MONK_ENV is test.
156
228
  def clear!
157
229
  unless Monk.env.test?
158
230
  raise ClearOutsideTestsError,
@@ -162,12 +234,14 @@ module Monk
162
234
  adapter.clear!
163
235
  end
164
236
 
165
- # Test-only: unfreezes the registry and forgets the configuration.
166
- # Recorded classes stay recorded, the same way a Ruby class can't be
167
- # undefined.
237
+ # Test-only: unfreezes the registry and forgets the configuration and
238
+ # the recurring tasks. Recorded classes stay recorded, the same way a
239
+ # Ruby class can't be undefined.
168
240
  def reset!
169
241
  @registry = nil
170
242
  @adapter = nil
243
+ @recurring_list = nil
244
+ @recurring_registry = nil
171
245
  end
172
246
 
173
247
  private
@@ -199,9 +273,9 @@ module Monk
199
273
  raise e.cause, cause: nil
200
274
  end
201
275
 
202
- def check_enqueueable!(job_class)
276
+ def check_enqueueable!(job_class, caller_name)
203
277
  unless job_class.is_a?(Class) && job_class < Monk::Job
204
- raise ArgumentError, "Monk::Jobs.enqueue needs a Monk::Job subclass, got #{job_class.inspect}"
278
+ raise ArgumentError, "#{caller_name} needs a Monk::Job subclass, got #{job_class.inspect}"
205
279
  end
206
280
  return if job_class.name
207
281
 
@@ -220,6 +294,10 @@ module Monk
220
294
  def classes
221
295
  @classes ||= []
222
296
  end
297
+
298
+ def recurring_list
299
+ @recurring_list ||= []
300
+ end
223
301
  end
224
302
 
225
303
  Monk.freeze_hooks << self
@@ -234,3 +312,4 @@ require_relative "jobs/job"
234
312
  # themselves (docs/adr/0017).
235
313
  require_relative "mail/deliver_later" if Monk.const_defined?(:Mail)
236
314
  require_relative "auth/send_login_link" if Monk.const_defined?(:Auth)
315
+ require_relative "auth/sweep" if Monk.const_defined?(:Auth)
@@ -11,13 +11,20 @@
11
11
  `Monk::Auth.redeem(token)` returns a session or nil. In routes:
12
12
  `set_session_cookie(session)`, `current_subject` (nil when logged out),
13
13
  `require_user!` (halts 401), `require_csrf!`, `log_out!`. With jobs:
14
- `Monk::Auth::SendLoginLink.enqueue(email)`.
14
+ `Monk::Auth::SendLoginLink.enqueue(email)`. Rate limit, in the login
15
+ route before sending: `halt(429) unless Monk::Auth.allow_login_request?(email)`
16
+ (one per address per `login_request_interval:`). `Monk::Auth.sweep!`
17
+ deletes expired rows; with jobs, `Monk::Auth::Sweep` runs it nightly
18
+ (`config/recurring.rb`).
15
19
 
16
20
  **Test:** `test/auth_test.rb` sends a link to `log/test.log` and redeems it.
21
+ A test that asks for links calls `Monk::Auth.clear_login_requests!` in
22
+ `teardown`, or its next request for the same address gets a 429.
17
23
 
18
24
  **Pitfalls:**
19
- - `/auth/request` needs a per-email rate limit before it goes live: without
20
- one, anyone can make the app send mail to any address.
25
+ - Keep `allow_login_request?` in `/auth/request`: without a rate limit,
26
+ anyone can make the app send mail to any address. It limits each
27
+ address, not a client asking for many.
21
28
  - Never point `deliver:` at `Monk::Mail.deliver_later`: the link, token
22
29
  included, would sit in the job queue. Use `SendLoginLink` instead.
23
30
  - Links come from `Monk::Settings[:public_url]`, never from request headers.
@@ -3,14 +3,8 @@
3
3
  # its decisions: the rate limit, where to go after logging in, JSON or HTML.
4
4
  #
5
5
  # monk:example auth-routes -- log in by email link, log out, a page for logged-in users.
6
- # # Before going live: keep a rate limit (without one, anyone can make this
6
+ # # Before going live: keep the rate limit (without one, anyone can make this
7
7
  # # app send mail to any address), and list your redirects in config/auth.rb.
8
- # module AuthRoutes
9
- # # Per process and approximate: 5 links per address per 10 minutes. With
10
- # # several bin/server processes, count in the database instead.
11
- # LIMIT = Monk::Auth::RateLimiter.new(limit: 5, window: 600)
12
- # end
13
- #
14
8
  # class App
15
9
  # get("/login") { @title = "Log in"; render "auth/login" }
16
10
  #
@@ -18,7 +12,9 @@
18
12
  # post("/auth/request") do
19
13
  # email = params[:email].to_s.strip.downcase
20
14
  # halt(422) if email.empty?
21
- # halt(429) if AuthRoutes::LIMIT.exceeded?(email)
15
+ # # One link per address per login_request_interval (config/auth.rb),
16
+ # # counted in the database, so across every bin/server process.
17
+ # halt(429) unless Monk::Auth.allow_login_request?(email)
22
18
  #
23
19
  # # Sent here, in the request. With jobs, replace these two lines with
24
20
  # # Monk::Auth::SendLoginLink.enqueue(email): the token is then made and
@@ -20,6 +20,7 @@ Monk::Auth.configure(
20
20
  session_ttl: 1_209_600, # seconds a session stays valid (14 days)
21
21
  redirect_allowlist: [], # paths request_login(redirect_to:) is allowed to target
22
22
  callback_path: "/auth/callback", # your route redeeming a token: GET "#{callback_path}/:token"
23
+ login_request_interval: 60, # seconds an address waits between login emails (allow_login_request?)
23
24
  secure: !Monk.env.development?, # Secure cookie flag; off in dev so plain-http testing works (Safari drops it)
24
25
  deliver: AuthMailer::MAGIC_LINK,
25
26
  )
@@ -0,0 +1,2 @@
1
+ DROP INDEX sessions_subject;
2
+ DROP TABLE login_requests;
@@ -0,0 +1,16 @@
1
+ -- Monk::Auth housekeeping (Monk 0.22): the login-email rate limit's table
2
+ -- and an index for finding a person's sessions.
3
+
4
+ -- One row per address: when a login link was last requested for it, for
5
+ -- Monk::Auth.allow_login_request?. Checked in the login route, before the
6
+ -- link is sent or enqueued: with jobs the login token only exists once the
7
+ -- job runs, so login_tokens can't tell a request is already queued.
8
+ -- Monk::Auth.sweep! deletes rows older than login_request_interval.
9
+ CREATE TABLE login_requests (
10
+ email TEXT PRIMARY KEY, -- stripped and downcased
11
+ requested_at TIMESTAMPTZ NOT NULL DEFAULT now()
12
+ );
13
+
14
+ -- A person's sessions by subject: Monk::Auth.revoke_all, or who is online.
15
+ -- An app that already has an index on sessions (subject) can drop this line.
16
+ CREATE INDEX sessions_subject ON sessions (subject);
@@ -1,8 +1,9 @@
1
1
  ## auth
2
2
 
3
3
  Logging in by email link. `config/auth.rb` configures Monk::Auth,
4
- `app/mailers/auth_mailer.rb` sends the link through mail, and a migration
5
- creates the `login_tokens` and `sessions` tables: run `bin/setup_db` and
4
+ `app/mailers/auth_mailer.rb` sends the link through mail, and two
5
+ migrations create the `login_tokens`, `sessions` and `login_requests`
6
+ tables: run `bin/setup_db` and
6
7
  `DB_NAME={{app}}_test bin/setup_db` (postgres, above).
7
8
 
8
9
  ### Before production
@@ -12,6 +13,17 @@ one in production's environment, e.g. from
12
13
  `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'`. `config/auth.rb`
13
14
  reads it with `ENV.fetch`: unset, the app doesn't boot.
14
15
 
16
+ ### Housekeeping
17
+
18
+ - **Rate limit.** The login route allows one email per address per
19
+ minute (`login_request_interval:` in `config/auth.rb`), counted in
20
+ `login_requests`, so across every `bin/server` process. A limit across
21
+ addresses (per IP, say) is up to the app.
22
+ - **Cleanup.** With jobs (`monk add jobs`), `bin/jobs` deletes expired
23
+ sessions and login tokens every night (`monk_auth_sweep` in
24
+ `config/recurring.rb`). Without jobs, run `Monk::Auth.sweep!` on a
25
+ schedule of your own.
26
+
15
27
  ### Check it works
16
28
 
17
29
  1. `bundle exec rake test` runs `test/auth_test.rb`: a link is sent (to
@@ -33,5 +45,6 @@ reads it with `ENV.fetch`: unset, the app doesn't boot.
33
45
  ```
34
46
 
35
47
  and `/me` answers `401` again.
36
- 3. Before going live: keep the rate limit, and list in `config/auth.rb`
48
+ 3. Before going live: keep the rate limit (the `allow_login_request?`
49
+ line in `app/routes/auth.rb`), and list in `config/auth.rb`
37
50
  (`redirect_allowlist`) the paths a login may send the user back to.
@@ -4,6 +4,12 @@ require_relative "test_helper"
4
4
  # sent by config/auth.rb's deliver: (AuthMailer, to log/test.log here), and
5
5
  # redeemed into a session. SETUP.md, auth.
6
6
  class AuthTest < Minitest::Test
7
+ # Lifts the login-email rate limit, so the next test can ask for a link
8
+ # for the same address at once.
9
+ def teardown
10
+ Monk::Auth.clear_login_requests!
11
+ end
12
+
7
13
  def test_a_login_link_is_sent_and_redeems_into_a_session
8
14
  email = "login-check-#{rand(1_000_000)}@example.test"
9
15
  token = Monk::Auth.request_login(email)
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem "monkrb", "~> 0.21", require: "monk"
3
+ gem "monkrb", "~> 0.22", require: "monk"
4
4
  gem "rackup"
5
5
  gem "webrick"
6
6
  gem "kino"
@@ -25,6 +25,11 @@ end
25
25
  Dir[File.expand_path("../app/#{role}/**/*.rb", __dir__)].sort.each { |file| require file }
26
26
  end
27
27
 
28
+ # With jobs (monk add jobs), the schedules of recurring jobs. Last, because
29
+ # each one names a job class from app/jobs/.
30
+ recurring = File.expand_path("recurring.rb", __dir__)
31
+ require recurring if File.exist?(recurring)
32
+
28
33
  # With websocket (monk add websocket), every process that loads this file
29
34
  # can broadcast (bin/server, bin/jobs, bin/console, the tests), so its
30
35
  # transport is started here, once everything above has declared its
@@ -1,18 +1,25 @@
1
1
  ## jobs
2
2
 
3
3
  **Where:** `config/jobs.rb` (the queue lives in the `:primary` database),
4
- job classes in `app/jobs/`, `bin/jobs` runs them.
4
+ job classes in `app/jobs/`, schedules in `config/recurring.rb`, `bin/jobs`
5
+ runs them.
5
6
 
6
7
  **Main calls:** `class SendInvoice < Monk::Job` with
7
8
  `def self.perform(id)`; `SendInvoice.enqueue(id)`, `wait: 300`, `at: time`,
8
9
  or `conn: conn` to enqueue inside your own transaction. Class settings:
9
10
  `queue "mailers"`, `max_attempts 5`, `never_retry SomeError`. With mail:
10
11
  `Monk::Mail.deliver_later(...)`; with auth:
11
- `Monk::Auth::SendLoginLink.enqueue(email)`.
12
+ `Monk::Auth::SendLoginLink.enqueue(email)`. Recurring, in
13
+ `config/recurring.rb` only:
14
+ `Monk::Jobs.recurring "key", JobClass, cron: "0 3 * * *", args: [...]`
15
+ (`skip_if_pending: true` to skip while the last run is pending). With
16
+ auth, `config/recurring.rb` already schedules `monk_auth_sweep`.
12
17
 
13
18
  **Test:** `test/jobs_test.rb`. In tests, `Monk::Jobs.drain!` runs every
14
19
  enqueued job right away, each in a Ractor; `Monk::Jobs.clear!` in
15
- `teardown` empties the queue.
20
+ `teardown` empties the queue. `Monk::Jobs.run_scheduler!(at: time)` runs
21
+ the schedules as if the clock read `time` (call it once to set the
22
+ checkpoint, then at the next occurrence).
16
23
 
17
24
  **Pitfalls:**
18
25
  - A job's arguments are stored as JSON: pass ids and plain values, never
@@ -20,6 +27,10 @@ enqueued job right away, each in a Ractor; `Monk::Jobs.clear!` in
20
27
  - A job may run more than once (its process was killed mid-job): make it
21
28
  safe to repeat.
22
29
  - Jobs run in Ractors: the same shareability rules as routes.
30
+ - Cron is UTC. A new recurring task first fires at its next occurrence,
31
+ not on deploy, and a missed run fires once, not once per occurrence
32
+ missed. Keep a task's key when renaming its class.
23
33
 
24
- **Examples:** `jobs-enqueue` in `app/routes/jobs.rb`; `app/jobs/hello_job.rb`
34
+ **Examples:** `jobs-enqueue` in `app/routes/jobs.rb`, `jobs-recurring` in
35
+ `config/recurring.rb`; `app/jobs/hello_job.rb`
25
36
  is a real, deletable demo job.
@@ -0,0 +1,21 @@
1
+ # Recurring jobs: bin/jobs enqueues each one on its schedule, once per
2
+ # occurrence however many job processes are running. Loaded after app/,
3
+ # since each names a job class.
4
+ #
5
+ # Monk::Jobs.recurring "<key>", JobClass, cron: "<expression>", args: [...]
6
+ #
7
+ # The key is the task's identity in the database: keep it when renaming
8
+ # the class. cron: is a five-field cron expression (minute hour
9
+ # day-of-month month day-of-week, or @hourly, @daily, ...), always in UTC.
10
+ # A new task first runs at its next occurrence, not on deploy; after
11
+ # downtime only the latest missed occurrence runs.
12
+ # skip_if_pending: true skips an occurrence while the previous run hasn't
13
+ # finished. Monk's docs/guides/jobs.md, "Recurring jobs".
14
+
15
+ # With auth (monk add auth): deletes expired sessions and login tokens, and
16
+ # old login requests, every night at 03:17 UTC.
17
+ Monk::Jobs.recurring "monk_auth_sweep", Monk::Auth::Sweep, cron: "17 3 * * *" if defined?(Monk::Auth::Sweep)
18
+
19
+ # monk:example jobs-recurring -- the demo job (app/jobs/hello_job.rb) at the top of every hour.
20
+ # Monk::Jobs.recurring "hello_hourly", HelloJob, cron: "@hourly", args: ["cron"]
21
+ # monk:end
@@ -0,0 +1 @@
1
+ DROP TABLE monk_recurring_tasks;
@@ -0,0 +1,13 @@
1
+ -- Monk::Jobs.recurring: one checkpoint row per declared task, the latest
2
+ -- occurrence it has been handled for. Why this shape:
3
+ -- docs/adr/0016-recurring-jobs-on-the-job-process-tick-with-a-checkpoint-row.md.
4
+ CREATE TABLE monk_recurring_tasks (
5
+ key TEXT PRIMARY KEY, -- the key given to Monk::Jobs.recurring
6
+ cron TEXT NOT NULL, -- the expression the checkpoint was set under
7
+ last_occurrence_at TIMESTAMPTZ NOT NULL, -- the checkpoint
8
+ -- The job enqueued for the checkpoint, for skip_if_pending. No foreign
9
+ -- key: ON DELETE SET NULL would add a write to every job's finish, and
10
+ -- a dangling id just means that job is done.
11
+ last_job_id BIGINT,
12
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
13
+ );
@@ -1,7 +1,7 @@
1
1
  ## jobs
2
2
 
3
- Background jobs on the app's own Postgres. A migration creates the queue's
4
- tables: run `bin/setup_db` and `DB_NAME={{app}}_test bin/setup_db`
3
+ Background jobs on the app's own Postgres. Two migrations create the
4
+ queue's tables and the recurring jobs' one: run `bin/setup_db` and `DB_NAME={{app}}_test bin/setup_db`
5
5
  (postgres, above).
6
6
 
7
7
  ### Run them
@@ -18,6 +18,19 @@ first. `TERM` or Ctrl-C lets the jobs in hand finish before it exits. In
18
18
  production it's one more process from the same image, with `bin/jobs` as
19
19
  its command.
20
20
 
21
+ ### Recurring jobs
22
+
23
+ Declare schedules in `config/recurring.rb`:
24
+
25
+ ```ruby
26
+ Monk::Jobs.recurring "purge_sessions", PurgeSessions, cron: "0 3 * * *"
27
+ ```
28
+
29
+ Cron times are UTC. A running `bin/jobs` enqueues each occurrence once,
30
+ however many are running; with none running, nothing fires, and on
31
+ restart only the latest missed occurrence runs. A new task first runs at
32
+ its next occurrence, not on deploy.
33
+
21
34
  ### Check it works
22
35
 
23
36
  1. `bundle exec rake test` runs `test/jobs_test.rb`, which enqueues the
@@ -28,3 +41,8 @@ its command.
28
41
  3. The example: uncomment the `jobs-enqueue` block in `app/routes/jobs.rb`,
29
42
  restart `bin/server`, and
30
43
  `curl -X POST "http://localhost:9292/jobs/hello?name=Bob"`.
44
+ 4. A recurring job, in a test: `Monk::Jobs.run_scheduler!(at: time)` runs
45
+ the schedule as if the clock read `time`. The first call only records
46
+ where the task is; a second call at the next occurrence enqueues it,
47
+ and `Monk::Jobs.drain!` runs it. The `jobs-recurring` example in
48
+ `config/recurring.rb` schedules the demo job every hour.
data/lib/monk/version.rb CHANGED
@@ -5,5 +5,5 @@ module Monk
5
5
  # class of bug .freeze! guards against for routes/error handlers/models,
6
6
  # just on a top-level constant that every worker Ractor reads on boot
7
7
  # (found live under kino: GET /hello 500'd until this was frozen).
8
- VERSION = "0.21.1".freeze
8
+ VERSION = "0.22.0".freeze
9
9
  end