pg_pipeline 0.3.1 → 0.3.3

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: ca8a6402d9ee49ae0393f519c135f4174248df64aa27332f0773e095eb3179c8
4
- data.tar.gz: 3c01ed0c9be6950a3b4d264bc613e5fef94c56dc81cb8916c791902ffeac61b0
3
+ metadata.gz: f3bb4583fbf47130e8b86eec8f44c70577f053c9cacd1e9774a09a5b7a5d3999
4
+ data.tar.gz: '099f44877fde9db5faa63a776e31ecb6d1b56cc155dd08fd0b6dff52252a719e'
5
5
  SHA512:
6
- metadata.gz: dc55189c229e3958e40781e30577ac82c3645f70fa55c2bce894baf9e0a423caa67d3ba533f77d5fce627943b424e0c7de2cea5f99a2ea25b0903022f438b3df
7
- data.tar.gz: 508c35a99f8a9f5f3fa3665b741adaf58f593b6cc6c95fbfb86b48ded121adb010d4203fce6d9063274e510171e9f401ed0295b8691f2211ce273395cd7dcb9d
6
+ metadata.gz: ddc6fa628e441b5cf744a3521e0ca90a923cf732be59662c6cee9df5f522ad69319628d6ee98e18711baa28d8d42eef7585c0af6239cb9a8f4f921212338d014
7
+ data.tar.gz: 75e56315d776d60cadf07b675e048fdb674f06bc8b67fe960e95be4d7ed8bd89008eb25a8828eb96655994ed83fa23f392a541a73b77dbc4f113dc28261c815a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.3] - 2026-08-19
4
+
5
+ Opt-in typed result decoding for multiplexed prepared statements. Default
6
+ behaviour, Sync-per-unit, and the failure model are unchanged.
7
+
8
+ ### Added
9
+
10
+ - `Client#prepare(..., typed: true)` — text format. The first `TUPLES_OK`
11
+ result builds a `TypeMapByColumn`; later dispatches reuse that map from the
12
+ `Request` (resolved at submit, never re-read from handle state at drain).
13
+ Column values arrive as typed Ruby objects (Integer, Time, BigDecimal, …)
14
+ instead of strings, for every PostgreSQL type ruby-pg covers in text mode.
15
+
16
+ ### Notes
17
+
18
+ - This is an API feature, not a throughput lever. Typed columns that become
19
+ immediates (`int`, `bool`) drop one heap object each; the rest of the
20
+ per-query allocation is the control plane (`Request`, fibers, `PG::Result`).
21
+ - `CoderMapsBundle` is built on a throwaway connection at the first
22
+ `prepare(..., typed: true)`, never at `Client.start` and never on a live
23
+ pipeline FIFO (a catalog query there desyncs Sync). Untyped `query` /
24
+ `prepare` do not touch type maps on the drain path.
25
+ - A failed type-map apply is a request-local `QueryError`. `typed: true`
26
+ promised a typed value; the handle does not silently fall back to strings.
27
+ - `bigdecimal` is an optional dependency of ruby-pg for `numeric` text
28
+ decoding (required as a gem on Ruby 3.4+). Without it, other typed columns
29
+ still work; `numeric` follows the registry.
30
+ - Default path (no `typed:`): `TypeMapAllStrings`, text format, string keys.
31
+ - Binary result format is not part of this release.
32
+
33
+ ## [0.3.2] - 2026-08-17
34
+
35
+ Runtime correctness. Public SQL, Sync-per-unit, failure model, and the
36
+ `Fiber::Scheduler` host contract are unchanged.
37
+
38
+ ### Fixed
39
+
40
+ - Bounded waits (`Notification#wait`, `Task#wait`) park through
41
+ `scheduler.block(blocker, remaining)` and a monotonic deadline. They no
42
+ longer wrap `park` in `Timeout.timeout`. A wait timeout is not a work
43
+ timeout: `Notification#wait` no longer rescues `TimeoutError`, so an
44
+ enclosing `Runtime.with_timeout` cannot be swallowed by
45
+ `BoundedQueue#wait_on` or `wait_for_pinned_idle`. `wait(0)` polls.
46
+ - `Runtime.with_timeout` (health probes and other work limits) calls
47
+ `scheduler.timeout_after(duration, Deadline, message)` when the hook
48
+ exists. `Deadline < Exception`, so `rescue => e` cannot eat it; the
49
+ method still raises `Runtime::TimeoutError` at its own boundary. A
50
+ scheduler without the hook falls back to stdlib `Timeout` and warns
51
+ once — `Thread#raise` can hit the wrong fiber.
52
+ - `Notification#signal` and `Semaphore#release` skip a waiter that
53
+ `Task#stop` already released, or a fiber that is no longer alive. On a
54
+ deferred `unblock` the previous `shift` + wake handed the signal to the
55
+ stopped waiter and left the live one parked; a dead semaphore waiter
56
+ burned a pinned-pool slot. `park` also raises `Cancel` after a stop-wake
57
+ so the wait is not mistaken for a successful signal.
58
+
59
+ ### Unchanged
60
+
61
+ - Per-fiber waiter-hash reuse, `Request` one-shot completion, watcher
62
+ `fiber_interrupt` / poll shutdown, and the host-installed scheduler
63
+ rule.
64
+
3
65
  ## [0.3.1] - 2026-08-09
