monkrb 0.21.1 → 0.22.1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -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/bin.rb +94 -0
  8. data/lib/monk/generators/auth.rb +1 -0
  9. data/lib/monk/generators/jobs.rb +3 -1
  10. data/lib/monk/jobs/adapters/pg.rb +90 -2
  11. data/lib/monk/jobs/cron.rb +180 -0
  12. data/lib/monk/jobs/errors.rb +21 -0
  13. data/lib/monk/jobs/firing.rb +11 -0
  14. data/lib/monk/jobs/recurring_task.rb +9 -0
  15. data/lib/monk/jobs/runtime.rb +38 -2
  16. data/lib/monk/jobs/scheduler.rb +91 -0
  17. data/lib/monk/jobs.rb +87 -8
  18. data/lib/monk/live/session.rb +16 -9
  19. data/lib/monk/templates/auth/agents.md +10 -3
  20. data/lib/monk/templates/auth/app/routes/auth.rb +4 -8
  21. data/lib/monk/templates/auth/config/auth.rb +1 -0
  22. data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.down.sql +2 -0
  23. data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.up.sql +16 -0
  24. data/lib/monk/templates/auth/setup.md +16 -3
  25. data/lib/monk/templates/auth/test/auth_test.rb +6 -0
  26. data/lib/monk/templates/base/Gemfile +1 -1
  27. data/lib/monk/templates/base/config/load.rb +5 -0
  28. data/lib/monk/templates/jobs/agents.md +15 -4
  29. data/lib/monk/templates/jobs/config/recurring.rb +21 -0
  30. data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.down.sql +1 -0
  31. data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.up.sql +13 -0
  32. data/lib/monk/templates/jobs/setup.md +20 -2
  33. data/lib/monk/templates/websocket/bin/websocket_server +14 -0
  34. data/lib/monk/templates/websocket/setup.md +2 -1
  35. data/lib/monk/version.rb +1 -1
  36. data/lib/monk/websocket/handshake.rb +32 -9
  37. data/lib/monk/websocket/pg_fanout.rb +16 -7
  38. data/lib/monk/websocket/redis_fanout.rb +16 -7
  39. data/lib/monk/websocket/registry.rb +40 -5
  40. data/lib/monk/websocket/server.rb +211 -47
  41. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd1a1140ab4c223d2bdd9c10a226ea274e0051af1ab01444194cb4dc88ebb166
4
- data.tar.gz: 9fab9365ba1a31224caf14038c2e36c0ba59c9cc70dd60c037f4dbfa78aec8c8
3
+ metadata.gz: ae98dd74608d293e7fdae3c428abae8d851f25e4a52d02d9d92aa499588d5182
4
+ data.tar.gz: ff9fb430178e748ea859c9c6ae8ef72b8d35851b297a7d38f39b9f36a8597f02
5
5
  SHA512:
6
- metadata.gz: 36bf94be6268d9f12fbb703132f96b00a84c52e5eeb6b03d9b90b9e981d5c9be2ee89003ecc7952b2880d288454d4d6d1734ffb6cfd8bd70328dded0cbbc1e0e
7
- data.tar.gz: 58c8a897231b31edcc82cfd0e3162c64633a2a121f2b3cc01dbbe3b2fa6ef7a7d9760eb2b623a4b6b8def85004322abba20b001758dadd291f8a2ada52b3d26b
6
+ metadata.gz: ffa8fe3639cca148a43d9bd308d2e5b7fe761d391d3687a709e82c2747ec0825184c2b3f10bafe3630a6f568733545ff8879ab0768f9f555256e83c0d0cc5f7e
7
+ data.tar.gz: 1a7ec63d1a87f9f31d931022889a1630dcd4b8c4e02316992650a72e2bc5114fe49c280ace2a638b43d9425a4460283fe295f10b6f27ce499649db8e4156ebe9
data/CHANGELOG.md CHANGED
@@ -4,6 +4,100 @@ 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.1 - 2026-10-20
8
+
9
+ ### Added
10
+
11
+ - **Several WebSocket processes on one port.** `Server.new(reuse_port:
12
+ true)` binds with `SO_REUSEPORT`, and the scaffold's
13
+ `bin/websocket_server` runs `WS_PROCESSES` processes (1 by default),
14
+ starting that many copies of itself through the new
15
+ `Monk::Bin.spawn_processes!` and passing `TERM`/`INT` on. Each has its
16
+ own heap and its own GC pauses over its share of the sockets, so on
17
+ Ruby 4.0 K processes take about K² times the connects a second
18
+ (`docs/guides/websocket.md`, "Several processes"; plan item 8). An
19
+ existing app copies the new script (`docs/guides/upgrading.md`,
20
+ 0.22.1).
21
+
22
+ ### Fixed
23
+
24
+ - **A WebSocket connect burst queues instead of collapsing.** The server
25
+ caps the handshakes in flight (`Server.new(max_handshakes:)`, 128 by
26
+ default); past the cap, connects wait in the kernel's listen backlog.
27
+ Before, every accepted socket started its handshake at once, and a
28
+ burst past what the process could verify (a restart's reconnect wave)
29
+ filled the `db_pool:` queue and timed most of them out
30
+ (`docs/plan-ws-connect-ceiling.md`, item 1).
31
+ - **A WebSocket handshake has a deadline and a size limit.** A handshake
32
+ not over within `handshake_timeout:` (10 s by default) is dropped, and a request head past 16 KiB gets a
33
+ `431`. Before, a socket that connected and sent nothing held its
34
+ connection Ractor forever, and a request with no blank line grew
35
+ without limit (item 2).
36
+ - **A WebSocket handshake whose session check fails is refused with
37
+ `503`**, and one line on stderr. Before, a `verify` that raised (the
38
+ `db_pool:` full or timed out, the database down) killed the
39
+ connection's Ractor with a backtrace and sent no response. Under
40
+ `authenticate: :optional` it is a `503` too, not an anonymous
41
+ connection. Clients without an `Origin` also get `Retry-After`
42
+ (item 3).
43
+
44
+ ### Changed
45
+
46
+ - **A WebSocket handshake allocates less.** The request is read with one
47
+ `gets` instead of a String a byte, parsed once instead of twice, and
48
+ its credential worked out once instead of up to three times. Fewer
49
+ objects a connect means fewer GCs, which is what limits connects a
50
+ second once thousands of sockets are open (item 4).
51
+ - **A Live subscribe is one registry ask, not one a topic.**
52
+ `Registry#register_many(keys, port)` and `#unregister_many` (on
53
+ `RedisFanout` and `PgFanout` too) take every key at once;
54
+ `Monk::Live`'s subscribe, unsubscribe and cleanup use them. Each fiber
55
+ also reuses one reply port for its asks instead of making one an ask,
56
+ and the registry survives answering an ask whose caller gave up
57
+ (item 5).
58
+ - **`bin/websocket_server` runs with GC settings for many open sockets.**
59
+ It starts itself again, once at boot, with
60
+ `RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO=0.6`, `_GOAL_RATIO=0.8` and
61
+ `_MAX_RATIO=0.9` (`Monk::Bin::WEBSOCKET_GC_ENV`, through the new
62
+ `Monk::Bin.boot_with_env!`), unless the environment sets them. On Ruby
63
+ 4.0, with 15K sockets held, 6–30× fewer GCs and p99 to ready at
64
+ 300 connects/s from 6.3 s to 1.2 s, for ~30% more memory. An existing
65
+ app adds one line (`docs/guides/upgrading.md`, 0.22.1) (item 7).
66
+ Restarting with the settings carries the warning level (`-W0`) over;
67
+ other flags given to `ruby` itself aren't (`RUBYOPT` is).
68
+
69
+ ## 0.22.0 - 2026-10-13
70
+
71
+ ### Added
72
+
73
+ - **Recurring jobs.** `Monk::Jobs.recurring "purge_sessions",
74
+ PurgeSessions, cron: "0 3 * * *"`, declared in `config/recurring.rb`,
75
+ is enqueued by `bin/jobs` at each occurrence, once however many job
76
+ processes run. Five-field cron in UTC, plus `@hourly` to `@yearly`. A
77
+ new task first runs at its next occurrence, missed occurrences run
78
+ once, and `skip_if_pending: true` skips a run while the previous one
79
+ hasn't finished. `Monk::Jobs.run_scheduler!(at:)` runs the schedule in
80
+ tests. One checkpoint row per task in the new `monk_recurring_tasks`
81
+ table (ADR 0016, `docs/guides/jobs.md`, "Recurring jobs").
82
+ `monk add jobs` writes `config/recurring.rb` with a commented example
83
+ and the table's migration, and the base `config/load.rb` loads
84
+ `config/recurring.rb` after `app/`. An app that already has jobs:
85
+ `docs/guides/upgrading.md`, 0.22; until then its tests that call
86
+ `Monk::Jobs.clear!` fail on the missing table.
87
+ - **A login-email rate limit in `Monk::Auth`.**
88
+ `Monk::Auth.allow_login_request?(email)` allows one login email per
89
+ address per `login_request_interval:` (60 seconds), counted in a new
90
+ `login_requests` table, so across every process. The scaffolded login
91
+ routes use it instead of the per-process `RateLimiter`.
92
+ `Monk::Auth.clear_login_requests!` resets it in tests.
93
+ - **Auth cleans up after itself.** `Monk::Auth.sweep!` deletes in batches
94
+ and covers `login_requests`; the new `Monk::Auth::Sweep` job runs it,
95
+ and `monk add jobs`'s `config/recurring.rb` schedules it nightly
96
+ (`monk_auth_sweep`) when auth is installed.
97
+ - **An index on `sessions (subject)`**, in the same new auth migration,
98
+ `create_login_requests`. An app that already has auth:
99
+ `docs/guides/upgrading.md`, 0.22, "Auth housekeeping".
100
+
7
101
  ## 0.21.1 - 2026-10-09
8
102
 
9
103
  ### Fixed
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`). In 0.22.1 (2026-10-20): the WebSocket server queues a connect burst instead of collapsing under it, refuses a handshake it can't check with a `503`, and takes more connects a second with thousands of sockets open: fewer allocations a connect, GC settings in `bin/websocket_server`, and `WS_PROCESSES` for several server processes on one port (`docs/guides/websocket.md`, `docs/plan-ws-connect-ceiling.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)
data/lib/monk/bin.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require "rbconfig"
2
+
1
3
  module Monk
