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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +4 -4
  4. data/lib/monk/auth/errors.rb +5 -0
  5. data/lib/monk/auth/sweep.rb +23 -0
  6. data/lib/monk/auth.rb +101 -6
  7. data/lib/monk/generators/auth.rb +1 -0
  8. data/lib/monk/generators/jobs.rb +3 -1
  9. data/lib/monk/jobs/adapters/pg.rb +90 -2
  10. data/lib/monk/jobs/cron.rb +180 -0
  11. data/lib/monk/jobs/errors.rb +21 -0
  12. data/lib/monk/jobs/firing.rb +11 -0
  13. data/lib/monk/jobs/recurring_task.rb +9 -0
  14. data/lib/monk/jobs/runtime.rb +38 -2
  15. data/lib/monk/jobs/scheduler.rb +91 -0
  16. data/lib/monk/jobs.rb +87 -8
  17. data/lib/monk/templates/auth/agents.md +10 -3
  18. data/lib/monk/templates/auth/app/routes/auth.rb +4 -8
  19. data/lib/monk/templates/auth/config/auth.rb +1 -0
  20. data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.down.sql +2 -0
  21. data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.up.sql +16 -0
  22. data/lib/monk/templates/auth/setup.md +16 -3
  23. data/lib/monk/templates/auth/test/auth_test.rb +6 -0
  24. data/lib/monk/templates/base/Dockerfile +8 -4
  25. data/lib/monk/templates/base/Gemfile +1 -1
  26. data/lib/monk/templates/base/config/load.rb +11 -0
  27. data/lib/monk/templates/jobs/agents.md +15 -4
  28. data/lib/monk/templates/jobs/config/recurring.rb +21 -0
  29. data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.down.sql +1 -0
  30. data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.up.sql +13 -0
  31. data/lib/monk/templates/jobs/setup.md +20 -2
  32. data/lib/monk/templates/websocket/agents.md +3 -0
  33. data/lib/monk/templates/websocket/bin/websocket_server +4 -0
  34. data/lib/monk/templates/websocket/config/websocket_postgres.rb +21 -7
  35. data/lib/monk/templates/websocket/config/websocket_redis.rb +5 -0
  36. data/lib/monk/templates/websocket/setup.md +8 -5
  37. data/lib/monk/version.rb +1 -1
  38. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 715184d6d6d9b3d6e50ab43d88d3c8ab7c56db310d0d30d90d67150b9dc28e0d
4
- data.tar.gz: 16265638cdde3786734a98c8bd0a743b26e4dca7ea3f253b74c32407ec0ae7f1
3
+ metadata.gz: fa788432dda8104f066b4230e406e690df36c72528f57de126001519576fa0e6
4
+ data.tar.gz: 36352b5fa73ff475ca37141e8d5aeca9f0cd487845f1521f62bc3048650a57a2
5
5
  SHA512:
6
- metadata.gz: '097a3550e18dfe987ade8ec24705073b1d0d129fa7303d557acf70ba896186c423d18bb780cbe1e2d6704d7ec8a3706ae163761b7e56225bf7fd651fe0f9163b'
7
- data.tar.gz: c1d3e26f194892e893fbdc92a30699fee4b2fbd0424a74bb063b5ca86eda29f76f4909f07e5c44d7211f78d242a1f01f6f36f3d356b37c203d861ffbba97bf83
6
+ metadata.gz: 03ecd1b575dcb40a8a1a35c4b5b0a2b1c8f867369ffda064d6138fbde7f8db8c586d9d4e302d2ee471a71a29b9c50133ee8a91eaf4c49dec88ca64e0158aecab
7
+ data.tar.gz: c14485432530c8e4f14ee1ff630208a191ebffb4ec6c1c1fd6326562aba05555a4173e865fe73b913027f896a530166ab0d115468d28dc15efdade92fc49d0c5
data/CHANGELOG.md CHANGED
@@ -4,6 +4,60 @@ All notable changes to this project are documented here. Format is loosely
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are as released
5
5
  in `lib/monk/version.rb`.
6
6
 