4
66
 
5
67
  Reliability and correctness fixes on top of 0.3.0. No public API changes other
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
@@ -26,7 +26,9 @@ module PgPipeline
26
26
 
27
27
  def start = ClientOps.start(self)
28
28
  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)
29
+ def prepare(name, sql, param_types = nil, typed: false)
30
+ ClientOps.prepare(self, name, sql, param_types, typed: typed)
31
+ end
30
32
  def stats = ClientOps.stats(self)
31
33
 
32
34
  def session(&block)
@@ -78,18 +80,22 @@ module PgPipeline
78
80
  end
79
81
  end
80
82
 
81
- def prepare(client, name, sql, param_types)
83
+ def prepare(client, name, sql, param_types, typed: false)
82
84
  ensure_started!(client)
83
85
  sql = RequestOps.snapshot_sql(sql)
84
86
  SessionGuard.assert_multiplexable_normalized!(sql, mode: client.guard)
85
- pool(client).__send__(:prepare_statement, client, name, sql, param_types)
87
+ pool(client).__send__(:prepare_statement, client, name, sql, param_types, typed: typed)
86
88
  end
87
89
 
88
90
  def query_prepared(client, statement, params)
89
91
  ensure_started!(client)
90
92
 
91
93
  wait_for_request do
92
- submit_with_failover(client) { Request.prepared_query(statement, params: params) }
94
+ submit_with_failover(client) do
95
+ request = Request.prepared_query(statement, params: params)
96
+ pool(client).bind_type_map(request, statement) if statement.typed?
97
+ request
98
+ end
93
99
  end
94
100
  end
95
101
 
@@ -13,7 +13,7 @@ module PgPipeline
13
13
  DEFAULT_MAX_PENDING = 256
14
14
  DEFAULT_MAX_IN_FLIGHT = 64
15
15
 
16
- attr_reader :conn, :caps, :max_pending, :max_in_flight
16
+ attr_reader :conn, :caps, :max_pending, :max_in_flight, :type_maps
17
17
  attr_accessor :socket, :requests, :events, :reader_rearm, :writer_commands,
18
18
  :inflight, :dispatching, :submitting, :accepting, :running,
19
19
  :draining, :needs_flush, :writer_armed, :request_event_pending,
@@ -30,11 +30,13 @@ module PgPipeline
30
30
  def dispatches = @dispatches || 0
31
31
  def leaked_watchers = @leaked_watchers || 0
32
32
 
33
- def initialize(conn, max_pending: DEFAULT_MAX_PENDING, max_in_flight: DEFAULT_MAX_IN_FLIGHT)
33
+ def initialize(conn, max_pending: DEFAULT_MAX_PENDING, max_in_flight: DEFAULT_MAX_IN_FLIGHT,
34
+ type_maps: nil)
34
35
  @max_pending = DriverOps.positive_integer!(max_pending, :max_pending)
35
36
  @max_in_flight = DriverOps.positive_integer!(max_in_flight, :max_in_flight)
36
37
 
37
38
  @conn = conn
39
+ @type_maps = type_maps
38
40
  @caps = ServerCaps.from_connection(conn)
39
41
  @caps.assert_supported!
40
42
  DriverOps.warn_flush_coupling_once(@caps)
@@ -387,45 +389,61 @@ module PgPipeline
387
389
  request = d.inflight.first
388
390
  raise ProtocolError, "result without an in-flight request" unless request
389
391
 
390
- if result.nil?
391
- request.query_boundary!
392
- next
393
- end
394
-
395
- status = result.result_status
396
- case status
397
- when PG::PGRES_TUPLES_OK
398
- ensure_before_query_boundary!(request, status)
399
- request.accept_result(result)
400
- when PG::PGRES_PIPELINE_SYNC
401
- clear_result(result)
402
- complete_front(d, request)
403
- when PG::PGRES_COMMAND_OK, PG::PGRES_EMPTY_QUERY
404
- ensure_before_query_boundary!(request, status)
405
- request.accept_result(result)
406
- when PG::PGRES_FATAL_ERROR
407
- ensure_before_query_boundary!(request, status)
408
- request.record_error!(query_error(result), result: result)
409
- when PG::PGRES_PIPELINE_ABORTED
410
- ensure_before_query_boundary!(request, status)
411
- clear_result(result)
412
- request.record_error!(PipelineAbortedError.new("pipeline unit aborted"))
413
- when PG::PGRES_BAD_RESPONSE
414
- clear_result(result)
415
- raise ProtocolError, "server response was not understood"
416
- when PG::PGRES_COPY_IN, PG::PGRES_COPY_OUT, PG::PGRES_COPY_BOTH
417
- clear_result(result)
418
- raise ProtocolError, "COPY is not supported on the multiplexed pipeline"
419
- else
420
- clear_result(result)
421
- raise ProtocolError, "unexpected pipeline result status #{status}"
422
- end
392
+ handle_pipeline_result(d, request, result)
423
393
  end