2
4
  # What a scaffolded app's bin/ scripts run their work in:
3
5
  #
@@ -13,7 +15,60 @@ module Monk
13
15
  # is. Errors are recognized by class name, so this file loads neither pg
14
16
  # nor redis (docs/adr/0017).
15
17
  module Bin
18
+ # bin/websocket_server's GC settings: keep the heap 60-90% free. On Ruby
19
+ # 4.0 a GC runs whenever new connection Ractors have used up the free
20
+ # pages of the smallest heap (each takes one, however little it
21
+ # allocates), and every GC stops and marks every open socket: at 15K
22
+ # sockets held, these run 6-30x fewer GCs, for ~30% more memory
23
+ # (docs/plan-ws-connect-ceiling.md, "After items 1-5"). Measured to
24
+ # change nothing on 4.1, whose Ractors have heaps of their own.
25
+ WEBSOCKET_GC_ENV = {
26
+ "RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO" => "0.6",
27
+ "RUBY_GC_HEAP_FREE_SLOTS_GOAL_RATIO" => "0.8",
28
+ "RUBY_GC_HEAP_FREE_SLOTS_MAX_RATIO" => "0.9",
29
+ }.freeze
30
+
16
31
  class << self
32
+ # Ruby reads settings like these only when it boots, so a script
33
+ # that needs them starts itself again, once, with the ones the
34
+ # environment doesn't set; what it does set wins. Call it first,
35
+ # before the script loads the app. The same PID throughout. Of the
36
+ # flags given to ruby itself, only the warning level is carried over
37
+ # (RUBYOPT is, whole).
38
+ def boot_with_env!(script, env)
39
+ missing = env.reject { |name, _| ENV.key?(name) }
40
+ return if missing.empty?
41
+
42
+ # exec drops what's still buffered.
43
+ $stdout.flush
44
+ $stderr.flush
45
+ exec(missing, RbConfig.ruby, *warning_flags, script, *ARGV)
46
+ end
47
+
48
+ # Runs the script in `count` processes: this one starts that many
49
+ # copies of it, then only waits on them, passing INT and TERM on; in
50
+ # each copy (MONK_BIN_PROCESS set) it returns at once. Copies, not
51
+ # forks: on macOS a forked child that opens a Postgres connection
52
+ # crashes in the Objective-C runtime, and a fresh process inherits
53
+ # nothing. If one ends on its own (a crash, a service down at boot),
54
+ # the others are stopped too, and this process exits with its status,
55
+ # so whatever restarts the script restarts them all. With 1, returns
56
+ # at once, in this process.
57
+ def spawn_processes!(script, count)
58
+ unless count.is_a?(Integer) && count.positive?
59
+ raise ArgumentError, "the process count must be a positive Integer, got #{count.inspect}"
60
+ end
61
+ return if count == 1 || ENV.key?("MONK_BIN_PROCESS")
62
+
63
+ # Each in its own process group, so a Ctrl-C at the terminal
64
+ # reaches it once, passed on from here, and not twice.
65
+ pids = Array.new(count) do |index|
66
+ env = { "MONK_BIN_PROCESS" => (index + 1).to_s }
67
+ Process.spawn(env, RbConfig.ruby, *warning_flags, script, *ARGV, pgroup: true)
68
+ end
69
+ exit supervise(pids)
70
+ end
71
+
17
72
  def run(script)