7
+ ## 0.22.0 - 2026-10-13
8
+
9
+ ### Added
10
+
11
+ - **Recurring jobs.** `Monk::Jobs.recurring "purge_sessions",
12
+ PurgeSessions, cron: "0 3 * * *"`, declared in `config/recurring.rb`,
13
+ is enqueued by `bin/jobs` at each occurrence, once however many job
14
+ processes run. Five-field cron in UTC, plus `@hourly` to `@yearly`. A
15
+ new task first runs at its next occurrence, missed occurrences run
16
+ once, and `skip_if_pending: true` skips a run while the previous one
17
+ hasn't finished. `Monk::Jobs.run_scheduler!(at:)` runs the schedule in
18
+ tests. One checkpoint row per task in the new `monk_recurring_tasks`
19
+ table (ADR 0016, `docs/guides/jobs.md`, "Recurring jobs").
20
+ `monk add jobs` writes `config/recurring.rb` with a commented example
21
+ and the table's migration, and the base `config/load.rb` loads
22
+ `config/recurring.rb` after `app/`. An app that already has jobs:
23
+ `docs/guides/upgrading.md`, 0.22; until then its tests that call
24
+ `Monk::Jobs.clear!` fail on the missing table.
25
+ - **A login-email rate limit in `Monk::Auth`.**
26
+ `Monk::Auth.allow_login_request?(email)` allows one login email per
27
+ address per `login_request_interval:` (60 seconds), counted in a new
28
+ `login_requests` table, so across every process. The scaffolded login
29
+ routes use it instead of the per-process `RateLimiter`.
30
+ `Monk::Auth.clear_login_requests!` resets it in tests.
31
+ - **Auth cleans up after itself.** `Monk::Auth.sweep!` deletes in batches
32
+ and covers `login_requests`; the new `Monk::Auth::Sweep` job runs it,
33
+ and `monk add jobs`'s `config/recurring.rb` schedules it nightly
34
+ (`monk_auth_sweep`) when auth is installed.
35
+ - **An index on `sessions (subject)`**, in the same new auth migration,
36
+ `create_login_requests`. An app that already has auth:
37
+ `docs/guides/upgrading.md`, 0.22, "Auth housekeeping".
38
+
39
+ ## 0.21.1 - 2026-10-09
40
+
41
+ ### Fixed
42
+
43
+ - **`monk add websocket --transport=postgres` publishes through the
44
+ `:notify` pool.** `config/websocket.rb` declares
45
+ `Monk::Persistence::Pg.pool(:notify, size: 1)` and builds
46
+ `PgFanout.new(..., db_pool: :notify)`, so a process holds one publishing
47
+ connection however many Ractors publish. Before, every `bin/server`
48
+ worker opened its own. `AppWebSocket.start!` starts the pool (with
49
+ Redis it does nothing): `config/load.rb` calls it at the end, which
50
+ covers `bin/server`, `bin/jobs`, `bin/console` and the tests, and
51
+ `bin/websocket_server` calls it too. An existing app: replace
52
+ `config/websocket.rb` with
53
+ `monk help websocket --file config/websocket.rb --transport=postgres`
54
+ and add the last lines of `monk help base --file config/load.rb`.
55
+ - **The scaffolded `Dockerfile` builds with `monk add postgres`.** Its
56
+ builder stage installs `build-essential`: `irb`'s dependencies (`erb`,
57
+ `io-console`, `prism`) compile native extensions. The image that runs
58
+ still has no compiler. An existing app: add the `apt-get` line from
59
+ `monk help base --file Dockerfile`.
60
+
7
61
  ## 0.21.0 - 2026-10-09
8
62
 
9
63
  ### Changed (breaking)
data/README.md CHANGED
@@ -21,7 +21,7 @@ bin/server # -> http://localhost:9292/hello
21
21
  monk add --list # the modules you can add: postgres, auth, jobs, live...