424
394
  ensure
425
395
  d.results_read += read if read.positive?
426
396
  end
427
397
  end
428
398
 
399
+ def handle_pipeline_result(d, request, result)
400
+ if result.nil?
401
+ request.query_boundary!
402
+ return
403
+ end
404
+
405
+ status = result.result_status
406
+ case status
407
+ when PG::PGRES_TUPLES_OK
408
+ accept_tuples_ok(d, request, result)
409
+ when PG::PGRES_PIPELINE_SYNC
410
+ clear_result(result)
411
+ complete_front(d, request)
412
+ when PG::PGRES_COMMAND_OK, PG::PGRES_EMPTY_QUERY
413
+ ensure_before_query_boundary!(request, status)
414
+ request.accept_result(result)
415
+ when PG::PGRES_FATAL_ERROR
416
+ ensure_before_query_boundary!(request, status)
417
+ request.record_error!(query_error(result), result: result)
418
+ when PG::PGRES_PIPELINE_ABORTED
419
+ ensure_before_query_boundary!(request, status)
420
+ clear_result(result)
421
+ request.record_error!(PipelineAbortedError.new("pipeline unit aborted"))
422
+ when PG::PGRES_BAD_RESPONSE
423
+ reject_pipeline_status!(result, "server response was not understood")
424
+ when PG::PGRES_COPY_IN, PG::PGRES_COPY_OUT, PG::PGRES_COPY_BOTH
425
+ reject_pipeline_status!(result, "COPY is not supported on the multiplexed pipeline")
426
+ else
427
+ reject_pipeline_status!(result, "unexpected pipeline result status #{status}")
428
+ end
429
+ end
430
+
431
+ def accept_tuples_ok(d, request, result)
432
+ ensure_before_query_boundary!(request, PG::PGRES_TUPLES_OK)
433
+ begin
434
+ apply_type_map(d, request, result)
435
+ request.accept_result(result)
436
+ rescue StandardError => e
437
+ message = e.is_a?(Error) ? e.message : "typed result mapping failed: #{e.class}: #{e.message}"
438
+ request.record_error!(QueryError.new(message, cause_result: result), result: result)
439
+ end
440
+ end
441
+
442
+ def reject_pipeline_status!(result, message)
443
+ clear_result(result)
444
+ raise ProtocolError, message
445
+ end
446
+
429
447
  def ensure_before_query_boundary!(request, status)
430
448
  return unless request.query_boundary_seen?
431
449
 
@@ -644,6 +662,15 @@ module PgPipeline
644
662
  nil
645
663
  end
646
664
 
665
+ def apply_type_map(d, request, result)
666
+ return unless request.type_map
667
+
668
+ maps = d.type_maps
669
+ raise Error, "typed statement has no type-map registry" unless maps
670
+
671
+ maps.apply!(request, result, d.conn)
672
+ end
673
+
647
674
  def clear_result(result)
648
675
  result.clear if result.respond_to?(:clear)
649
676
  end
@@ -6,6 +6,7 @@ require_relative "errors"
6
6
  require_relative "runtime"
7
7
  require_relative "connection_driver"
8
8
  require_relative "prepared_statement"
9
+ require_relative "type_maps"
9
10
  require_relative "server_caps"
10
11
 
11
12
  module PgPipeline
@@ -64,6 +65,7 @@ module PgPipeline
64
65
  @prepared_statements = {}
65
66
  @prepared_generation = 0
66
67
  @statement_sequence = 0
68
+ @type_maps = TypeMaps.new
67
69
 
68
70
  @pinned_free = []
69
71
  @pinned_in_use = {}
@@ -113,59 +115,90 @@ module PgPipeline
113
115
  driver
114
116
  end
115
117
 
116
- def prepare_statement(client, name, sql, param_types)
118
+ def bind_type_map(request, statement)
119
+ return unless statement.typed?
120
+
121
+ maps = @type_maps
122
+ return unless maps
123
+
124
+ maps.bind(request, statement)
125
+ end
126
+
127
+ def prepare_statement(client, name, sql, param_types, typed: false)
117
128
  ensure_available!
118
129
  logical_name = PreparedStatementOps.snapshot_name(name)
