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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +2 -2
  4. data/lib/monk/base.rb +20 -0
  5. data/lib/monk/jobs/adapters/pg.rb +3 -1
  6. data/lib/monk/live/client/monk_live.js +2 -2
  7. data/lib/monk/live/client/protocol.js +5 -0
  8. data/lib/monk/live/policy.rb +39 -4
  9. data/lib/monk/live/session.rb +7 -0
  10. data/lib/monk/live.rb +15 -3
  11. data/lib/monk/persistence/errors.rb +32 -0
  12. data/lib/monk/persistence/pg.rb +92 -1
  13. data/lib/monk/persistence/pool.rb +513 -0
  14. data/lib/monk/persistence.rb +196 -7
  15. data/lib/monk/scaffold.rb +155 -78
  16. data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
  17. data/lib/monk/templates/auth/config/auth.rb +13 -20
  18. data/lib/monk/templates/base/app/app.rb +9 -0
  19. data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
  20. data/lib/monk/templates/base/app/helpers/.keep +0 -0
  21. data/lib/monk/templates/base/app/jobs/.keep +0 -0
  22. data/lib/monk/templates/base/app/mailers/.keep +0 -0
  23. data/lib/monk/templates/base/app/models/.keep +0 -0
  24. data/lib/monk/templates/base/app/presenters/.keep +0 -0
  25. data/lib/monk/templates/base/bin/websocket_server +17 -2
  26. data/lib/monk/templates/base/config/load.rb +18 -0
  27. data/lib/monk/templates/base/config.ru +3 -12
  28. data/lib/monk/templates/base/public/js/app.js +1 -1
  29. data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
  30. data/lib/monk/templates/jobs/bin/jobs +5 -13
  31. data/lib/monk/templates/jobs/config/jobs.rb +2 -4
  32. data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
  33. data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
  34. data/lib/monk/templates/live/bin/websocket_server +23 -3
  35. data/lib/monk/templates/live/config/live.rb +1 -1
  36. data/lib/monk/templates/live/config/live_pg.rb +1 -1
  37. data/lib/monk/templates/mail/config/mail.rb +2 -2
  38. data/lib/monk/templates/postgres/bin/console +4 -2
  39. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  40. data/lib/monk/version.rb +1 -1
  41. data/lib/monk/websocket/connection.rb +25 -6
  42. data/lib/monk/websocket/errors.rb +11 -0
  43. data/lib/monk/websocket/listeners.rb +47 -0
  44. data/lib/monk/websocket/pg_fanout.rb +189 -31
  45. data/lib/monk/websocket/redis_fanout.rb +125 -33
  46. data/lib/monk/websocket/registry.rb +30 -0
  47. data/lib/monk/websocket/server.rb +128 -29
  48. data/lib/monk/websocket.rb +2 -0
  49. data/lib/monk.rb +10 -0
  50. metadata +21 -9
  51. /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
  52. /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
  53. /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
  54. /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
  55. /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: e8e07694f006907b83202dbafa0dcbfd61d31e1c1f90b1012597dada8eb24c3e
4
- data.tar.gz: cbadf97dbb1fb1063332c7046afcc96fd526e7c483c0bcc8a5873f1b94bb2636
3
+ metadata.gz: 0346b062053981ee7d06c7d3cc9b563c429c1b81443e449b683e53a8080005a1
4
+ data.tar.gz: 8793cbc85210dc77a03b41e3adc39f58fed92488ef2662d2222c9136f5c55f4f
5
5
  SHA512:
6
- metadata.gz: f44edaf6f9f80765fa51841d068b920f880fc6855fd702cbff242a51423ca4f7206452b9f4899a6ca5340b2ac228fd8604c287e1dad82d9f122ea6ac26304b2d
7
- data.tar.gz: 1dc1579210fbbd2c211a590eb50ccabf9c385b7e2d8362b723c0cbe1edd55ad64bfd886d8948725d4514b84afee274144fd25790dbf554b82a0372072319e33d
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, `views/`, `public/`, a `SETUP.md`). Flags add Postgres, auth, email, background jobs, Redis and live updates: see [`docs/guides/scaffolding.md`](docs/guides/scaffolding.md).
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.reset
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.
@@ -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.check.call(subject, topic) ? true : false
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
@@ -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
- def authorize(pattern, anonymous: false, &block)
69
- @rules = Ractor.make_shareable([*@rules, Policy.build_rule(pattern, anonymous, block)])
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
@@ -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