22
22
  ```
23
23
 
24
- `monk new` scaffolds the app's own `Gemfile` with `gem "monkrb", "~> 0.21", require: "monk"`.
24
+ `monk new` scaffolds the app's own `Gemfile` with `gem "monkrb", "~> 0.22", require: "monk"`.
25
25
 
26
26
  `monk new` writes a working skeleton (an HTML home page, a `/hello` route, a `/api/hello` JSON route, `public/`, its tests, a `SETUP.md` and an `AGENTS.md` for coding agents), with the app's own code under `app/`: `app/app.rb` and `app/routes/` for routes, `app/views/`, and one directory per role (`models/`, `presenters/`, `helpers/`, `mailers/`, `broadcasts/`, `jobs/`). `monk add` adds Postgres, Redis, email, auth, background jobs, WebSocket and live updates, to a new app or an old one, each with its wiring, commented examples of how to use it, and a test (`monk new my_app --with auth,jobs` does both at once): see [`docs/guides/scaffolding.md`](docs/guides/scaffolding.md), which also says where each kind of code goes.
27
27
 
@@ -60,9 +60,9 @@ Everything beyond the core is opt-in (`require "monk"` alone loads none of it).
60
60
  | Logging | request log per environment, `Monk::Log.debug`/`info`/`warn`/`error` | — | [`logging.md`](docs/guides/logging.md) |
61
61
  | Persistence | `Monk::Persistence::Pg`: raw `pg`, per-Ractor connections, hash-based `Model` | `require "monk/persistence/pg"` (+ `.../pg/model`); needs the `pg` gem | [`persistence.md`](docs/guides/persistence.md) |
62
62
  | Migrations | plain `.sql` up/down pairs, `Migrator` | `require "monk/persistence/pg/migrator"`; needs the `pg` gem | [`migrations.md`](docs/guides/migrations.md) |
63
- | Auth and sessions | `Monk::Auth`: passwordless tokens, Bearer or cookie + CSRF | `require "monk/auth"`; needs the `pg` gem and a registered Postgres connection | [`auth.md`](docs/guides/auth.md) |
63
+ | Auth and sessions | `Monk::Auth`: passwordless tokens, Bearer or cookie + CSRF, a per-address login-email rate limit, nightly cleanup with jobs | `require "monk/auth"`; needs the `pg` gem and a registered Postgres connection | [`auth.md`](docs/guides/auth.md) |
64
64
  | Email | `Monk::Mail`: text/HTML email over SMTP or a local relay, one `MAIL_URL`, sent from any worker Ractor, or from a background job with `deliver_later` | `require "monk/mail"`; `deliver_later` is there once `monk/jobs` is loaded too; SMTP needs the `net-smtp` gem | [`mail.md`](docs/guides/mail.md) |
65
- | Background jobs | `Monk::Jobs`: a queue in Postgres, run by `bin/jobs` on worker Ractors; retries with backoff, scheduled jobs, enqueue inside the app's own transaction, `drain!` for tests | `require "monk/jobs"` (+ `require "monk/jobs/runtime"` in the job process); needs the `pg` gem and a registered Postgres connection | [`jobs.md`](docs/guides/jobs.md) |
65
+ | Background jobs | `Monk::Jobs`: a queue in Postgres, run by `bin/jobs` on worker Ractors; retries with backoff, scheduled jobs, recurring jobs on a cron schedule (UTC), enqueue inside the app's own transaction, `drain!` for tests | `require "monk/jobs"` (+ `require "monk/jobs/runtime"` in the job process); needs the `pg` gem and a registered Postgres connection | [`jobs.md`](docs/guides/jobs.md) |
66
66
  | WebSocket | `Monk::WebSocket`: RFC 6455 server as its own process, Redis or Postgres fan-out | `require "monk/websocket"`; fan-out: `require "monk/websocket/redis_fanout"` (+ the `redis` gem) or `require "monk/websocket/pg_fanout"` (+ the `pg` gem) | [`websocket.md`](docs/guides/websocket.md) |
67
67
  | Live updates | `Monk::Live`: server-rendered HTML patches pushed to open tabs | `require "monk/live"`; needs the WebSocket server and, across processes, Redis or Postgres | [`live.md`](docs/guides/live.md) |
68
68
  | Scaffolding | `monk new` and `monk add`: one generator per module, for a new app or an existing one; text or `--json` output | — (the `monk` command: `monk new NAME --with ...`, `monk add MODULE`, `monk add --list`) | [`scaffolding.md`](docs/guides/scaffolding.md) |
@@ -88,6 +88,6 @@ More (server modes, Docker) in [`docs/development.md`](docs/development.md).
88
88
 
89
89
  ## Status
90
90
 
91
- Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). Features to add are listed in [`docs/roadmap.md`](docs/roadmap.md). Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), the RubyGems release as `monkrb` (both 2026-09-22), and `Monk::WebSocket::PgFanout`/`monk new --live --postgres` — the same cross-process fan-out over Postgres `LISTEN`/`NOTIFY` instead of Redis, for an app that doesn't want a second piece of infrastructure (`docs/design/live-pg-fanout.md`, 2026-09-24), the built-in mailer `Monk::Mail` (0.17, 2026-09-26, ADR 0012), and background jobs on Postgres, `Monk::Jobs`, with mail sent from jobs (0.18, 2026-09-29, ADRs 0013 and 0014). In 0.19 (2026-10-01): `monk new` lays the app out under `app/`, one directory per role (ADR 0015); a WebSocket fanout listens only in the process that holds the sockets (`#listen!`); `authenticate: :optional` lets visitors' sockets in anonymously, for Live's rules to decide; and the WebSocket server stops cleanly on `TERM` (`docs/design/websocket.md`). In 0.20 (2026-10-05): Monk recovers from a dropped database connection (a Postgres restart, a failover) without a process restart. `checkout` reconnects a dead connection before running its block, never retrying the block itself; both fanouts' listeners reconnect and then make every open page resync; and a route that raises logs why (`docs/history/plan-pg-reconnect.md`). Also new: connection pools for Ractors that shouldn't each hold a connection (`Monk::Persistence::Pg.pool`). The WebSocket server's session checks, Live's subscribe rules and `PgFanout`'s publisher can run through one, so open pages share a few connections instead of holding one each: 1,000 open sockets on 4 connections (`docs/guides/persistence.md`, "Pools"; `docs/history/plan-pg-pool.md`). In 0.21 (2026-10-09): scaffolding by module. `monk new` makes the core app, and `monk add` adds a module (Postgres, Redis, mail, auth, jobs, WebSocket, Live) to any app, new or existing, with its wiring, commented examples, a test, and its sections of `SETUP.md` and an `AGENTS.md` for coding agents; `--json` for agents and scripts. Redis and WebSocket become modules of their own, the WebSocket transport is chosen once per app, and `monk new`'s module flags are gone (ADR 0017, `docs/guides/scaffolding.md`, `docs/history/plan-scaffold.md`).
91
+ Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). Features to add are listed in [`docs/roadmap.md`](docs/roadmap.md). Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), the RubyGems release as `monkrb` (both 2026-09-22), and `Monk::WebSocket::PgFanout`/`monk new --live --postgres` — the same cross-process fan-out over Postgres `LISTEN`/`NOTIFY` instead of Redis, for an app that doesn't want a second piece of infrastructure (`docs/design/live-pg-fanout.md`, 2026-09-24), the built-in mailer `Monk::Mail` (0.17, 2026-09-26, ADR 0012), and background jobs on Postgres, `Monk::Jobs`, with mail sent from jobs (0.18, 2026-09-29, ADRs 0013 and 0014). In 0.19 (2026-10-01): `monk new` lays the app out under `app/`, one directory per role (ADR 0015); a WebSocket fanout listens only in the process that holds the sockets (`#listen!`); `authenticate: :optional` lets visitors' sockets in anonymously, for Live's rules to decide; and the WebSocket server stops cleanly on `TERM` (`docs/design/websocket.md`). In 0.20 (2026-10-05): Monk recovers from a dropped database connection (a Postgres restart, a failover) without a process restart. `checkout` reconnects a dead connection before running its block, never retrying the block itself; both fanouts' listeners reconnect and then make every open page resync; and a route that raises logs why (`docs/history/plan-pg-reconnect.md`). Also new: connection pools for Ractors that shouldn't each hold a connection (`Monk::Persistence::Pg.pool`). The WebSocket server's session checks, Live's subscribe rules and `PgFanout`'s publisher can run through one, so open pages share a few connections instead of holding one each: 1,000 open sockets on 4 connections (`docs/guides/persistence.md`, "Pools"; `docs/history/plan-pg-pool.md`). In 0.21 (2026-10-09): scaffolding by module. `monk new` makes the core app, and `monk add` adds a module (Postgres, Redis, mail, auth, jobs, WebSocket, Live) to any app, new or existing, with its wiring, commented examples, a test, and its sections of `SETUP.md` and an `AGENTS.md` for coding agents; `--json` for agents and scripts. Redis and WebSocket become modules of their own, the WebSocket transport is chosen once per app, and `monk new`'s module flags are gone (ADR 0017, `docs/guides/scaffolding.md`, `docs/history/plan-scaffold.md`). In 0.22 (2026-10-13): recurring jobs on a cron schedule, `Monk::Jobs.recurring` in `config/recurring.rb`, enqueued once per occurrence however many `bin/jobs` run (ADR 0016, `docs/guides/jobs.md`); auth housekeeping, with a login-email rate limit in the database, an index on `sessions (subject)` and a nightly cleanup job (`docs/guides/auth.md`); and an upgrade guide for apps made with an earlier Monk (`docs/guides/upgrading.md`).
92
92
 