119
130
  if @prepared_statements.key?(logical_name)
120
131
  raise Error, "prepared statement #{logical_name.inspect} already exists"
121
132
  end
122
133
 
134
+ statement = register_prepared_statement(client, logical_name, sql, param_types, typed)
135
+ requests = []
136
+
137
+ begin
138
+ requests = dispatch_prepare(statement)
139
+ await_prepares!(requests)
140
+ statement
141
+ rescue Exception
142
+ drop_prepared_statement(logical_name, statement)
143
+ raise
144
+ ensure
145
+ requests.each { |request| request.cancel! unless request.settled? }
146
+ end
147
+ end
148
+
149
+ def register_prepared_statement(client, logical_name, sql, param_types, typed)
123
150
  @statement_sequence += 1
124
151
  statement = PreparedStatement.new(
125
152
  client: client,
126
153
  name: logical_name,
127
154
  physical_name: "pgp_#{@statement_sequence.to_s(36)}",
128
155
  sql: sql,
129
- param_types: param_types
156
+ param_types: param_types,
157
+ typed: typed
130
158
  )
131
159
 
132
160
  @prepared_statements[logical_name] = statement
161
+ register_typed_maps(statement) if statement.typed?
133
162
  @prepared_generation += 1
134
- requests = []
163
+ statement
164
+ end
135
165
 
136
- begin
137
- drivers = @drivers.select(&:available?)
138
- if drivers.empty?
139
- raise NotDispatchedError, "no live pipeline connections; statement was not prepared"
140
- end
166
+ def register_typed_maps(statement)
167
+ @type_maps ||= TypeMaps.new
168
+ @type_maps.register(statement)
169
+ @type_maps.ensure_bundle!(@connection_args)
170
+ end
141
171
 
142
- requests = drivers.map do |driver|
143
- Request.prepare(statement).tap { |request| driver.submit(request) }
144
- end
172
+ def dispatch_prepare(statement)
173
+ drivers = @drivers.select(&:available?)
174
+ if drivers.empty?
175
+ raise NotDispatchedError, "no live pipeline connections; statement was not prepared"
176
+ end
145
177
 
146
- first_error = nil
147
- requests.each do |request|
148
- begin
149
- RequestOps.clear_result(request.wait)
150
- rescue StandardError => e
151
- if first_error
152
- e.clear_result! if e.respond_to?(:clear_result!)
153
- else
154
- first_error = e
155
- end
156
- end
157
- end
158
- raise first_error if first_error
178
+ drivers.map { |driver| Request.prepare(statement).tap { |r| driver.submit(r) } }
179
+ end
159
180
 
160
- statement
161
- rescue Exception
162
- if @prepared_statements.delete(logical_name)
163
- @prepared_generation += 1
181
+ def await_prepares!(requests)
182
+ first_error = nil
183
+ requests.each do |request|
184
+ begin
185
+ RequestOps.clear_result(request.wait)
186
+ rescue StandardError => e
187
+ if first_error
188
+ e.clear_result! if e.respond_to?(:clear_result!)
189
+ else
190
+ first_error = e
191
+ end
164
192
  end
165
- raise
166
- ensure
167
- requests.each { |request| request.cancel! unless request.settled? }
168
193
  end
194
+ raise first_error if first_error
195
+ end
196
+
197
+ def drop_prepared_statement(logical_name, statement)
198
+ return unless @prepared_statements.delete(logical_name)
199
+
200
+ @type_maps.unregister(statement.physical_name) if statement&.typed? && @type_maps
201
+ @prepared_generation += 1
169
202
  end
170
203
 
171
204
  def with_pinned
@@ -174,31 +207,11 @@ module PgPipeline
174
207
  raise Error, "pinned pool is disabled (pinned_size=0)" if @pinned_size.zero?
175
208
 
176
209
  owner = Fiber.current
177
- if @pinned_owners[owner].positive?
178
- raise RecursiveCheckoutError,
179
- "nested Client#session/transaction on the same fiber is not allowed; " \
180
- "use Transaction#savepoint for nested atomicity"
181
- end
210
+ assert_not_nested_pinned!(owner)
182
211
 
183
212
  @pinned_gate.acquire do
184
- raise @pinned_error if @pinned_error
185
- raise ShutdownError, "pool is closing" if @closing
186
-
187
- conn = nil
188
- @pinned_active += 1
189
- @pinned_owners[owner] += 1
190
-
191
- begin
192
- conn = @pinned_free.pop || PoolOps.new_connection(@connection_args)
193
- raise @pinned_error if @pinned_error
194
- raise ShutdownError, "pool is closing" if @closing
195
-
196
- @pinned_in_use[conn] = owner
197
- yield conn
198
- ensure
199
- @pinned_in_use.delete(conn) if conn
200
- release_pinned(owner, conn)
201
- end
213
+ assert_pinned_open!
214
+ run_pinned(owner) { |conn| yield conn }
202
215
  end
