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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa788432dda8104f066b4230e406e690df36c72528f57de126001519576fa0e6
|
|
4
|
+
data.tar.gz: 36352b5fa73ff475ca37141e8d5aeca9f0cd487845f1521f62bc3048650a57a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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)
|
data/lib/monk/auth/errors.rb
CHANGED
|
@@ -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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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/generators/auth.rb
CHANGED
|
@@ -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" }
|
data/lib/monk/generators/jobs.rb
CHANGED
|
@@ -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)
|
|
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
|