93
93
  [Made with Love ❤️, Ruby 💎 and AI 🤖](docs/ai_usage_disclaimer.md)
@@ -13,4 +13,9 @@ module Monk
13
13
 
14
14
  class MissingAuthDeliveryError < StandardError
15
15
  end
16
+
17
+ # Monk::Auth.clear_login_requests! outside MONK_ENV=test: it would lift
18
+ # the login-email rate limit for every address.
19
+ class ClearLoginRequestsOutsideTestsError < StandardError
20
+ end
16
21
  end
@@ -0,0 +1,23 @@
1
+ # Monk::Auth::Sweep. Loaded at the end of whichever of monk/auth and
2
+ # monk/jobs is required second, once both are defined (docs/adr/0017), so
3
+ # it requires neither itself.
4
+
5
+ module Monk
6
+ module Auth
7
+ # Monk::Auth.sweep! as a job: deletes expired login tokens and sessions
8
+ # and old login requests. config/recurring.rb (monk add jobs) runs it
9
+ # nightly when auth is installed (docs/history/plan-auth-housekeeping.md
10
+ # decision 1):
11
+ #
12
+ # Monk::Jobs.recurring "monk_auth_sweep", Monk::Auth::Sweep, cron: "17 3 * * *" if defined?(Monk::Auth::Sweep)
13
+ class Sweep < Monk::Job
14
+ def self.perform
15
+ counts = Monk::Auth.sweep!
16
+ Monk::Log.info(
17
+ "Monk::Auth::Sweep: deleted #{counts[:sessions]} sessions, #{counts[:login_tokens]} login tokens, " \
18
+ "#{counts[:login_requests]} login requests",
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
data/lib/monk/auth.rb CHANGED
@@ -16,6 +16,34 @@ module Monk
16
16
  module Auth
17
17
  REQUIRED_CONFIG_KEYS = %i[db_name secret login_ttl session_ttl].freeze
18
18
 
19
+ # The login-email rate limit (docs/history/plan-auth-housekeeping.md decision
20
+ # 5): records a request unless one was recorded for the address less
21
+ # than $2 seconds ago. One statement, so racing requests for one
22
+ # address let exactly one through; a refused request changes nothing.
23
+ LOGIN_REQUEST_SQL = <<~SQL.freeze
24
+ INSERT INTO login_requests (email) VALUES ($1)
25
+ ON CONFLICT (email) DO UPDATE SET requested_at = now()
26
+ WHERE login_requests.requested_at < now() - $2::int * interval '1 second'
27
+ RETURNING 1
28
+ SQL
29
+
30
+ SWEEP_BATCH_SIZE = 10_000
31
+
32
+ # sweep!'s statements, one batch each. Tokens and sessions expire by
33
+ # the app's clock, as redeem and verify check them; login requests by
34
+ # the database's, as allow_login_request? does.
35
+ SWEEP_LOGIN_TOKENS_SQL = <<~SQL.freeze
36
+ DELETE FROM login_tokens WHERE id IN (SELECT id FROM login_tokens WHERE expires_at < $1 LIMIT $2)
37
+ SQL
38
+ SWEEP_SESSIONS_SQL = <<~SQL.freeze
39
+ DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE expires_at < $1 LIMIT $2)
40
+ SQL
41
+ SWEEP_LOGIN_REQUESTS_SQL = <<~SQL.freeze
42
+ DELETE FROM login_requests WHERE email IN (
43
+ SELECT email FROM login_requests WHERE requested_at < now() - $1::int * interval '1 second' LIMIT $2
44
+ )
45
+ SQL
46
+
19
47
  class << self
20
48
  # Called from Base#freeze! (Seam B), via Monk.freeze_hooks. Freezes
21
49
  # the value, not the module -- Monk::Auth is always Ractor.shareable?
@@ -37,15 +65,17 @@ module Monk
37
65
  # callback_path: is where the app's own callback route redeems a
38
66
  # token (GET "#{callback_path}/:token"). Monk doesn't define that
39
67
  # route, but builds links to it: login_link below, and so
40
- # Monk::Auth::SendLoginLink.
68
+ # Monk::Auth::SendLoginLink. login_request_interval: is the seconds
69
+ # allow_login_request? makes an address wait between login emails.
41
70
  def configure(
42
71
  db_name: nil, secret: nil, login_ttl: nil, session_ttl: nil, redirect_allowlist: [], secure: true,
43
- deliver: nil, callback_path: "/auth/callback"
72
+ deliver: nil, callback_path: "/auth/callback", login_request_interval: 60
44
73
  )
45
74
  config = {
46
75
  db_name: db_name, secret: secret, login_ttl: login_ttl, session_ttl: session_ttl,
47
76
  redirect_allowlist: redirect_allowlist, secure: secure, deliver: deliver,
48
77
  callback_path: checked_callback_path(callback_path),
78
+ login_request_interval: checked_login_request_interval(login_request_interval),
49
79
  }
50
80
 
51
81
  REQUIRED_CONFIG_KEYS.each do |key|
@@ -86,6 +116,37 @@ module Monk
86
116
  raw
87
117
  end
88
118
 