203
216
  end
204
217
 
@@ -453,7 +466,7 @@ module PgPipeline
453
466
  def start_pipeline_driver
454
467
  conn = PoolOps.new_connection(@connection_args)
455
468
  prepare_registered_statements(conn)
456
- driver = ConnectionDriver.new(conn, max_pending: @max_pending, max_in_flight: @max_in_flight)
469
+ driver = ConnectionDriver.new(conn, max_pending: @max_pending, max_in_flight: @max_in_flight, type_maps: @type_maps)
457
470
  driver.start
458
471
  rescue Exception
459
472
  PoolOps.safe_close(conn) if conn
@@ -471,10 +484,11 @@ module PgPipeline
471
484
  next if prepared.key?(statement.physical_name)
472
485
 
473
486
  result = if statement.param_types.nil?
474
- conn.prepare(statement.physical_name, statement.sql)
475
- else
476
- conn.prepare(statement.physical_name, statement.sql, statement.param_types)
477
- end
487
+ conn.prepare(statement.physical_name, statement.sql)
488
+ else
489
+ conn.prepare(statement.physical_name, statement.sql, statement.param_types)
490
+ end
491
+
478
492
  RequestOps.clear_result(result)
479
493
  prepared[statement.physical_name] = true
480
494
  end
@@ -483,22 +497,41 @@ module PgPipeline
483
497
  end
484
498
  end
485
499
 
486
- def release_pinned(owner, conn)
487
- if conn
488
- if @closing
489
- PoolOps.safe_close(conn)
490
- else
491
- recycled = recycle_pinned_connection(conn)
492
-
493
- if recycled
494
- if @closing
495
- PoolOps.safe_close(recycled)
496
- else
497
- @pinned_free << recycled
498
- end
499
- end
500
- end
500
+ def assert_not_nested_pinned!(owner)
501
+ return unless @pinned_owners[owner].positive?
502
+
503
+ raise RecursiveCheckoutError,
504
+ "nested Client#session/transaction on the same fiber is not allowed; " \
505
+ "use Transaction#savepoint for nested atomicity"
506
+ end
507
+
508
+ def assert_pinned_open!
509
+ raise @pinned_error if @pinned_error
510
+ raise ShutdownError, "pool is closing" if @closing
511
+ end
512
+
513
+ def run_pinned(owner)
514
+ conn = nil
515
+ @pinned_active += 1
516
+ @pinned_owners[owner] += 1
517
+
518
+ begin
519
+ conn = take_pinned_connection
520
+ assert_pinned_open!
521
+ @pinned_in_use[conn] = owner
522
+ yield conn
523
+ ensure
524
+ @pinned_in_use.delete(conn) if conn
525
+ release_pinned(owner, conn)
501
526
  end
527
+ end
528
+
529
+ def take_pinned_connection
530
+ @pinned_free.pop || PoolOps.new_connection(@connection_args)
531
+ end
532
+
533
+ def release_pinned(owner, conn)
534
+ return_pinned_connection(conn) if conn
502
535
  ensure
503
536
  @pinned_active -= 1
504
537
  @pinned_owners[owner] -= 1
@@ -506,6 +539,22 @@ module PgPipeline
506
539
  @pinned_idle.signal if @pinned_active.zero?
507
540
  end
508
541
 
542
+ def return_pinned_connection(conn)
543
+ if @closing
544
+ PoolOps.safe_close(conn)
545
+ return
546
+ end
547
+
548
+ recycled = recycle_pinned_connection(conn)
549
+ return unless recycled
550
+
551
+ if @closing
552
+ PoolOps.safe_close(recycled)
553
+ else
554
+ @pinned_free << recycled
555
+ end
556
+ end
557
+
509
558
  def recycle_pinned_connection(conn)
510
559
  if conn.server_version < DISCARD_SEQUENCES_SERVER_VERSION
511
560
  PoolOps.safe_close(conn)
@@ -602,9 +651,7 @@ module PgPipeline
602
651
 
603
652
  start = rr % size
604
653
  best = nil
605
- best_index = 0
606
- best_load = 0
607
- offset = 0
654
+ best_index, best_load, offset = 0, 0, 0
608
655
 
609
656
  while offset < size
610
657
  index = start + offset
@@ -5,22 +5,26 @@ require_relative "request"
5
5
 
6
6
  module PgPipeline
7
7
  class PreparedStatement
8
- attr_reader :name, :sql, :param_types, :physical_name
8
+ attr_reader :name, :sql, :param_types, :physical_name, :typed
9
9
 
10
- def initialize(client:, name:, physical_name:, sql:, param_types: nil)
10
+ def initialize(client:, name:, physical_name:, sql:, param_types: nil, typed: false)
11
11
  @client = client
