pg_pipeline 0.3.2 → 0.3.4

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: c7fdda2e5d806be070a3255e8cef4a63b72acaeac87d7a7777d580bfb0fc0d0a
4
- data.tar.gz: 2b23bf8808241eb64396672959a86ae86c26af05bb0d72049de646907cc9e99d
3
+ metadata.gz: c47c7cb9266d0541b6d57aeba3c25f311f1e8ba6b661c2b79ddc0aea6fe5fe24
4
+ data.tar.gz: 8c34eae2688944b938ee4adfe2c07ae2730571536ff328dc41368add95667410
5
5
  SHA512:
6
- metadata.gz: 37a5b065506fb61a675a72023057b0d24254ea7fa411ea358c68e255e0ffabf53079ab3a7214be59b2d09b02063aa2c09875576868a213df8c91526f90834ebd
7
- data.tar.gz: fb0e3b15f73e8de66b56d09c7791b8e357477c859b83293474ec48c15d41d12298f91c974bd8ebeb0ae2cc6a3437ed598c442e6eb26e8a13787e71a3d35856f7
6
+ metadata.gz: a869a0dcfb3a2c8f0eaa9f7f4979ebf71f8af893b3792de15a4db6c8a2f9852f9f7a42474580468cdb3b9cac50f08ec0a2525627551fc731d715b70ac1c3d03c
7
+ data.tar.gz: ca9179c01ab0a824b07be2923578b51489d91ccd2e9076ba2da44d789f2aef666cffd77d481b85577c0265f8f7bc0f0b33fe028ba535007928bb7489b21c504c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.4] - 2026-10-28
4
+
5
+ Internal refactoring of the control plane. No intended public API or wire
6
+ behaviour changes.
7
+
8
+ ### Changed
9
+
10
+ - Centralized pool option defaults and validation.
11
+ - Split driver, pool, request, SQL-guard, and type-map operations into smaller
12
+ focused methods and removed redundant repeated lookups.
13
+
14
+ ### Fixed
15
+
16
+ - Readable driver events now trigger result draining.
17
+
18
+ ## [0.3.3] - 2026-08-19
19
+
20
+ Opt-in typed result decoding for multiplexed prepared statements. Default
21
+ behaviour, Sync-per-unit, and the failure model are unchanged.
22
+
23
+ ### Added
24
+
25
+ - `Client#prepare(..., typed: true)` — text format. The first `TUPLES_OK`
26
+ result builds a `TypeMapByColumn`; later dispatches reuse that map from the
27
+ `Request` (resolved at submit, never re-read from handle state at drain).
28
+ Column values arrive as typed Ruby objects (Integer, Time, BigDecimal, …)
29
+ instead of strings, for every PostgreSQL type ruby-pg covers in text mode.
30
+
31
+ ### Notes
32
+
33
+ - This is an API feature, not a throughput lever. Typed columns that become
34
+ immediates (`int`, `bool`) drop one heap object each; the rest of the
35
+ per-query allocation is the control plane (`Request`, fibers, `PG::Result`).
36
+ - `CoderMapsBundle` is built on a throwaway connection at the first
37
+ `prepare(..., typed: true)`, never at `Client.start` and never on a live
38
+ pipeline FIFO (a catalog query there desyncs Sync). Untyped `query` /
39
+ `prepare` do not touch type maps on the drain path.
40
+ - A failed type-map apply is a request-local `QueryError`. `typed: true`
41
+ promised a typed value; the handle does not silently fall back to strings.
42
+ - `bigdecimal` is an optional dependency of ruby-pg for `numeric` text
43
+ decoding (required as a gem on Ruby 3.4+). Without it, other typed columns
44
+ still work; `numeric` follows the registry.
45
+ - Default path (no `typed:`): `TypeMapAllStrings`, text format, string keys.
46
+ - Binary result format is not part of this release.
47
+
3
48
  ## [0.3.2] - 2026-08-17
4
49
 
5
50
  Runtime correctness. Public SQL, Sync-per-unit, failure model, and the
data/README.md CHANGED
@@ -143,6 +143,27 @@ ensure
143
143
  end
144
144
  ```
145
145
 
146
+ Pass `typed: true` to decode cells through ruby-pg's text type map (`Integer`,
147
+ `Time`, `BigDecimal`, …) instead of strings. The map is built from the first
148
+ result and reused. This does not change the wire format or the Sync-per-unit
149
+ model; it is opt-in convenience, not a faster path.
150
+
151
+ ```ruby
152
+ by_id = db.prepare(
153
+ "user_by_id",
154
+ "SELECT id, email FROM users WHERE id = $1",
155
+ [23],
156
+ typed: true
157
+ )
158
+
159
+ result = by_id.query([42])
160
+ begin
161
+ result.first["id"] # => 42 (Integer)
162
+ ensure
163
+ result.clear
164
+ end
165
+ ```
166
+
146
167
  `Client#prepare` returns only after the statement is ready on every currently