119
+ # The login-email rate limit, for the login route to check before it
120
+ # sends or enqueues the link: true, and recorded, if no link was
121
+ # requested for this address in the last login_request_interval
122
+ # seconds; false otherwise. Per address, across every process. The
123
+ # address is stripped and downcased for the limit only.
124
+ #
125
+ # halt(429) unless Monk::Auth.allow_login_request?(email)
126
+ def allow_login_request?(email)
127
+ ensure_configured!
128
+ unless email.is_a?(String) && !email.strip.empty?
129
+ raise ArgumentError, "Monk::Auth.allow_login_request? needs an email address, got #{email.inspect}"
130
+ end
131
+
132
+ params = [email.strip.downcase, config[:login_request_interval]]
133
+ Monk::Persistence::Pg.checkout(config[:db_name]) { |c| c.exec_params(LOGIN_REQUEST_SQL, params).ntuples == 1 }
134
+ end
135
+
136
+ # Test-only: forgets every login request, so the next test can log in
137
+ # again at once. Refuses unless MONK_ENV is test.
138
+ def clear_login_requests!
139
+ unless Monk.env.test?
140
+ raise Monk::ClearLoginRequestsOutsideTestsError,
141
+ "Monk::Auth.clear_login_requests! lifts the login-email rate limit for every address, so it only " \
142
+ "runs with MONK_ENV=test (this is #{Monk.env})"
143
+ end
144
+
145
+ ensure_configured!
146
+ Monk::Persistence::Pg.checkout(config[:db_name]) { |c| c.exec("DELETE FROM login_requests") }
147
+ nil
148
+ end
149
+
89
150
  # The magic link for a raw token: the app's public_url
90
151
  # (config/settings.rb, a trusted origin -- never request headers like
91
152
  # X-Forwarded-Host, which any direct client can spoof), then
@@ -214,15 +275,28 @@ module Monk
214
275
  rows.size
215
276
  end
216
277
 
278
+ # Deletes expired login tokens and sessions, and login requests older
279
+ # than login_request_interval; returns how many of each. At most
280
+ # batch_size rows per statement, so a large backlog (the first sweep
281
+ # of an app that never swept) holds no lock for long. Run nightly by
282
+ # Monk::Auth::Sweep (docs/history/plan-auth-housekeeping.md decision 3).
217
283
  # Deliberately not grown onto Model: a `<` comparison is real
218
284
  # query-DSL scope for a hygiene task (docs/design/auth-sessions.md).
219
- def sweep!
285
+ def sweep!(batch_size: SWEEP_BATCH_SIZE)
220
286
  ensure_configured!
287
+ unless batch_size.is_a?(Integer) && batch_size.positive?
288
+ raise ArgumentError, "batch_size: must be a positive Integer, got #{batch_size.inspect}"
289
+ end
290
+
221
291
  Monk::Persistence::Pg.checkout(config[:db_name]) do |conn|
222
292
  now = Time.now
223
- login_tokens_deleted = conn.exec_params("DELETE FROM login_tokens WHERE expires_at < $1", [now]).cmd_tuples
224
- sessions_deleted = conn.exec_params("DELETE FROM sessions WHERE expires_at < $1", [now]).cmd_tuples
225
- { login_tokens: login_tokens_deleted, sessions: sessions_deleted }
293
+ {
294
+ login_tokens: delete_in_batches(conn, SWEEP_LOGIN_TOKENS_SQL, [now], batch_size),
295
+ sessions: delete_in_batches(conn, SWEEP_SESSIONS_SQL, [now], batch_size),
296
+ login_requests: delete_in_batches(
297
+ conn, SWEEP_LOGIN_REQUESTS_SQL, [config[:login_request_interval]], batch_size,
298
+ ),
299
+ }
226
300
  end
227
301
  end
228
302
 
@@ -279,6 +353,26 @@ module Monk
279
353
  path.chomp("/").freeze
280
354
  end
281
355
 
356
+ # Runs a batched DELETE until one deletes less than a full batch; the
357
+ # batch size is its last parameter.
358
+ def delete_in_batches(conn, sql, params, batch_size)
359
+ total = 0
360
+ loop do
361
+ deleted = conn.exec_params(sql, params + [batch_size]).cmd_tuples
362
+ total += deleted
363
+ return total if deleted < batch_size
364
+ end
365
+ end
366
+
367
+ def checked_login_request_interval(seconds)
368
+ unless seconds.is_a?(Integer) && seconds.positive?
369
+ raise Monk::InvalidAuthConfigError,
370
+ "Monk::Auth.configure(login_request_interval:) must be a positive Integer of seconds, got #{seconds.inspect}"
371
+ end
372
+
373
+ seconds
374
+ end
375
+
282
376
  def hash_token(raw)
283
377
  Digest::SHA256.hexdigest(raw)
284
378
  end
@@ -328,3 +422,4 @@ end
328
422
  # Monk::Auth::SendLoginLink, when jobs are loaded too -- either order works
329
423
  # (lib/monk/jobs.rb ends the same way, docs/adr/0017).
330
424
  require_relative "auth/send_login_link" if Monk.const_defined?(:Jobs)
425
+ require_relative "auth/sweep" if Monk.const_defined?(:Jobs)
@@ -9,6 +9,7 @@ Monk::Generator.define(:auth) do
9
9
  copy "app/routes/auth.rb", role: :example
10
10
  copy "test/auth_test.rb", role: :test
11
11
  migration "create_auth_tables"
12
+ migration "create_login_requests"
12
13
 
13
14
  env :development, { "AUTH_SECRET" => "change-me-dev-secret" }
14
15
  env :test, { "AUTH_SECRET" => "change-me-test-secret" }