12
12
  @name = PreparedStatementOps.snapshot_name(name)
13
13
  @physical_name = PreparedStatementOps.snapshot_name(physical_name)
14
14
  @sql = RequestOps.snapshot_sql(sql)
15
15
  @param_types = RequestOps.snapshot_param_types(param_types)
16
+ @typed = !!typed
16
17
  freeze
17
18
  end
18
19
 
20
+ def typed? = @typed
21
+
19
22
  def query(params = RequestOps::EMPTY_PARAMS) = PreparedStatementOps.query(self, params)
20
23
  alias call query
21
24
 
22
25
  def inspect
23
- "#<#{self.class} name=#{@name.inspect} sql=#{@sql.inspect}>"
26
+ flag = @typed ? " typed" : ""
27
+ "#<#{self.class} name=#{@name.inspect} sql=#{@sql.inspect}#{flag}>"
24
28
  end
25
29
 
26
30
  private
@@ -42,6 +46,5 @@ module PgPipeline
42
46
 
43
47
  value.frozen? ? value : value.dup.freeze
44
48
  end
45
-
46
49
  end
47
50
  end
@@ -6,7 +6,7 @@ module PgPipeline
6
6
  class Request
7
7
  attr_reader :sql, :params
8
8
  attr_accessor :state, :cancelled, :settled, :result_seen, :query_boundary_seen,
9
- :result, :error, :waiter, :waiter_scheduler
9
+ :result, :error, :waiter, :waiter_scheduler, :type_map
10
10
 
11
11
  def initialize(sql:, params: nil)
12
12
  @sql = RequestOps.snapshot_sql(sql)
@@ -20,6 +20,7 @@ module PgPipeline
20
20
  @error = nil
21
21
  @waiter = nil
22
22
  @waiter_scheduler = nil
23
+ @type_map = nil
23
24
  end
24
25
 
25
26
  def self.build(sql, params)
@@ -64,6 +65,7 @@ module PgPipeline
64
65
  @error = nil
65
66
  @waiter = nil
66
67
  @waiter_scheduler = nil
68
+ @type_map = nil
67
69
  self
68
70
  end
69
71
  end
@@ -7,19 +7,24 @@ module PgPipeline
7
7
  @waiters = []
8
8
  end
9
9
 
10
+ def waiting = @waiters.size
11
+
10
12
  def wait(timeout = nil)
13
+ wait_until(Runtime.deadline_for(timeout))
14
+ end
15
+
16
+ def wait_until(deadline = nil)
11
17
  Runtime.with_waiter(self, @waiters) do |waiter|
12
- Runtime.with_timeout(timeout) { Runtime.park(self, waiter) { false } }
13
- true
14
- rescue TimeoutError
15
- false
18
+ Runtime.park(self, waiter, deadline) { false }
16
19
  end
17
20
  end
18
21
 
19
22
  def signal
20
- waiter = @waiters.shift or return false
21
- Runtime.wake_dequeued(waiter, self)
22
- true
23
+ while (waiter = @waiters.shift)
24
+ return true if Runtime.wake_dequeued(waiter, self)
25
+ end
26
+
27
+ false
23
28
  end
24
29
 
25
30
  def signal_all
@@ -23,12 +23,12 @@ module PgPipeline
23
23
  end
24
24
 
25
25
  def release
26
- if (node = @waiting.shift)
26
+ while (node = @waiting.shift)
27
27
  node.grant!
28
- node.resume
29
- else
30
- @available += 1
28
+ return @available if node.resume
31
29
  end
30
+
31
+ @available += 1
32
32
  @available
33
33
  end
34
34
 
@@ -126,9 +126,10 @@ module PgPipeline
126
126
 
127
127
  def resume
128
128
  fiber = @fiber
129
- return unless fiber.alive?
129
+ return false unless fiber.alive?
130
130
 
131
131
  @scheduler.unblock(@blocker, fiber)
132
+ true
132
133
  end
133
134
  end
134
135
 
@@ -22,7 +22,10 @@ module PgPipeline
22
22
  end
23
23
 
24
24
  def wait(timeout = nil)
25
- Runtime.with_timeout(timeout) { join }
25
+ unless @done
26
+ deadline = Runtime.deadline_for(timeout)
27
+ raise TimeoutError, "task did not finish in time" unless join(deadline)
28
+ end
26
29
  raise @error if @error
27
30
 
28
31
  @result
@@ -94,8 +97,8 @@ module PgPipeline
94
97
 
95
98
  def release_blocker
96
99
  waiter = @blocker or return false
97
- Runtime.wake(waiter, waiter[:blocker])
98
- waiter[:fiber].alive?
100
+
101
+ Runtime.wake(waiter, waiter[:blocker]) && waiter[:fiber].alive?
99
102
  end