18
73
  yield
19
74
  rescue StandardError => e
@@ -41,6 +96,45 @@ module Monk
41
96
 
42
97
  private
43
98
 
99
+ # The warning level this Ruby runs at, as flags for another: a script
100
+ # started with -W0 stays quiet when it starts again.
101
+ def warning_flags
102
+ flags = []
103
+ flags << "-W0" if $VERBOSE.nil?
104
+ flags << "-W:no-experimental" unless Warning[:experimental]
105
+ flags
106
+ end
107
+
108
+ # Waits until every pid has ended, and returns the first nonzero
109
+ # status among them, or 0.
110
+ def supervise(pids)
111
+ stop = -> { pids.each { |pid| terminate(pid) } }
112
+ stopping = false
113
+ %w[INT TERM].each do |name|
114
+ trap(name) do
115
+ stopping = true
116
+ stop.call
117
+ end
118
+ end
119
+ status = 0
120
+ until pids.empty?
121
+ pid, process_status = Process.wait2
122
+ pids.delete(pid)
123
+ status = process_status.exitstatus || 1 if status.zero? && !process_status.success?
124
+ next if stopping
125
+
126
+ stopping = true
127
+ stop.call
128
+ end
129
+ status
130
+ end
131
+
132
+ def terminate(pid)
133
+ Process.kill("TERM", pid)
134
+ rescue Errno::ESRCH
135
+ nil # already gone
136
+ end
137
+
44
138
  def postgres(error, context)
45
139
  host = error.message[/server at "([^"]+)"/, 1] || ENV.fetch("DB_HOST", "127.0.0.1")
46
140
  port = error.message[/port (\d+)/, 1] || ENV.fetch("DB_PORT", "5432")
@@ -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) }