@@ -4,11 +4,12 @@ Monk::Generator.define(:jobs) do
4
4
  installed_if "config/jobs.rb"
5
5
  depends_on :postgres
6
6
 
7
- copy "config/jobs.rb", role: :wiring
7
+ copy "config/jobs.rb", "config/recurring.rb", role: :wiring
8
8
  copy "bin/jobs", role: :wiring, executable: true
9
9
  copy "app/jobs/hello_job.rb", "app/routes/jobs.rb", role: :example
10
10
  copy "test/jobs_test.rb", role: :test
11
11
  migration "create_jobs_tables"
12
+ migration "create_recurring_tables"
12
13
 
13
14
  queues = { "JOBS_WORKERS" => "2", "JOBS_QUEUES" => "mailers,default" }
14
15
  env :development, queues
@@ -18,4 +19,5 @@ Monk::Generator.define(:jobs) do
18
19
  agents_section "jobs/agents.md"
19
20
  next_step "bin/jobs", note: "beside bin/server"
20
21
  example "jobs-enqueue", todo: "uncomment, or enqueue your own jobs from any route"
22
+ example "jobs-recurring", todo: "uncomment to run the demo job every hour"
21
23
  end
@@ -2,11 +2,13 @@ require "json"
2
2
  require "time"
3
3
  require_relative "../../persistence/pg"
4
4
  require_relative "../claim"
5
+ require_relative "../firing"
5
6
 
6
7
  module Monk
7
8
  module Jobs
8
9
  module Adapters
9
- # The queue on Postgres: monk_jobs + monk_job_payloads + monk_processes
10
+ # The queue on Postgres: monk_jobs + monk_job_payloads + monk_processes,
11
+ # plus monk_recurring_tasks for the scheduler
10
12
  # (docs/adr/0013-jobs-narrow-state-table-plus-payloads.md). Holds only
11
13
  # the registered database's name and freezes itself, so the one
12
14
  # instance is shareable with every Ractor; each Ractor reaches the
@@ -151,9 +153,70 @@ module Monk
151
153
  WHERE id = $1 AND state = 'running' AND locked_by = $2
152
154
  SQL
153
155
 
156
+ # The scheduler's one statement per occurrence (docs/history/plan-scheduled-jobs.md
157
+ # decisions 5-8). ins, reinit and advance touch the task's row in
158
+ # mutually exclusive cases; advance is a compare-and-set on the
159
+ # checkpoint, so of several processes firing the same occurrence
160
+ # exactly one moves it and enqueues. The job's id is drawn from the
161
+ # sequence first, so the same UPDATE records it as last_job_id;
162
+ # that burns an id on calls that don't enqueue, harmless on a
163
+ # BIGINT. The pending check sits inside advance, so READ
164
+ # COMMITTED's recheck sees the row a racing UPDATE left. $10 stands
165
+ # in for now() under Monk::Jobs.run_scheduler!(at:).
166
+ FIRE_RECURRING_SQL = <<~SQL.freeze
167
+ WITH gate AS (
168
+ SELECT $3::timestamptz <= COALESCE($10::timestamptz, now()) AS due
169
+ ), prev AS (
170
+ SELECT last_occurrence_at FROM monk_recurring_tasks WHERE key = $1
171
+ ), seq AS (
172
+ SELECT nextval(pg_get_serial_sequence('monk_jobs', 'id')) AS id
173
+ ), ins AS (
174
+ INSERT INTO monk_recurring_tasks (key, cron, last_occurrence_at)
175
+ SELECT $1, $2, $3 FROM gate WHERE due
176
+ ON CONFLICT (key) DO NOTHING
177
+ RETURNING key
178
+ ), reinit AS (
179
+ UPDATE monk_recurring_tasks t
180
+ SET cron = $2, last_occurrence_at = $3, last_job_id = NULL, updated_at = now()
181
+ FROM gate
182
+ WHERE gate.due AND t.key = $1 AND t.cron <> $2
183
+ RETURNING t.key
184
+ ), advance AS (
185
+ UPDATE monk_recurring_tasks t
186
+ SET last_occurrence_at = $3, updated_at = now(),
187
+ last_job_id = CASE
188
+ WHEN $4::boolean AND EXISTS (
189
+ SELECT 1 FROM monk_jobs j WHERE j.id = t.last_job_id AND j.state <> 'failed'
190
+ ) THEN t.last_job_id
191
+ ELSE seq.id
192
+ END
193
+ FROM gate, seq
194
+ WHERE gate.due AND t.key = $1 AND t.cron = $2 AND t.last_occurrence_at < $3
195
+ RETURNING t.last_job_id IS DISTINCT FROM seq.id AS skipped, seq.id AS job_id
196
+ ), job AS (
197
+ INSERT INTO monk_jobs (id, queue, priority, max_attempts, run_at, state) OVERRIDING SYSTEM VALUE
198
+ SELECT job_id, $5::text, $6::smallint, $7::smallint, now(), 'available' FROM advance WHERE NOT skipped
199
+ RETURNING id
200
+ ), payload AS (
201
+ INSERT INTO monk_job_payloads (job_id, job_class, args)
202
+ SELECT id, $8::text, $9::jsonb FROM job
203
+ )
204
+ SELECT CASE
205
+ WHEN NOT (SELECT due FROM gate) THEN 'not_due'
206
+ WHEN EXISTS (SELECT 1 FROM ins) THEN 'initialized'
207
+ WHEN EXISTS (SELECT 1 FROM reinit) THEN 'reinitialized'
208
+ WHEN EXISTS (SELECT 1 FROM advance WHERE skipped) THEN 'skipped_pending'
209
+ WHEN EXISTS (SELECT 1 FROM advance) THEN 'enqueued'
210
+ ELSE 'already_handled'
211
+ END AS result,
212
+ (SELECT id FROM job) AS job_id,
213
+ (SELECT extract(epoch FROM last_occurrence_at)::float8 FROM prev) AS previous_checkpoint
214
+ SQL
215
+
154
216
  CLEAR_SQL = <<~SQL.freeze
