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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5745b05b44d3f2953a037262d97c7a211ebea46b72f9872e6d626c58886338bf
4
- data.tar.gz: f6a970fb8f3e724e992238c7c89f230e215cba4b9d36aadc6122bab452b36ea5
3
+ metadata.gz: 0346b062053981ee7d06c7d3cc9b563c429c1b81443e449b683e53a8080005a1
4
+ data.tar.gz: 8793cbc85210dc77a03b41e3adc39f58fed92488ef2662d2222c9136f5c55f4f
5
5
  SHA512:
6
- metadata.gz: 9f16caa7f5d287d95cf1adaf65f72a1da511f9a02671ab06ac51634c4f2a44af2ae76c232b7931ec6118f461116f9fc0ce89d08a56bd1e23eeb8f076f4ff834e
7
- data.tar.gz: 8083b153954fee1475e08b778772bdb8592ff597feae9367bf6d589bf22c5859792ea3bf9bc74e5c1baef0567568ee5302b87d1e82addc65a5703081988b7ec7
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.reset
255
+ Monk::Persistence::Pg.revive(conn)
254
256
  nil
255
257
  end
256
258
 
@@ -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
@@ -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
- def authorize(pattern, anonymous: false, &block)
79
- @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)])
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
@@ -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