147
168
  live pipeline connection. Replacement connections automatically prepare the
148
169
  registered catalog before they begin accepting requests. The logical name is
@@ -1,17 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "errors"
4
- require_relative "runtime"
3
+ require_relative 'errors'
4
+ require_relative 'runtime'
5
5
 
6
6
  module PgPipeline
7
7
  class BoundedQueue
8
8
  def initialize(limit)
9
9
  @limit = Integer(limit)
10
- raise ArgumentError, "limit must be >= 1" if @limit < 1
10
+ raise ArgumentError, 'limit must be >= 1' if @limit < 1
11
11
  rescue ArgumentError, TypeError
12
- raise ArgumentError, "limit must be an integer >= 1"
12
+ raise ArgumentError, 'limit must be an integer >= 1'
13
13
  else
14
- @items, @consumers, @producers = [], [], []
14
+ @items = []
15
+ @consumers = []
16
+ @producers = []
15
17
 
16
18
  @closed = false
17
19
  @close_error = nil
@@ -93,7 +95,7 @@ module PgPipeline
93
95
  end
94
96
 
95
97
  def raise_close_error
96
- error = @close_error || ShutdownError.new("queue closed")
98
+ error = @close_error || ShutdownError.new('queue closed')
97
99
  raise error.class, error.message
98
100
  end
99
101
  end
@@ -1,19 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "errors"
4
- require_relative "pool"
5
- require_relative "request"
6
- require_relative "prepared_statement"
7
- require_relative "session_guard"
8
- require_relative "session"
9
- require_relative "transaction"
3
+ require_relative 'errors'
4
+ require_relative 'pool'
5
+ require_relative 'request'
6
+ require_relative 'prepared_statement'
7
+ require_relative 'session_guard'
8
+ require_relative 'session'
9
+ require_relative 'transaction'
10
10
 
11
11
  module PgPipeline
12
12
  class Client
13
13
  attr_reader :guard
14
14
 
15
15
  def initialize(connection_args = nil, guard: :default, **pool_opts)
16
- @owner_thread, @scheduler = nil, nil
16
+ @owner_thread = nil
17
+ @scheduler = nil
17
18
 
18
19
  @guard = SessionGuard.normalize_mode!(guard)
19
20
  @pool = Pool.new(connection_args, **pool_opts)
@@ -26,7 +27,11 @@ module PgPipeline
26
27
 
27
28
  def start = ClientOps.start(self)
28
29
  def query(sql, params = RequestOps::EMPTY_PARAMS) = ClientOps.query(self, sql, params)
29
- def prepare(name, sql, param_types = nil) = ClientOps.prepare(self, name, sql, param_types)
30
+
31
+ def prepare(name, sql, param_types = nil, typed: false)
32
+ ClientOps.prepare(self, name, sql, param_types, typed: typed)
33
+ end
34
+
30
35
  def stats = ClientOps.stats(self)
31
36
 
32
37
  def session(&block)
@@ -42,9 +47,8 @@ module PgPipeline
42
47
 
43
48
  private
44
49
 
45
- attr_reader :pool, :owner_thread, :scheduler
46
- attr_accessor :started
47
- attr_writer :owner_thread, :scheduler
50
+ attr_accessor :owner_thread, :scheduler, :started
51
+ attr_reader :pool
48
52
  end
49
53
 
50
54
  module ClientOps
@@ -54,16 +58,18 @@ module PgPipeline
54
58
  client = Client.new(connection_args, **opts).start
55
59
  yield client
56
60
  ensure
57
- client.close if client
61
+ client&.close
58
62
  end
59
63
 
60
64
  def start(client)
61
- raise Error, "client already started" if started?(client)
62
- raise Error, "client start requires an active Fiber scheduler" unless Fiber.scheduler
65
+ raise Error, 'client already started' if started?(client)
66
+
67
+ scheduler = Fiber.scheduler
68
+ raise Error, 'client start requires an active Fiber scheduler' unless scheduler
63
69
 
64
70
  pool(client).start
65
71
  client.__send__(:owner_thread=, Thread.current)
66
- client.__send__(:scheduler=, Fiber.scheduler)
72
+ client.__send__(:scheduler=, scheduler)
67
73
  client.__send__(:started=, true)
68
74
  client
69
75
  end
@@ -78,18 +84,22 @@ module PgPipeline
78
84
  end
79
85
  end
80
86
 
81
- def prepare(client, name, sql, param_types)
87
+ def prepare(client, name, sql, param_types, typed: false)
82
88
  ensure_started!(client)
83
89
  sql = RequestOps.snapshot_sql(sql)
84
90
  SessionGuard.assert_multiplexable_normalized!(sql, mode: client.guard)
85
- pool(client).__send__(:prepare_statement, client, name, sql, param_types)
91
+ pool(client).__send__(:prepare_statement, client, name, sql, param_types, typed: typed)
86
92
  end