155
217
  DELETE FROM monk_jobs;
156
218
  DELETE FROM monk_processes;
219
+ DELETE FROM monk_recurring_tasks;
157
220
  SQL
158
221
 
159
222
  attr_reader :db_name
@@ -197,6 +260,30 @@ module Monk
197
260
  row && row["state"].to_sym
198
261
  end
199
262
 
263
+ # Handles one occurrence of a recurring task: initializes its
264
+ # checkpoint, enqueues its job, or finds the occurrence handled or
265
+ # not yet due (Firing). occurrence and clock are Times; clock, set
266
+ # only by Monk::Jobs.run_scheduler!, replaces the database clock.
267
+ def fire_recurring(key:, cron:, occurrence:, skip_if_pending:, job_class:, queue:, priority:, max_attempts:,
268
+ args:, clock: nil)
269
+ params = [
270
+ key, cron, occurrence.utc.iso8601(6), skip_if_pending, queue, priority, max_attempts, job_class, args,
271
+ clock&.utc&.iso8601(6),
272
+ ]
273
+ row = with_connection { |c| c.exec_params(FIRE_RECURRING_SQL, params).first }
274
+ checkpoint = row["previous_checkpoint"]
275
+ Firing.new(
276
+ result: row["result"].to_sym, job_id: row["job_id"] && Integer(row["job_id"]),
277
+ previous_checkpoint: checkpoint && Time.at(Float(checkpoint).to_r, in: "UTC"),
278
+ )
279
+ end
280
+
281
+ # Whether monk_recurring_tasks exists, checked once when bin/jobs
282
+ # starts (docs/history/plan-scheduled-jobs.md decision 9).
283
+ def recurring_table?
284
+ with_connection { |c| !c.exec("SELECT to_regclass('monk_recurring_tasks')::text").getvalue(0, 0).nil? }
285
+ end
286
+
200
287
  # Moves up to `limit` due scheduled jobs to available; returns how many.
201
288
  def stage_due(limit = 500)
202
289
  with_connection { |c| c.exec_params(STAGE_SQL, [limit]).cmd_tuples }
@@ -256,7 +343,8 @@ module Monk
256
343
  nil
257
344
  end
258
345
 
259
- # Every job (payloads by cascade) and every process row. Behind
346
+ # Every job (payloads by cascade), every process row and every
347
+ # recurring task's checkpoint. Behind
260
348
  # Monk::Jobs.clear!, which only runs in tests.
261
349
  def clear!
262
350
  with_connection { |c| c.exec(CLEAR_SQL) }
