monkrb 0.18.0 → 0.20.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 +166 -0
- data/README.md +2 -2
- data/lib/monk/base.rb +20 -0
- data/lib/monk/jobs/adapters/pg.rb +3 -1
- data/lib/monk/live/client/monk_live.js +2 -2
- data/lib/monk/live/client/protocol.js +5 -0
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +15 -3
- data/lib/monk/persistence/errors.rb +32 -0
- data/lib/monk/persistence/pg.rb +92 -1
- data/lib/monk/persistence/pool.rb +513 -0
- data/lib/monk/persistence.rb +196 -7
- data/lib/monk/scaffold.rb +155 -78
- data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
- data/lib/monk/templates/auth/config/auth.rb +13 -20
- data/lib/monk/templates/base/app/app.rb +9 -0
- data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
- data/lib/monk/templates/base/app/helpers/.keep +0 -0
- data/lib/monk/templates/base/app/jobs/.keep +0 -0
- data/lib/monk/templates/base/app/mailers/.keep +0 -0
- data/lib/monk/templates/base/app/models/.keep +0 -0
- data/lib/monk/templates/base/app/presenters/.keep +0 -0
- data/lib/monk/templates/base/bin/websocket_server +17 -2
- data/lib/monk/templates/base/config/load.rb +18 -0
- data/lib/monk/templates/base/config.ru +3 -12
- data/lib/monk/templates/base/public/js/app.js +1 -1
- data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
- data/lib/monk/templates/jobs/bin/jobs +5 -13
- data/lib/monk/templates/jobs/config/jobs.rb +2 -4
- data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
- data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
- data/lib/monk/templates/live/bin/websocket_server +23 -3
- data/lib/monk/templates/live/config/live.rb +1 -1
- data/lib/monk/templates/live/config/live_pg.rb +1 -1
- data/lib/monk/templates/mail/config/mail.rb +2 -2
- data/lib/monk/templates/postgres/bin/console +4 -2
- data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/connection.rb +25 -6
- data/lib/monk/websocket/errors.rb +11 -0
- data/lib/monk/websocket/listeners.rb +47 -0
- data/lib/monk/websocket/pg_fanout.rb +189 -31
- data/lib/monk/websocket/redis_fanout.rb +125 -33
- data/lib/monk/websocket/registry.rb +30 -0
- data/lib/monk/websocket/server.rb +128 -29
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +21 -9
- /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
- /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
- /data/lib/monk/templates/live/{views → app/views}/live/_hits.erb +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0346b062053981ee7d06c7d3cc9b563c429c1b81443e449b683e53a8080005a1
|
|
4
|
+
data.tar.gz: 8793cbc85210dc77a03b41e3adc39f58fed92488ef2662d2222c9136f5c55f4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 711bddca3ff4fbe17dd0b776e3a759ede7a810304e7c42ad96e5e2dfb6c37e9ad5b77ebad8c5f6224a3c993544722e55067ebffc57c1d20f7436c18021b5afae
|
|
7
|
+
data.tar.gz: e79bb8a6e8d1b77c8bf0227ce0ceeaf16a3ec87b733ce063e8dc32ae7ebd0b24d19915440fb8603f1fe453c74ee91fbe04c68e41e41c84060e6f2d75eaca7353
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,172 @@ 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.20.0 - 2026-10-05
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- A route that raises now logs why. With no `error` handler for the
|
|
12
|
+
exception, `log/<env>.log` gets `ERROR GET /path raised Class: message`
|
|
13
|
+
and the first 10 backtrace lines; before, only `GET /path -> 500` was
|
|
14
|
+
logged, in development too. An exception an `error` handler takes care
|
|
15
|
+
of logs one `INFO ... (handled)` line.
|
|
16
|
+
- `Monk::Persistence::Pg` recovers from a dropped connection (a Postgres
|
|
17
|
+
restart, a failover, `pg_terminate_backend`). Before, every request
|
|
18
|
+
that touched the database answered `500` until the process restarted.
|
|
19
|
+
`checkout` now checks the connection first, about 1 µs when it's
|
|
20
|
+
healthy, and reconnects a dead one. A block is never run twice: a drop
|
|
21
|
+
in the middle of one fails that request, and the next checkout
|
|
22
|
+
reconnects. While Postgres is down, `checkout` raises
|
|
23
|
+
`PG::ConnectionBad` at once.
|
|
24
|
+
- `Monk::WebSocket::PgFanout`'s publisher reconnects after a dropped
|
|
25
|
+
connection. Before, every `Monk::Live.patch`/`batch` in that Ractor
|
|
26
|
+
raised `PG::ConnectionBad` from then on, and no open page updated. A
|
|
27
|
+
failed `pg_notify` isn't retried; the client's resync recovers it.
|
|
28
|
+
- `Monk::WebSocket::PgFanout`'s `LISTEN` connection reconnects after a
|
|
29
|
+
drop, with backoff (500 ms doubling to 30 s). Before, its Ractor ended
|
|
30
|
+
silently and the WebSocket process never got another process's
|
|
31
|
+
broadcast again. Once listening again it closes every open socket with
|
|
32
|
+
`1011`, so each page reconnects and resyncs the broadcasts it missed. A
|
|
33
|
+
notify that can't be decoded or relayed is logged and skipped instead
|
|
34
|
+
of ending the listener.
|
|
35
|
+
- `Monk::WebSocket::RedisFanout`'s subscriber resubscribes after Redis
|
|
36
|
+
drops its connection, the same way `PgFanout`'s `LISTEN` does now:
|
|
37
|
+
backoff, then every open socket closed with `1011` so pages resync.
|
|
38
|
+
Before, it ended silently. A message without an origin separator is
|
|
39
|
+
logged and skipped; it used to reach sockets as `nil`.
|
|
40
|
+
- `Monk::WebSocket::Connection#close` called from a thread other than the
|
|
41
|
+
handler's (a relay, the `reverify_interval` check) could leave the
|
|
42
|
+
socket open, so the client never saw the close. It now shuts the
|
|
43
|
+
socket down; `Server.serve` closes it.
|
|
44
|
+
|
|
45
|
+
- `Monk::Persistence::Pg` connections and `PgFanout`'s publisher get
|
|
46
|
+
`connect_timeout: 5` unless the app sets one, so a reconnect to a host
|
|
47
|
+
that doesn't answer fails instead of waiting indefinitely.
|
|
48
|
+
- A WebSocket socket's own connections are closed when the socket ends:
|
|
49
|
+
its Postgres connection (`authenticate:` without `db_pool:`, or a
|
|
50
|
+
handler that queries) and a `PgFanout`/`RedisFanout` publisher (a
|
|
51
|
+
handler that broadcasts). Before, they stayed open until the garbage
|
|
52
|
+
collector happened to run, which a mostly idle server may not do for a
|
|
53
|
+
long time, so closed pages kept counting against Postgres's
|
|
54
|
+
`max_connections`. New: `Monk::Persistence.disconnect_all` closes every
|
|
55
|
+
connection the calling Ractor opened, and `PgFanout.disconnect_publisher`
|
|
56
|
+
/ `RedisFanout.disconnect_publisher` its publisher; `Server.serve` calls
|
|
57
|
+
them as each socket ends.
|
|
58
|
+
- `monk new --postgres` writes `db/migrate/.keep`. The directory was
|
|
59
|
+
created empty, and git doesn't keep an empty directory, so a fresh
|
|
60
|
+
clone of the app had no `db/migrate/`.
|
|
61
|
+
- An app scaffolded without `--auth` and without `--live` crashed at boot
|
|
62
|
+
in `bin/websocket_server` (`authenticate must be false, true or
|
|
63
|
+
:optional, got nil`). The template now passes `false`; fix an existing
|
|
64
|
+
app's line to `authenticate = !!(defined?(Monk::Auth) && Monk::Auth.config)`.
|
|
65
|
+
- A `checkout` block that ran a raw `BEGIN` and raised no longer leaves
|
|
66
|
+
its transaction open for the next checkout in that Ractor: it's rolled
|
|
67
|
+
back.
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
|
|
71
|
+
- `monk new --auth` checks WebSocket sessions through a pool:
|
|
72
|
+
`config/auth.rb` declares `Monk::Persistence::Pg.pool(:auth, size: 4)`,
|
|
73
|
+
and both `bin/websocket_server` templates start it and pass
|
|
74
|
+
`db_pool: :auth`. A new app's WebSocket process holds 4 connections
|
|
75
|
+
however many pages are open, instead of one per open page.
|
|
76
|
+
|
|
77
|
+
- Connection pools for Ractors that shouldn't each hold a connection
|
|
78
|
+
(`docs/guides/persistence.md`, "Pools"): `Monk::Persistence::Pg.pool(:auth,
|
|
79
|
+
size: 4)` declares one, `start_pools!(:auth)` starts it in the process that
|
|
80
|
+
uses it, and `pool(:auth).call(Contacts, :include?, owner, email)` runs that
|
|
81
|
+
method in one of its worker Ractors, from any Ractor. Errors keep their
|
|
82
|
+
class (`rescue PG::UniqueViolation` works), calls time out, a full queue
|
|
83
|
+
refuses calls, dying workers are replaced, and `call_async` doesn't wait.
|
|
84
|
+
`Monk.boot` lists running pools.
|
|
85
|
+
- `Monk::WebSocket::Server.new(db_pool:)` checks sessions through a pool:
|
|
86
|
+
open sockets no longer hold a Postgres connection each (1,000 sockets on
|
|
87
|
+
4 connections).
|
|
88
|
+
- `Monk::Live.authorize(..., db_pool:)` runs a rule in a pool.
|
|
89
|
+
- `Monk::WebSocket::PgFanout.new(registry, db_pool:)` publishes through a
|
|
90
|
+
pool of size 1: one publishing connection per process, and
|
|
91
|
+
`Monk::Live.patch` no longer waits for (or raises because of) Postgres.
|
|
92
|
+
- `Monk::WebSocket::Registry#close_all(code, reason)` closes every
|
|
93
|
+
registered connection's socket with that code, through a
|
|
94
|
+
`Monk::WebSocket::CloseRequest` sent to each port.
|
|
95
|
+
|
|
96
|
+
## 0.19.0 - 2026-10-01
|
|
97
|
+
|
|
98
|
+
### Fixed
|
|
99
|
+
|
|
100
|
+
- `Monk::WebSocket::Server#run` stops on `TERM` as it does on Ctrl-C: it
|
|
101
|
+
stops accepting and returns, so `bin/websocket_server` exits 0 on
|
|
102
|
+
`docker stop` and deploys instead of being ended by the signal (exit
|
|
103
|
+
143). Open sockets are still dropped without a close frame; see
|
|
104
|
+
`docs/guides/websocket.md`, "Stopping and restarting", which also warns
|
|
105
|
+
what other clients have to handle.
|
|
106
|
+
- `monk_live.js` jitters its reconnect delay (50%–150% of the backoff), so
|
|
107
|
+
the pages that lose their socket together on a WebSocket restart don't
|
|
108
|
+
all reconnect and refetch at once. An existing app has a copy of the
|
|
109
|
+
client in `public/js/monk_live/`: copy `lib/monk/live/client/` over it
|
|
110
|
+
again to get this.
|
|
111
|
+
|
|
112
|
+
### Added
|
|
113
|
+
|
|
114
|
+
- `Monk::WebSocket::Server.new(authenticate: :optional)`: a connection
|
|
115
|
+
with a valid session gets its subject, and one without (none, revoked or
|
|
116
|
+
expired) comes in anonymous instead of getting a `401`. The handler
|
|
117
|
+
decides what an anonymous connection may do; under `Monk::Live`, the
|
|
118
|
+
topic rules (`anonymous: true`). The `Origin` check covers cookies, as
|
|
119
|
+
under `true`, and anonymous connections that send an `Origin`.
|
|
120
|
+
`reverify_interval:` works with it, for connections that have a session.
|
|
121
|
+
`monk new --auth --live`'s `bin/websocket_server` uses it, so the demo
|
|
122
|
+
updates for visitors too, and SETUP.md explains who gets which topics.
|
|
123
|
+
The plain chat `bin/websocket_server` keeps `true`. See
|
|
124
|
+
`docs/design/websocket.md`, "Anonymous connections".
|
|
125
|
+
|
|
126
|
+
### Changed
|
|
127
|
+
|
|
128
|
+
- `monk new` lays the app out under `app/`, by role (ADR 0015):
|
|
129
|
+
`app/app.rb` holds `class App` and its routes, templates are in
|
|
130
|
+
`app/views/`, and every role directory is created with a `.keep`:
|
|
131
|
+
`app/models`, `presenters`, `helpers`, `mailers`, `broadcasts`, `jobs`.
|
|
132
|
+
`config.ru` is the same three lines for every flag set, and the new
|
|
133
|
+
`config/load.rb` requires the settings and each enabled module's config,
|
|
134
|
+
then loads `app/` by role. It never boots the app. `bin/jobs`,
|
|
135
|
+
`bin/console` and the generated test helper require it too. `--auth`'s
|
|
136
|
+
magic-link sender moves from `config/auth.rb` to
|
|
137
|
+
`app/mailers/app_mailer.rb` and is renamed `AppMailer::MAGIC_LINK`;
|
|
138
|
+
`config/auth.rb` requires that file itself. `config/jobs.rb` no longer
|
|
139
|
+
loads the job classes. The generated SETUP.md's test helper loads and
|
|
140
|
+
boots the app, with a sample request test. `docs/guides/scaffolding.md`,
|
|
141
|
+
"Where code goes", says what goes in each directory.
|
|
142
|
+
**An 0.18 app keeps working as it is.** To move it to the new layout:
|
|
143
|
+
1. `git mv views app/views` and `git mv jobs app/jobs`; create the other
|
|
144
|
+
role directories you want.
|
|
145
|
+
2. Move `class App` and its routes from `config.ru` into `app/app.rb`,
|
|
146
|
+
and change `views "views"` to `views "app/views"`.
|
|
147
|
+
3. Create `config/load.rb`: the `require_relative` lines `config.ru` had
|
|
148
|
+
for `config/*` (without the `config/` prefix), then the loop over
|
|
149
|
+
`app/` roles from a freshly generated one. Make `config.ru`
|
|
150
|
+
`require_relative "config/load"`, `require_relative "app/app"`,
|
|
151
|
+
`run Monk.boot(App)`.
|
|
152
|
+
4. Remove the `Dir[...]` loop from `config/jobs.rb`. In `bin/jobs`,
|
|
153
|
+
replace its config requires with `require_relative "../config/load"`
|
|
154
|
+
and point `Monk::Views.root` at `../app/views`. Do the same in
|
|
155
|
+
`bin/console`.
|
|
156
|
+
5. Optional: move `AppMailer` from `config/auth.rb` into
|
|
157
|
+
`app/mailers/app_mailer.rb` and `require_relative` it from
|
|
158
|
+
`config/auth.rb`.
|
|
159
|
+
- **Breaking:** `Monk::WebSocket::RedisFanout` and `PgFanout` no longer
|
|
160
|
+
subscribe when built. The process that holds sockets calls `#listen!`
|
|
161
|
+
once at boot: `Monk::Live.listen!`, or `REGISTRY.listen!` for plain
|
|
162
|
+
`Monk::WebSocket`. Publish-only processes (`bin/server`, `bin/jobs`,
|
|
163
|
+
`bin/console`) then hold no idle subscriber connection. `#listen!`
|
|
164
|
+
returns once the subscription is in effect and raises
|
|
165
|
+
`Monk::WebSocket::ListenError` if Redis or Postgres can't be reached.
|
|
166
|
+
`#register` before `#listen!` raises
|
|
167
|
+
`Monk::WebSocket::NotListeningError`. `Registry#listen!` is a no-op, so
|
|
168
|
+
the same line works with either. The scaffolded `bin/websocket_server`
|
|
169
|
+
calls it. **To upgrade**, add `Monk::Live.listen!` (or
|
|
170
|
+
`REGISTRY.listen!`) to your `bin/websocket_server` before
|
|
171
|
+
`server.run`. See `docs/design/websocket.md`, "Who listens".
|
|
172
|
+
|
|
7
173
|
## 0.18.0 - 2026-09-29
|
|
8
174
|
|
|
9
175
|
### Added
|
data/README.md
CHANGED
|
@@ -22,7 +22,7 @@ bin/server # -> http://localhost:9292/hello
|
|
|
22
22
|
|
|
23
23
|
`monk new` scaffolds the app's own `Gemfile` with `gem "monkrb", require: "monk"`.
|
|
24
24
|
|
|
25
|
-
`monk new` writes a working skeleton (an HTML home page, a `/hello` route, a `/api/hello` JSON route, `
|
|
25
|
+
`monk new` writes a working skeleton (an HTML home page, a `/hello` route, a `/api/hello` JSON route, `public/`, a `SETUP.md`), with the app's own code under `app/`: `app/app.rb` for routes, `app/views/`, and one directory per role (`models/`, `presenters/`, `helpers/`, `mailers/`, `broadcasts/`, `jobs/`). Flags add Postgres, auth, email, background jobs, Redis and live updates: see [`docs/guides/scaffolding.md`](docs/guides/scaffolding.md), which also says where each kind of code goes.
|
|
26
26
|
|
|
27
27
|
All `monk` commands and flags are listed by:
|
|
28
28
|
|
|
@@ -87,6 +87,6 @@ More (server modes, Docker) in [`docs/development.md`](docs/development.md).
|
|
|
87
87
|
|
|
88
88
|
## Status
|
|
89
89
|
|
|
90
|
-
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). There's no open roadmap issue at the moment; new work is proposed and tracked as it comes up. 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).
|
|
90
|
+
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). There's no open roadmap issue at the moment; new work is proposed and tracked as it comes up. 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`).
|
|
91
91
|
|
|
92
92
|
[Made with Love ❤️, Ruby 💎 and AI 🤖](docs/ai_usage_disclaimer.md)
|
data/lib/monk/base.rb
CHANGED
|
@@ -12,6 +12,11 @@ module Monk
|
|
|
12
12
|
EMPTY_ARRAY = [].freeze
|
|
13
13
|
private_constant :EMPTY_ARRAY
|
|
14
14
|
|
|
15
|
+
# How much of an unhandled exception's backtrace #log_exception
|
|
16
|
+
# writes -- the same depth Monk::Jobs keeps for a failed job.
|
|
17
|
+
BACKTRACE_LINES = 10
|
|
18
|
+
private_constant :BACKTRACE_LINES
|
|
19
|
+
|
|
15
20
|
# Action name -> path suffix (appended to the resource path) and verb(s)
|
|
16
21
|
# it dispatches on, for #resources. :update registers both PATCH and PUT --
|
|
17
22
|
# PATCH is the canonical partial-update verb, PUT accepted too since
|
|
@@ -162,6 +167,7 @@ module Monk
|
|
|
162
167
|
[200, context.headers, [body]]
|
|
163
168
|
rescue StandardError => e
|
|
164
169
|
handler = error_handlers.find { |matcher, _| matcher.is_a?(Class) && e.is_a?(matcher) }
|
|
170
|
+
log_exception(env, e, handled: !handler.nil?)
|
|
165
171
|
if handler
|
|
166
172
|
context.status = 500
|
|
167
173
|
body = context.instance_exec(context, &handler.last)
|
|
@@ -173,6 +179,20 @@ module Monk
|
|
|
173
179
|
end
|
|
174
180
|
end
|
|
175
181
|
|
|
182
|
+
# The request line alone ("GET /x -> 500") says nothing about why.
|
|
183
|
+
# Unhandled: an error, with the first backtrace lines. Handled by an
|
|
184
|
+
# `error` block: the app chose the response, so one info line --
|
|
185
|
+
# an error(NotFound) { halt 404 } shouldn't fill the log with ERRORs.
|
|
186
|
+
def log_exception(env, error, handled:)
|
|
187
|
+
line = "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]} raised #{error.class}: #{error.message}"
|
|
188
|
+
if handled
|
|
189
|
+
Monk::Log.info("#{line} (handled)")
|
|
190
|
+
else
|
|
191
|
+
trace = Array(error.backtrace).first(BACKTRACE_LINES).map { |frame| "\n #{frame}" }
|
|
192
|
+
Monk::Log.error("#{line}#{trace.join}")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
176
196
|
def log_request(env, status, start)
|
|
177
197
|
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
|
|
178
198
|
line = "#{Monk::Log.timestamp} #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]} -> #{status} (#{duration_ms}ms)"
|
|
@@ -247,10 +247,12 @@ module Monk
|
|
|
247
247
|
# Ractor's connection is still running on the server, then
|
|
248
248
|
# reconnects, so the worker's next query doesn't wait for the
|
|
249
249
|
# abandoned one (Phase 0.3) or land inside its open transaction.
|
|
250
|
+
# The cancel stays here: that connection is alive, just busy, so
|
|
251
|
+
# checkout's own probe wouldn't reset it.
|
|
250
252
|
def reset_connection
|
|
251
253
|
conn = Monk::Persistence::Pg[@db_name]
|
|
252
254
|
conn.cancel
|
|
253
|
-
conn
|
|
255
|
+
Monk::Persistence::Pg.revive(conn)
|
|
254
256
|
nil
|
|
255
257
|
end
|
|
256
258
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { Idiomorph } from "./idiomorph.js";
|
|
13
13
|
import {
|
|
14
|
-
INITIAL_DELAY, nextDelay, topicsFrom, diffTopics, subscribeMessage, unsubscribeMessage, parseMessage, checkSeq, resyncVerdict,
|
|
14
|
+
INITIAL_DELAY, nextDelay, jittered, topicsFrom, diffTopics, subscribeMessage, unsubscribeMessage, parseMessage, checkSeq, resyncVerdict,
|
|
15
15
|
} from "./protocol.js";
|
|
16
16
|
|
|
17
17
|
const emit = (name, detail = {}) => document.dispatchEvent(new CustomEvent(`monk-live:${name}`, { detail }));
|
|
@@ -185,7 +185,7 @@ function connect() {
|
|
|
185
185
|
socket.onclose = () => {
|
|
186
186
|
emit("disconnected");
|
|
187
187
|
if (stopped) return;
|
|
188
|
-
setTimeout(connect, delay);
|
|
188
|
+
setTimeout(connect, jittered(delay));
|
|
189
189
|
delay = nextDelay(delay);
|
|
190
190
|
};
|
|
191
191
|
}
|
|
@@ -8,6 +8,11 @@ export const MAX_DELAY = 30000;
|
|
|
8
8
|
// Reconnect backoff: double, capped.
|
|
9
9
|
export const nextDelay = (delay) => Math.min(delay * 2, MAX_DELAY);
|
|
10
10
|
|
|
11
|
+
// The wait actually used: 50%..150% of the backoff delay. When the WS process
|
|
12
|
+
// restarts, every open page loses its socket at once; without jitter they all
|
|
13
|
+
// reconnect, and all refetch their page (resync), at the same instant.
|
|
14
|
+
export const jittered = (delay, random = Math.random) => Math.floor(delay * (0.5 + random()));
|
|
15
|
+
|
|
11
16
|
const MODES = ["morph", "replace", "append", "prepend", "remove"];
|
|
12
17
|
|
|
13
18
|
// data-live-topic values ("a:1 b:2") -> a sorted, de-duplicated topic list.
|
data/lib/monk/live/policy.rb
CHANGED
|
@@ -7,8 +7,16 @@ module Monk
|
|
|
7
7
|
# the matching rule says `anonymous: true`: a block like
|
|
8
8
|
# `topic == "contacts:#{subject}"` would otherwise let a nil subject
|
|
9
9
|
# in through "contacts:". A block that raises fails closed.
|
|
10
|
+
#
|
|
11
|
+
# A rule with db_pool: runs its block in that Monk::Persistence::Pg
|
|
12
|
+
# pool (docs/history/plan-pg-pool.md), not in the socket's own Ractor,
|
|
13
|
+
# so a rule that queries the database doesn't open a connection per
|
|
14
|
+
# open page. The block crosses to the pool as it is: rules are
|
|
15
|
+
# shareable procs already. If the pool itself fails (not started in
|
|
16
|
+
# this process, timed out, full), the rule denies and the reason is
|
|
17
|
+
# logged.
|
|
10
18
|
module Policy
|
|
11
|
-
Rule = Data.define(:pattern, :anonymous, :check) do
|
|
19
|
+
Rule = Data.define(:pattern, :anonymous, :check, :db_pool) do
|
|
12
20
|
def matches?(topic)
|
|
13
21
|
if pattern.end_with?("*")
|
|
14
22
|
topic.start_with?(pattern.chomp("*"))
|
|
@@ -18,14 +26,17 @@ module Monk
|
|
|
18
26
|
end
|
|
19
27
|
end
|
|
20
28
|
|
|
21
|
-
def self.build_rule(pattern, anonymous, block)
|
|
29
|
+
def self.build_rule(pattern, anonymous, block, db_pool = nil)
|
|
22
30
|
unless pattern.is_a?(String) && !pattern.strip.empty? && valid_wildcard?(pattern)
|
|
23
31
|
raise ArgumentError,
|
|
24
32
|
"pattern must be a topic or a topic prefix ending in a single *, got #{pattern.inspect}"
|
|
25
33
|
end
|
|
26
34
|
raise ArgumentError, "authorize needs a block: { |subject, topic| ... }" unless block
|
|
35
|
+
unless db_pool.nil? || db_pool.is_a?(Symbol)
|
|
36
|
+
raise ArgumentError, "db_pool must be a pool name (a Symbol), got #{db_pool.inspect}"
|
|
37
|
+
end
|
|
27
38
|
|
|
28
|
-
Rule.new(pattern: pattern.dup.freeze, anonymous: anonymous, check: shareable(block))
|
|
39
|
+
Rule.new(pattern: pattern.dup.freeze, anonymous: anonymous, check: shareable(block), db_pool: db_pool)
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
def self.allowed?(rules, subject, topic)
|
|
@@ -33,11 +44,35 @@ module Monk
|
|
|
33
44
|
return false unless rule
|
|
34
45
|
return false if subject.nil? && !rule.anonymous
|
|
35
46
|
|
|
36
|
-
rule
|
|
47
|
+
run(rule, subject, topic) ? true : false
|
|
48
|
+
rescue Monk::PersistenceTimeoutError, Monk::PoolFullError, Monk::PoolNotStartedError, Monk::UnknownPoolError,
|
|
49
|
+
Monk::PoolWorkerDiedError, Monk::PoolStoppedError => e
|
|
50
|
+
log_pool_failure(rule, topic, e)
|
|
51
|
+
false
|
|
37
52
|
rescue StandardError
|
|
38
53
|
false
|
|
39
54
|
end
|
|
40
55
|
|
|
56
|
+
# What a pooled rule runs in the pool's worker.
|
|
57
|
+
def self.check(block, subject, topic) = block.call(subject, topic)
|
|
58
|
+
|
|
59
|
+
def self.run(rule, subject, topic)
|
|
60
|
+
return rule.check.call(subject, topic) unless rule.db_pool
|
|
61
|
+
|
|
62
|
+
Monk::Persistence::Pg.pool(rule.db_pool).call(Policy, :check, rule.check, subject, topic)
|
|
63
|
+
end
|
|
64
|
+
private_class_method :run
|
|
65
|
+
|
|
66
|
+
def self.log_pool_failure(rule, topic, error)
|
|
67
|
+
Monk::Log.error(
|
|
68
|
+
"Monk::Live: denied #{topic} (rule #{rule.pattern}): its pool #{rule.db_pool.inspect} failed: " \
|
|
69
|
+
"#{error.class}: #{error.message}",
|
|
70
|
+
)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
private_class_method :log_pool_failure
|
|
75
|
+
|
|
41
76
|
def self.valid_wildcard?(pattern)
|
|
42
77
|
pattern.count("*").zero? || (pattern.count("*") == 1 && pattern.end_with?("*"))
|
|
43
78
|
end
|
data/lib/monk/live/session.rb
CHANGED
|
@@ -95,6 +95,13 @@ module Monk
|
|
|
95
95
|
seq = 0
|
|
96
96
|
loop do
|
|
97
97
|
payload = @port.receive
|
|
98
|
+
# Registry#close_all: broadcasts were missed, so the client must
|
|
99
|
+
# reconnect and resync (ADR 0011). run's read loop ends, and
|
|
100
|
+
# cleanup unregisters every topic.
|
|
101
|
+
if payload.is_a?(Monk::WebSocket::CloseRequest)
|
|
102
|
+
return @connection.close(code: payload.code, reason: payload.reason)
|
|
103
|
+
end
|
|
104
|
+
|
|
98
105
|
seq += 1
|
|
99
106
|
@connection.write(payload.start_with?("{") ? "{\"seq\":#{seq},#{payload.byteslice(1..)}" : payload)
|
|
100
107
|
end
|
data/lib/monk/live.rb
CHANGED
|
@@ -62,11 +62,23 @@ module Monk
|
|
|
62
62
|
@max_topics = max_topics
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# Boot-time, main Ractor, in the WS process only (bin/websocket_server):
|
|
66
|
+
# starts the configured registry relaying other processes' updates
|
|
67
|
+
# to this process's sockets. A publish-only process (bin/server,
|
|
68
|
+
# bin/jobs) never calls it, so it opens no subscriber connection.
|
|
69
|
+
def listen!
|
|
70
|
+
registry || raise(NotConfiguredError,
|
|
71
|
+
"Monk::Live isn't configured -- call Monk::Live.configure(registry: ...) before listen!",)
|
|
72
|
+
registry.listen!
|
|
73
|
+
end
|
|
74
|
+
|
|
65
75
|
# Boot-time, main Ractor. See Monk::Live::Policy for the semantics:
|
|
66
76
|
# deny by default, first matching rule wins, anonymous subjects need
|
|
67
|
-
# `anonymous: true`. The block must be Ractor-shareable.
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
# `anonymous: true`. The block must be Ractor-shareable. db_pool:
|
|
78
|
+
# names a Monk::Persistence::Pg pool to run the block in, for a rule
|
|
79
|
+
# that queries the database; the WebSocket process must start it.
|
|
80
|
+
def authorize(pattern, anonymous: false, db_pool: nil, &block)
|
|
81
|
+
@rules = Ractor.make_shareable([*@rules, Policy.build_rule(pattern, anonymous, block, db_pool)])
|
|
70
82
|
end
|
|
71
83
|
|
|
72
84
|
def authorized?(subject, topic) = Policy.allowed?(rules, subject, topic)
|
|
@@ -4,4 +4,36 @@ module Monk
|
|
|
4
4
|
|
|
5
5
|
class UnknownPersistenceError < StandardError
|
|
6
6
|
end
|
|
7
|
+
|
|
8
|
+
# Monk::Persistence::Pg.pool(name) for a pool never declared.
|
|
9
|
+
class UnknownPoolError < StandardError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Monk::Persistence::Pg.pool(name) for a pool declared but not started
|
|
13
|
+
# in this process (start_pools!).
|
|
14
|
+
class PoolNotStartedError < StandardError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A pool call's method returned a value that can't be copied to the
|
|
18
|
+
# caller's Ractor (a PG::Result, say).
|
|
19
|
+
class PoolReturnError < StandardError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A pool's queue is full: as many calls as its queue: option allows are
|
|
23
|
+
# already waiting for a worker.
|
|
24
|
+
class PoolFullError < StandardError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The pool worker running a call died (not an exception from the called
|
|
28
|
+
# method, which the caller gets): the call may or may not have finished.
|
|
29
|
+
class PoolWorkerDiedError < StandardError
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The pool has stopped: calls waiting when it stopped, and calls after.
|
|
33
|
+
class PoolStoppedError < StandardError
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# start_pools! couldn't connect a pool's workers.
|
|
37
|
+
class PoolStartError < StandardError
|
|
38
|
+
end
|
|
7
39
|
end
|
data/lib/monk/persistence/pg.rb
CHANGED
|
@@ -36,11 +36,102 @@ module Monk
|
|
|
36
36
|
.tap { |types| %w[json jsonb].each { |name| types.register_type(0, name, nil, JsonDecoder) } },
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
+
# Without a connect_timeout, libpq waits indefinitely for a host that
|
|
40
|
+
# doesn't answer -- and #checkout's revive does so holding the
|
|
41
|
+
# Ractor's slot. The same 5 s as Registry's checkout timeout. An
|
|
42
|
+
# app's own connect_timeout: wins.
|
|
43
|
+
DEFAULT_CONNECT_TIMEOUT = 5
|
|
44
|
+
|
|
45
|
+
# Every diagnostic field libpq reports for an error (SQLSTATE,
|
|
46
|
+
# constraint, table, detail, hint, ...).
|
|
47
|
+
DIAG_FIELDS = PG.constants.grep(/\APG_DIAG_/).map { |name| PG.const_get(name) }.freeze
|
|
48
|
+
|
|
49
|
+
# What a PG::Error's #result becomes when the error leaves a pool's
|
|
50
|
+
# worker (#make_portable): a PG::Result is tied to its connection and
|
|
51
|
+
# can't be copied to another Ractor. Keeps what reading an error
|
|
52
|
+
# needs; anything else raises NoMethodError.
|
|
53
|
+
ErrorResult = Data.define(:fields, :error_message, :result_status, :res_status) do
|
|
54
|
+
def self.from(result)
|
|
55
|
+
fields = DIAG_FIELDS.to_h { |code| [code, result.error_field(code)] }.compact
|
|
56
|
+
new(
|
|
57
|
+
fields: fields, error_message: result.error_message,
|
|
58
|
+
result_status: result.result_status, res_status: result.res_status,
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def error_field(code) = fields[code]
|
|
63
|
+
alias_method :result_error_field, :error_field
|
|
64
|
+
alias_method :result_error_message, :error_message
|
|
65
|
+
end
|
|
66
|
+
|
|
39
67
|
class << self
|
|
68
|
+
# A copy of an exception about to leave a pool's worker
|
|
69
|
+
# (Pool::Portable): a PG::Error holds its connection and result,
|
|
70
|
+
# which can't be copied to another Ractor. The connection goes (no
|
|
71
|
+
# use in another Ractor anyway), and the result becomes an
|
|
72
|
+
# ErrorResult, so error_field still answers. The error keeps its
|
|
73
|
+
# class, so `rescue PG::UniqueViolation` matches as it would here.
|
|
74
|
+
def make_portable(error)
|
|
75
|
+
return unless error.is_a?(PG::Error)
|
|
76
|
+
|
|
77
|
+
result = error.result
|
|
78
|
+
error.instance_variable_set(:@connection, nil)
|
|
79
|
+
error.instance_variable_set(:@result, result && ErrorResult.from(result))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Detects a connection the server has closed (a restart, a
|
|
83
|
+
# failover, pg_terminate_backend). libpq still reports
|
|
84
|
+
# CONNECTION_OK, but the socket is readable: a healthy idle
|
|
85
|
+
# connection's isn't. So the common case costs one zero-timeout
|
|
86
|
+
# select, about 1 µs, and nothing sent.
|
|
87
|
+
#
|
|
88
|
+
# A readable socket is either the server's FATAL before it closes,
|
|
89
|
+
# or a NOTIFY on a connection that LISTENs. Reading on to tell
|
|
90
|
+
# them apart races the close: the FATAL can arrive a moment before
|
|
91
|
+
# the end of file, and a second select in between sees nothing.
|
|
92
|
+
# So a readable socket gets an empty query, one round trip, which
|
|
93
|
+
# fails on a closed connection and leaves pending notifies queued.
|
|
94
|
+
#
|
|
95
|
+
# A network path that dies without closing the socket (a NAT
|
|
96
|
+
# timing out an idle connection) leaves it unreadable, and this
|
|
97
|
+
# can't see it: the next query waits for TCP to give up.
|
|
98
|
+
#
|
|
99
|
+
# Public: PgFanout probes its own connections with it.
|
|
100
|
+
def alive?(conn)
|
|
101
|
+
return false unless conn.status == PG::CONNECTION_OK
|
|
102
|
+
return true unless conn.socket_io.wait_readable(0)
|
|
103
|
+
|
|
104
|
+
conn.exec("")
|
|
105
|
+
true
|
|
106
|
+
rescue PG::Error, IOError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Same connection object, new backend: the result type map set by
|
|
111
|
+
# #connect survives. Raises PG::ConnectionBad if Postgres is still
|
|
112
|
+
# unreachable; the next checkout tries again.
|
|
113
|
+
def revive(conn)
|
|
114
|
+
conn.reset
|
|
115
|
+
end
|
|
116
|
+
|
|
40
117
|
private
|
|
41
118
|
|
|
119
|
+
# A block that ran a raw BEGIN and raised leaves the connection in
|
|
120
|
+
# a transaction, and the next checkout in this Ractor would run
|
|
121
|
+
# inside it. (conn.transaction rolls back by itself.) Skipped for
|
|
122
|
+
# PQTRANS_ACTIVE, a query still running: a ROLLBACK would first
|
|
123
|
+
# wait for it. Monk::Jobs cancels those itself.
|
|
124
|
+
def release(conn)
|
|
125
|
+
return unless conn.status == PG::CONNECTION_OK
|
|
126
|
+
return unless [PG::PQTRANS_INTRANS, PG::PQTRANS_INERROR].include?(conn.transaction_status)
|
|
127
|
+
|
|
128
|
+
conn.exec("ROLLBACK")
|
|
129
|
+
rescue PG::Error
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
42
133
|
def connect(**opts)
|
|
43
|
-
conn = PG.connect(**opts)
|
|
134
|
+
conn = PG.connect(connect_timeout: DEFAULT_CONNECT_TIMEOUT, **opts)
|
|
44
135
|
conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn, registry: RESULT_TYPES)
|
|
45
136
|
conn
|
|
46
137
|
end
|