100
103
 
101
104
  def complete(result, error)
@@ -107,11 +110,11 @@ module PgPipeline
107
110
  wake_waiters
108
111
  end
109
112
 
110
- def join
111
- return if @done
113
+ def join(deadline)
114
+ return true if @done
112
115
 
113
116
  Runtime.with_waiter(self, @waiters) do |waiter|
114
- Runtime.park(self, waiter) { @done }
117
+ Runtime.park(self, waiter, deadline) { @done }
115
118
  end
116
119
  end
117
120
 
@@ -7,12 +7,25 @@ require_relative "errors"
7
7
  module PgPipeline
8
8
  module Runtime
9
9
  class Cancel < Exception; end
10
+ class Deadline < Exception; end
10
11
  class TimeoutError < Error; end
11
12
 
12
13
  module_function
13
14
 
14
15
  CURRENT_TASK_KEY = :pg_pipeline_current_task
15
16
  WAITER_KEY = :pg_pipeline_waiter
17
+ DEADLINE_MESSAGE = "execution expired"
18
+
19
+ MISSING_TIMEOUT_HOOK_WARNING = <<~MESSAGE
20
+ pg_pipeline: %s does not implement #timeout_after.
21
+
22
+ Falling back to stdlib Timeout, which uses Thread#raise and can deliver
23
+ the timeout to an unrelated fiber. Use a scheduler that implements
24
+ #timeout_after (async, itsi-scheduler) or avoid Runtime.with_timeout
25
+ on this host.
26
+ MESSAGE
27
+
28
+ @warned_schedulers = {}
16
29
 
17
30
  def spawn(name: nil, &block)
18
31
  Task.spawn(name: name, &block)
@@ -22,6 +35,10 @@ module PgPipeline
22
35
  Fiber.scheduler or raise Error, "operation requires an active Fiber scheduler"
23
36
  end
24
37
 
38
+ def native_timeouts?(target = Fiber.scheduler)
39
+ !target.nil? && target.respond_to?(:timeout_after)
40
+ end
41
+
25
42
  def current_task
26
43
  Thread.current[CURRENT_TASK_KEY]
27
44
  end
@@ -44,16 +61,20 @@ module PgPipeline
44
61
  waiter
45
62
  end
46
63
 
47
- def park(blocker, waiter)
64
+ def park(blocker, waiter, deadline = nil)
48
65
  scheduler = waiter[:scheduler]
49
66
  task = current_task
50
67
  task&.enter_block(waiter)
51
68
 
52
69
  until waiter[:ready] || yield
53
- scheduler.block(blocker, nil)
70
+ remaining = deadline ? (deadline - monotonic_now) : nil
71
+ return false if deadline && remaining <= 0
72
+
73
+ scheduler.block(blocker, remaining)
54
74
  task&.raise_if_cancelled!
55
75
  end
56
- nil
76
+ task&.raise_if_cancelled!
77
+ true
57
78
  ensure
58
79
  waiter[:blocker] = nil
59
80
  task&.exit_block
@@ -65,7 +86,10 @@ module PgPipeline
65
86
  waiters << waiter
66
87
  yield waiter
67
88
  ensure
68
- waiters.delete(waiter) if waiter[:queued]
89
+ if waiter[:queued]
90
+ waiters.delete(waiter)
91
+ waiter[:queued] = false
92
+ end
69
93
  end
70
94
 
71
95
  def wake_dequeued(waiter, blocker)
@@ -73,21 +97,59 @@ module PgPipeline
73
97
  wake(waiter, blocker)
74
98
  end
75
99
 
100
+ def wake(waiter, blocker)
101
+ return false if waiter[:ready]
102
+
103
+ waiter[:ready] = true
104
+ fiber = waiter[:fiber]
105
+ return false unless fiber.alive?
106
+
107
+ waiter[:scheduler].unblock(blocker, fiber)
108
+ true
109
+ end
110
+
111
+ def monotonic_now
112
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ end
114
+
115
+ def deadline_for(timeout)
116
+ return nil if timeout.nil?
117
+
118
+ seconds = Float(timeout)
119
+ raise ArgumentError, "timeout must be non-negative and finite" unless seconds.finite? && seconds >= 0
120
+
121
+ monotonic_now + seconds
122
+ end
123
+
76
124
  def with_timeout(duration)
77
125
  return yield if duration.nil?
78
126
 
79
- timeout = Float(duration)
80
- raise ArgumentError, "timeout must be non-negative and finite" unless timeout.finite? && timeout >= 0
127
+ seconds = Float(duration)
128
+ raise ArgumentError, "timeout must be non-negative and finite" unless seconds.finite? && seconds >= 0
81
129
 