87
93
 
88
94
  def query_prepared(client, statement, params)
89
95
  ensure_started!(client)
90
96
 
91
97
  wait_for_request do
92
- submit_with_failover(client) { Request.prepared_query(statement, params: params) }
98
+ submit_with_failover(client) do
99
+ request = Request.prepared_query(statement, params: params)
100
+ pool(client).bind_type_map(request, statement) if statement.typed?
101
+ request
102
+ end
93
103
  end
94
104
  end
95
105
 
@@ -102,26 +112,38 @@ module PgPipeline
102
112
 
103
113
  def submit_with_failover(client)
104
114
  attempts = 0
105
- limit = [pool(client).pipeline_size, 1].max
115
+ client_pool = pool(client)
116
+ limit = [client_pool.pipeline_size, 1].max
106
117
  last_error = nil
107
118
 
108
119
  while attempts < limit
109
120
  request = yield
110
- begin
111
- pool(client).__send__(:pipeline_driver).submit(request)
112
- return request
113
- rescue NotDispatchedError => e
114
- last_error = e
115
- attempts += 1
116
- rescue ShutdownError => e
117
- last_error = e
118
- attempts += 1
119
- break unless request.state == :new && !request.settled?
120
- end
121
+ last_error = submit_attempt(client_pool, request)
122
+ return request unless last_error
123
+
124
+ attempts += 1
125
+ break if stop_retrying?(request, last_error)
121
126
  end
122
127
 
123
- error = last_error || NotDispatchedError.new("no live pipeline connections; request was not dispatched")
124
- raise(error.is_a?(NotDispatchedError) ? error : NotDispatchedError.new(error.message))
128
+ raise_submission_error(last_error)
129
+ end
130
+
131
+ def submit_attempt(client_pool, request)
132
+ client_pool.__send__(:pipeline_driver).submit(request)
133
+ nil
134
+ rescue NotDispatchedError, ShutdownError => e
135
+ e
136
+ end
137
+
138
+ def stop_retrying?(request, error)
139
+ error.is_a?(ShutdownError) && (request.state != :new || request.settled?)
140
+ end
141
+
142
+ def raise_submission_error(error)
143
+ error ||= NotDispatchedError.new('no live pipeline connections; request was not dispatched')
144
+ raise error if error.is_a?(NotDispatchedError)
145
+
146
+ raise NotDispatchedError, error.message
125
147
  end
126
148
 
127
149
  def session(client)
@@ -137,13 +159,13 @@ module PgPipeline
137
159
  end
138
160
  end
139
161
 
140
- def transaction(client)
162
+ def transaction(client, &block)
141
163
  ensure_started!(client)
142
164
 
143
165
  pool(client).__send__(:with_pinned) do |conn|
144
166
  tx = Transaction.new(conn)
145
167
  begin
146
- TransactionOps.run(tx) { |transaction| yield transaction }
168
+ TransactionOps.run(tx, &block)
147
169
  ensure
148
170
  SessionOps.close!(tx)
149
171
  end
@@ -159,10 +181,11 @@ module PgPipeline
159
181
  return unless started?(client)
160
182
 
161
183
  ensure_context!(client)
184
+ client_pool = pool(client)
162
185
  begin
163
- pool(client).graceful_close
186
+ client_pool.graceful_close
164
187
  ensure
165
- client.__send__(:started=, false) if pool(client).closing?
188
+ client.__send__(:started=, false) if client_pool.closing?
166
189
  end
167
190
  end
168
191
 
@@ -170,10 +193,11 @@ module PgPipeline
170
193
  return unless started?(client)
171
194
 
172
195
  ensure_context!(client)
196
+ client_pool = pool(client)
173
197
  begin
174
- pool(client).abort!
198
+ client_pool.abort!
175
199
  ensure
176
- client.__send__(:started=, false) if pool(client).closing?
200
+ client.__send__(:started=, false) if client_pool.closing?
177
201
  end
178
202
  end
179
203
 
@@ -182,7 +206,7 @@ module PgPipeline
182
206
  end
183
207
 
184
208
  def ensure_started!(client)
185
- raise Error, "client not started; call #start or use Client.open" unless started?(client)
209
+ raise Error, 'client not started; call #start or use Client.open' unless started?(client)
186
210
 
187
211
  ensure_context!(client)
188
212
  end
@@ -191,7 +215,7 @@ module PgPipeline
191
215
  return if Thread.current.equal?(client.__send__(:owner_thread)) &&
192
216
  Fiber.scheduler.equal?(client.__send__(:scheduler))
193
217
 
194
- raise Error, "client is reactor-local and cannot be used from another thread/scheduler"
218
+ raise Error, 'client is reactor-local and cannot be used from another thread/scheduler'
195
219
  end
196
220
 
197
221
  def started?(client)