@@ -0,0 +1,180 @@
1
+ require_relative "errors"
2
+
3
+ module Monk
4
+ module Jobs
5
+ # A recurring task's schedule (docs/history/plan-scheduled-jobs.md decision 3):
6
+ # five fields -- minute, hour, day of month, month, day of week -- each
7
+ # `*`, numbers, lists (`,`), ranges (`-`) and steps (`/`), plus the
8
+ # shortcuts in SHORTCUTS. No seconds, no names, no time zones: every
9
+ # time is UTC.
10
+ #
11
+ # When both day fields are restricted, a day matching either counts; a
12
+ # field starting with `*` (`*/2` too) doesn't restrict, as in Vixie
13
+ # cron. Day of week 7 is Sunday, like 0.
14
+ #
15
+ # Frozen Arrays of Integers all the way down, so a parsed expression is
16
+ # shareable with every Ractor.
17
+ Cron = Data.define(:expression, :minutes, :hours, :days, :months, :weekdays, :any_day, :any_weekday)
18
+
19
+ class Cron
20
+ SHORTCUTS = {
21
+ "@hourly" => "0 * * * *", "@daily" => "0 0 * * *", "@weekly" => "0 0 * * 0",
22
+ "@monthly" => "0 0 1 * *", "@yearly" => "0 0 1 1 *",
23
+ }.freeze
24
+
25
+ FIELDS = [["minute", 0..59], ["hour", 0..23], ["day of month", 1..31], ["month", 1..12],
26
+ ["day of week", 0..7],].freeze
27
+
28
+ PART = %r{\A(\*|\d+(?:-\d+)?)(?:/(\d+))?\z}
29
+
30
+ # The longest month each month can have: February counts its 29th.
31
+ MONTH_DAYS = [nil, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
32
+
33
+ MINUTE = 60
34
+ HOUR = 3600
35
+ DAY = 86_400
36
+
37
+ # Neither lookup can go further than this without having found an
38
+ # occurrence: February 29th is at most 8 years away.
39
+ MAX_YEARS = 9
40
+
41
+ def self.parse(expression)
42
+ unless expression.is_a?(String)
43
+ raise InvalidCronError, "a cron expression is a String, got #{expression.inspect}"
44
+ end
45
+
46
+ normalized = expression.split.join(" ")
47
+ fields = shortcut(normalized).split
48
+ unless fields.size == 5
49
+ raise InvalidCronError,
50
+ "#{expression.inspect} has #{fields.size} field(s); a cron expression has 5 fields " \
51
+ "(minute hour day-of-month month day-of-week) or is one of #{SHORTCUTS.keys.join(", ")}"
52
+ end
53
+
54
+ minutes, hours, days, months, weekdays = fields.zip(FIELDS).map do |text, (name, range)|
55
+ parse_field(text, name, range, expression)
56
+ end
57
+ cron = new(
58
+ expression: normalized.freeze, minutes:, hours:, days:, months:,
59
+ weekdays: weekdays.map { |day| day % 7 }.uniq.sort.freeze,
60
+ any_day: fields[2].start_with?("*"), any_weekday: fields[4].start_with?("*"),
61
+ )
62
+ cron.check_matches!
63
+ Ractor.make_shareable(cron)
64
+ end
65
+
66
+ def self.shortcut(normalized)
67
+ return normalized unless normalized.start_with?("@")
68
+
69
+ SHORTCUTS.fetch(normalized) do
70
+ raise InvalidCronError, "unknown cron shortcut #{normalized.inspect}; known: #{SHORTCUTS.keys.join(", ")}"
71
+ end
72
+ end
73
+
74
+ def self.parse_field(text, name, range, expression)
75
+ invalid = ->(problem) { raise InvalidCronError, "#{expression.inspect}: #{name} #{problem}" }
76
+
77
+ text.split(",", -1).flat_map do |part|
78
+ match = PART.match(part) || invalid.call("#{part.inspect} isn't a number, range or step")
79
+ base, step = match.captures
80
+ invalid.call("#{part.inspect}: a step goes on * or a range (a-b/n)") if step && !base.match?(/[*-]/)
81
+ invalid.call("#{part.inspect}: the step is 0") if step&.to_i&.zero?
82
+
83
+ from, to = base == "*" ? range.minmax : base.split("-").map(&:to_i).then { |a, b| [a, b || a] }
84
+ [from, to].reject { |value| range.cover?(value) }.each do |value|
85
+ invalid.call("#{value} is out of range (#{range.min}-#{range.max})")
86
+ end
87
+ invalid.call("range #{part.inspect} runs backwards") if from > to
88
+
89
+ (from..to).step(step ? step.to_i : 1).to_a
90
+ end.uniq.sort.freeze
91
+ end
92
+ private_class_method :shortcut, :parse_field
93
+
94
+ def to_s = expression
95
+
96
+ # The latest occurrence at or before `time`, as a UTC Time on a whole
97
+ # minute. Steps field by field, so a yearly expression takes a handful
98
+ # of steps, not a year of minutes.
99
+ def previous(time)
100
+ time = minute_of(time)
101
+ limit = time.year - MAX_YEARS
102
+ loop do
103
+ raise InvalidCronError, "#{expression.inspect}: no occurrence found" if time.year < limit
104
+
105
+ time =
106
+ if !months.include?(time.month)
107
+ month = months.rfind { |m| m < time.month }
108
+ month ? end_of_month(time.year, month) : end_of_month(time.year - 1, months.last)
109
+ elsif !day?(time)
110
+ day_of(time) - MINUTE
111
+ elsif !hours.include?(time.hour)
112
+ hour = hours.rfind { |h| h < time.hour }
113
+ hour ? day_of(time) + (hour * HOUR) + (59 * MINUTE) : day_of(time) - MINUTE
114
+ elsif !minutes.include?(time.min)
115
+ minute = minutes.rfind { |m| m < time.min }
116
+ minute ? hour_of(time) + (minute * MINUTE) : hour_of(time) - MINUTE
117
+ else
118
+ return time
119
+ end
120
+ end
121
+ end
122
+
123
+ # The first occurrence after `time`, as a UTC Time on a whole minute.
124
+ def next_after(time)
125
+ time = minute_of(time) + MINUTE
126
+ limit = time.year + MAX_YEARS
127
+ loop do
128
+ raise InvalidCronError, "#{expression.inspect}: no occurrence found" if time.year > limit
129
+
130
+ time =
131
+ if !months.include?(time.month)
132
+ month = months.find { |m| m > time.month }
133
+ month ? Time.utc(time.year, month) : Time.utc(time.year + 1, months.first)
134
+ elsif !day?(time)
135
+ day_of(time) + DAY
136
+ elsif !hours.include?(time.hour)
137
+ hour = hours.find { |h| h > time.hour }
138
+ hour ? day_of(time) + (hour * HOUR) : day_of(time) + DAY
139
+ elsif !minutes.include?(time.min)
140
+ minute = minutes.find { |m| m > time.min }
141
+ minute ? hour_of(time) + (minute * MINUTE) : hour_of(time) + HOUR
142
+ else
143
+ return time
144
+ end
145
+ end
146
+ end
147
+
148
+ # A day of month on its own (day of week *) must exist in one of the
149
+ # months, or the expression never matches: `0 0 30 2 *`. With day of
150
+ # week restricted, some week always has a matching day.
151
+ def check_matches!
152
+ return unless any_weekday && !any_day
153
+ return if months.any? { |month| days.first <= MONTH_DAYS[month] }
154
+
155
+ raise InvalidCronError, "#{expression.inspect} never matches: none of its months has day #{days.first}"
156
+ end
157
+
158
+ private
159
+
160
+ def minute_of(time)
161
+ utc = time.getutc
162
+ Time.utc(utc.year, utc.month, utc.day, utc.hour, utc.min)
163
+ end
164
+
165
+ def day_of(time) = Time.utc(time.year, time.month, time.day)
166
+
167
+ def hour_of(time) = Time.utc(time.year, time.month, time.day, time.hour)
168
+
169
+ def end_of_month(year, month)
170
+ (month == 12 ? Time.utc(year + 1, 1, 1) : Time.utc(year, month + 1, 1)) - MINUTE
171
+ end
172
+
173
+ def day?(time)
174
+ in_month = days.include?(time.day)
175
+ in_week = weekdays.include?(time.wday)
176
+ any_day || any_weekday ? in_month && in_week : in_month || in_week
177
+ end
178
+ end
179
+ end
180
+ end