82
- ::Timeout.timeout(timeout, TimeoutError) { yield }
130
+ begin
131
+ arm_deadline(seconds) { yield }
132
+ rescue Deadline => e
133
+ raise TimeoutError, e.message
134
+ end
83
135
  end
84
136
 
85
- def wake(waiter, blocker)
86
- waiter[:ready] = true
87
- fiber = waiter[:fiber]
88
- return unless fiber.alive?
137
+ def arm_deadline(seconds, &block)
138
+ target = Fiber.scheduler
139
+ if native_timeouts?(target)
140
+ return target.timeout_after(seconds, Deadline, DEADLINE_MESSAGE, &block)
141
+ end
89
142
 
90
- waiter[:scheduler].unblock(blocker, fiber)
143
+ warn_missing_timeout_hook(target) if target
144
+ ::Timeout.timeout(seconds, Deadline, DEADLINE_MESSAGE, &block)
145
+ end
146
+
147
+ def warn_missing_timeout_hook(target)
148
+ key = target.class
149
+ return if @warned_schedulers[key]
150
+
151
+ @warned_schedulers[key] = true
152
+ warn(format(MISSING_TIMEOUT_HOOK_WARNING, key))
91
153
  end
92
154
  end
93
155
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg"
4
+ require_relative "errors"
5
+
6
+ module PgPipeline
7
+ class TypeMaps
8
+ PENDING = Object.new.freeze
9
+
10
+ def initialize
11
+ @cache = {}
12
+ @wants_typed = {}
13
+ @bundle = nil
14
+ @text_map_for_results = nil
15
+ end
16
+
17
+ def register(statement)
18
+ @wants_typed[statement.physical_name] = true if statement.typed?
19
+ end
20
+
21
+ def unregister(physical_name)
22
+ @cache.delete(physical_name)
23
+ @wants_typed.delete(physical_name)
24
+ end
25
+
26
+ def typed?(physical_name)
27
+ @wants_typed[physical_name]
28
+ end
29
+
30
+ def cached(physical_name)
31
+ @cache[physical_name]
32
+ end
33
+
34
+ def bind(request, statement)
35
+ return unless statement.typed?
36
+
37
+ request.type_map = @cache[statement.physical_name] || PENDING
38
+ end
39
+
40
+ def ensure_bundle!(connection_args)
41
+ return if @bundle && @bundle != :failed
42
+
43
+ conn = nil
44
+ conn = connection_args.nil? ? PG::Connection.new : PG::Connection.new(connection_args)
45
+ @bundle = PG::BasicTypeRegistry::CoderMapsBundle.new(conn)
46
+ @text_map_for_results = PG::BasicTypeMapForResults.new(@bundle)
47
+ rescue StandardError => e
48
+ @bundle = :failed
49
+ @text_map_for_results = nil
50
+ raise Error, "failed to build CoderMapsBundle (#{e.class}: #{e.message})"
51
+ ensure
52
+ begin
53
+ conn&.close
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ end
58
+
59
+ def apply!(request, result, _conn)
60
+ map = request.type_map
61
+ return if map.nil?
62
+
63
+ if map.equal?(PENDING)
64
+ name = request.statement_name
65
+ map = @cache[name]
66
+ unless map
67
+ map = build_column_map!(result)
68
+ @cache[name] = map
69
+ end
70
+ request.type_map = map
71
+ end
72
+
73
+ result.type_map = map
74
+ end
75
+
76
+ private
77
+
78
+ def build_column_map!(result)
79
+ unless result.result_status == PG::PGRES_TUPLES_OK && result.nfields.positive?
80
+ raise Error, "typed mapping requires a TUPLES_OK result with at least one column"
81
+ end
82
+ if @bundle == :failed || @text_map_for_results.nil?
83
+ raise Error, "typed result mapping is unavailable (CoderMapsBundle failed)"
84
+ end
85
+
86
+ @text_map_for_results.build_column_map(result)
87
+ end
88
+ end
89
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgPipeline
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.3"
5
5
  end
data/lib/pg_pipeline.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "pg_pipeline/session"
9
9
  require_relative "pg_pipeline/transaction"
10
10
  require_relative "pg_pipeline/request"
11
11
  require_relative "pg_pipeline/prepared_statement"
12
+ require_relative "pg_pipeline/type_maps"
12
13
  require_relative "pg_pipeline/connection_driver"
13
14
  require_relative "pg_pipeline/pool"
14
15
  require_relative "pg_pipeline/client"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_pipeline
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov
@@ -134,6 +134,7 @@ files:
134
134
  - lib/pg_pipeline/session.rb
135
135
  - lib/pg_pipeline/session_guard.rb
136
136
  - lib/pg_pipeline/transaction.rb
137
+ - lib/pg_pipeline/type_maps.rb
137
138
  - lib/pg_pipeline/version.rb
138
139
  homepage: https://github.com/roman-haidarov/pg_pipeline
139
140
  licenses: