monkrb 0.21.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +4 -4
- data/lib/monk/auth/errors.rb +5 -0
- data/lib/monk/auth/sweep.rb +23 -0
- data/lib/monk/auth.rb +101 -6
- data/lib/monk/generators/auth.rb +1 -0
- data/lib/monk/generators/jobs.rb +3 -1
- data/lib/monk/jobs/adapters/pg.rb +90 -2
- data/lib/monk/jobs/cron.rb +180 -0
- data/lib/monk/jobs/errors.rb +21 -0
- data/lib/monk/jobs/firing.rb +11 -0
- data/lib/monk/jobs/recurring_task.rb +9 -0
- data/lib/monk/jobs/runtime.rb +38 -2
- data/lib/monk/jobs/scheduler.rb +91 -0
- data/lib/monk/jobs.rb +87 -8
- data/lib/monk/templates/auth/agents.md +10 -3
- data/lib/monk/templates/auth/app/routes/auth.rb +4 -8
- data/lib/monk/templates/auth/config/auth.rb +1 -0
- data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.down.sql +2 -0
- data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.up.sql +16 -0
- data/lib/monk/templates/auth/setup.md +16 -3
- data/lib/monk/templates/auth/test/auth_test.rb +6 -0
- data/lib/monk/templates/base/Dockerfile +8 -4
- data/lib/monk/templates/base/Gemfile +1 -1
- data/lib/monk/templates/base/config/load.rb +11 -0
- data/lib/monk/templates/jobs/agents.md +15 -4
- data/lib/monk/templates/jobs/config/recurring.rb +21 -0
- data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.down.sql +1 -0
- data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.up.sql +13 -0
- data/lib/monk/templates/jobs/setup.md +20 -2
- data/lib/monk/templates/websocket/agents.md +3 -0
- data/lib/monk/templates/websocket/bin/websocket_server +4 -0
- data/lib/monk/templates/websocket/config/websocket_postgres.rb +21 -7
- data/lib/monk/templates/websocket/config/websocket_redis.rb +5 -0
- data/lib/monk/templates/websocket/setup.md +8 -5
- data/lib/monk/version.rb +1 -1
- metadata +11 -1
data/lib/monk/jobs/errors.rb
CHANGED
|
@@ -6,6 +6,17 @@ module Monk
|
|
|
6
6
|
class NotFrozenError < StandardError
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
+
# Declaring a recurring task after Monk.freeze! has sealed the
|
|
10
|
+
# registry: it would never be seen by the scheduler.
|
|
11
|
+
class RegistryFrozenError < StandardError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# bin/jobs starting with recurring tasks declared but no
|
|
15
|
+
# monk_recurring_tasks table: an app that has jobs from before 0.22
|
|
16
|
+
# and hasn't added the migration yet.
|
|
17
|
+
class MissingRecurringTableError < StandardError
|
|
18
|
+
end
|
|
19
|
+
|
|
9
20
|
# A job_class name that isn't a Monk::Job subclass this process knows
|
|
10
21
|
# about: a job enqueued by a newer deploy, a renamed class, or a name
|
|
11
22
|
# that was never a job at all.
|
|
@@ -21,9 +32,19 @@ module Monk
|
|
|
21
32
|
class ClearOutsideTestsError < StandardError
|
|
22
33
|
end
|
|
23
34
|
|
|
35
|
+
# Monk::Jobs.run_scheduler! outside MONK_ENV=test: it would fire a real
|
|
36
|
+
# app's schedules at a made-up time.
|
|
37
|
+
class RunSchedulerOutsideTestsError < StandardError
|
|
38
|
+
end
|
|
39
|
+
|
|
24
40
|
# An ArgumentError, like Monk::Mail::InvalidMessageError: it's always
|
|
25
41
|
# the caller's input that's wrong.
|
|
26
42
|
class InvalidArgumentsError < ArgumentError
|
|
27
43
|
end
|
|
44
|
+
|
|
45
|
+
# A recurring task's cron expression that doesn't parse, or never
|
|
46
|
+
# matches a minute. Raised when the task is declared, at boot.
|
|
47
|
+
class InvalidCronError < ArgumentError
|
|
48
|
+
end
|
|
28
49
|
end
|
|
29
50
|
end
|
|
@@ -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
|
data/lib/monk/jobs/runtime.rb
CHANGED
|
@@ -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(", ")}
|
|
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
|
|
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
|
|
155
|
-
# and so it refuses to run
|
|
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
|
|
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, "
|
|
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
|
|
20
|
-
|
|
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
|
|
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
|
-
#
|
|
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,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
|
|
5
|
-
|
|
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
|
|
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,15 +1,19 @@
|
|
|
1
1
|
# Two-stage build: the builder resolves and installs gems, the final image
|
|
2
2
|
# only keeps the installed bundle plus the app -- no compiler, no gem
|
|
3
|
-
# cache, in the image that actually runs.
|
|
4
|
-
#
|
|
3
|
+
# cache, in the image that actually runs. The builder has a compiler for
|
|
4
|
+
# the gems that build a native extension (irb's dependencies, with monk
|
|
5
|
+
# add postgres); kino ships as precompiled platform gems, and pg 1.6+
|
|
6
|
+
# ships precompiled with its own libpq. A plain `bundle install`
|
|
5
7
|
# (Gemfile.lock committed) already resolves every compatible platform, not
|
|
6
8
|
# just the machine that ran it -- nothing to add by hand before building on
|
|
7
9
|
# a different host (docs/guides/deploying.md, "Before the first build").
|
|
8
|
-
# monk/rackup/webrick are pure Ruby, and pg (monk add postgres) 1.6+
|
|
9
|
-
# ships precompiled with its own libpq.
|
|
10
10
|
FROM ruby:4.0-slim AS builder
|
|
11
11
|
WORKDIR /app
|
|
12
12
|
|
|
13
|
+
RUN apt-get update \
|
|
14
|
+
&& apt-get install -y --no-install-recommends build-essential \
|
|
15
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
16
|
+
|
|
13
17
|
COPY Gemfile Gemfile.lock* ./
|
|
14
18
|
RUN bundle install
|
|
15
19
|
|
|
@@ -24,3 +24,14 @@ end
|
|
|
24
24
|
%w[models presenters helpers mailers broadcasts jobs].each do |role|
|
|
25
25
|
Dir[File.expand_path("../app/#{role}/**/*.rb", __dir__)].sort.each { |file| require file }
|
|
26
26
|
end
|
|
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
|
+
|
|
33
|
+
# With websocket (monk add websocket), every process that loads this file
|
|
34
|
+
# can broadcast (bin/server, bin/jobs, bin/console, the tests), so its
|
|
35
|
+
# transport is started here, once everything above has declared its
|
|
36
|
+
# databases.
|
|
37
|
+
AppWebSocket.start! if defined?(AppWebSocket)
|
|
@@ -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`
|
|
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
|
|
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
|
+
);
|