monkrb 0.19.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 +89 -0
- data/README.md +1 -1
- data/lib/monk/base.rb +20 -0
- data/lib/monk/jobs/adapters/pg.rb +3 -1
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +5 -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 +3 -1
- data/lib/monk/templates/auth/config/auth.rb +6 -0
- data/lib/monk/templates/base/bin/websocket_server +11 -2
- data/lib/monk/templates/live/bin/websocket_server +9 -1
- 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/listeners.rb +24 -0
- data/lib/monk/websocket/pg_fanout.rb +143 -20
- data/lib/monk/websocket/redis_fanout.rb +78 -29
- data/lib/monk/websocket/registry.rb +24 -0
- data/lib/monk/websocket/server.rb +54 -15
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +3 -1
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,95 @@ 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
|
+
|
|
7
96
|
## 0.19.0 - 2026-10-01
|
|
8
97
|
|
|
9
98
|
### Fixed
|
data/README.md
CHANGED
|
@@ -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), 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`).
|
|
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
|
|
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
|
@@ -74,9 +74,11 @@ module Monk
|
|
|
74
74
|
|
|
75
75
|
# Boot-time, main Ractor. See Monk::Live::Policy for the semantics:
|
|
76
76
|
# deny by default, first matching rule wins, anonymous subjects need
|
|
77
|
-
# `anonymous: true`. The block must be Ractor-shareable.
|
|
78
|
-
|
|
79
|
-
|
|
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)])
|
|
80
82
|
end
|
|
81
83
|
|
|
82
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
|