pg_pipeline 0.2.3 → 0.2.5

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: 7135257ec52d199f4a7e0d3666909eebd8effa277f7efe80d27ca8ff00717f93
4
- data.tar.gz: 8f3b976cc4b9f8400439a1504bbad9a019865a88a7f41e6556907ba649587c80
3
+ metadata.gz: 9bc5e0b7e4d8a274bc84e16fc7205774f0c20a0d4b2ee2ed9482a676eb15b986
4
+ data.tar.gz: fd0f6c75af1815f9574b2133caff20c092db8f45f4ee04085bf975f51be231e0
5
5
  SHA512:
6
- metadata.gz: 90422fd9a948509c2e53b9058d052a822e76f303a736a39488a0870b6fa903d6c1b2b8248e00649269a63ed606a9a8d04b1c2c081cf0f2b89e81000af8a36e54
7
- data.tar.gz: fa76f361560fa078903685d69de81600b8337509b8f37b89cd93fdfdc71ebc3953180edf8487b4aa24d79d521237ef98bc727cde48ca959acb41c0d589749999
6
+ metadata.gz: 3a284e77a674fc31d6e1b0263ff32cd254814f8f535e84060227e30d5e7ffe284b104dc764dfb9cd9062dc4f56b5319b088d9649ff6213e60498775806930637
7
+ data.tar.gz: fa814de3830232f0f9136d3f582917bd91c426fc540f1d4cc19ded4872eb9eed4d45f936738d35f34c9377beb6057fe9e4c204b413f1654aa31b3d431e2c3266
data/CHANGELOG.md CHANGED
@@ -1,5 +1,80 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.5] - 2026-08-03
4
+
5
+ Control-plane cleanup on the multiplexed query path: fewer per-query allocations,
6
+ a cheaper session-guard fast path, slightly tighter result drain, and driver
7
+ fill metrics. Wire behaviour and the public API are unchanged.
8
+
9
+ ### Performance
10
+
11
+ - `SessionGuard` skips comment/literal masking when the SQL has no quotes,
12
+ comments or dollar-quotes; skips the forbidden-pattern scan when the SQL has
13
+ neither `(` nor `into`; and uses a byte scan for multi-statement detection.
14
+ - `Request.build` / `PreparedQueryRequest.build` avoid keyword `Class#new` hash
15
+ allocation on the hot path.
16
+ - `RequestOps.snapshot_params` reuses a shared frozen empty array and skips
17
+ copying frozen immutable param arrays.
18
+ - Default query params use that empty frozen array instead of allocating `[]`
19
+ per call (`Client`, `Session`, `PreparedStatement`).
20
+ - `PoolOps.select_driver_into` writes the round-robin cursor into a pool-owned
21
+ slot instead of allocating a `[driver, cursor]` pair per selection.
22
+ - `drain_results` matches hot statuses (`TUPLES_OK`, `PIPELINE_SYNC`) first and
23
+ accumulates `results_read` once per drain.
24
+
25
+ ### Observability
26
+
27
+ - Driver stats add `fast_sync`, `units_per_readable`, `results_per_readable`,
28
+ `flush_calls`, `flush_incomplete`, `dispatches`, and `flush_calls_per_unit`.
29
+ - One process-level warning on libpq older than 17 when Sync is coupled to flush
30
+ (`PG_PIPELINE_SILENCE_WARNINGS=1` to suppress).
31
+
32
+ ### Reliability
33
+
34
+ - Round-robin cursor is normalised with `rr % size` when past the pool length.
35
+ - Driver metric readers default to zero if ivars are unset.
36
+
37
+ ### Tests
38
+
39
+ - Guard fast-path / masking / cache eviction coverage.
40
+ - `Request.build` equivalence and prepared-query build coverage.
41
+ - Randomised driver-selection vs reference algorithm.
42
+ - Drain counter write-back when the loop raises.
43
+
44
+ ## [0.2.4] - 2026-07-31
45
+
46
+ Request-completion allocation patch. It replaces the general-purpose
47
+ `Async::Notification` attached to every multiplexed request with a direct
48
+ single-waiter fiber handoff while preserving deferred reactor wakeups and the
49
+ existing SQL, Sync, result and failure semantics.
50
+
51
+ ### Performance
52
+
53
+ - A request now stores only the waiting fiber and its originating scheduler.
54
+ `Scheduler#block` parks that fiber and `Scheduler#unblock` schedules only that
55
+ waiter on a later reactor turn.
56
+ - Removed the per-request `Async::Notification` and its initial
57
+ `Thread::Queue`; when a waiter is present, completion also avoids the
58
+ replacement queue and `Async::Notification::Signal` allocation.
59
+ - The optimization is limited to one-shot request completion. Bounded-queue and
60
+ pinned-pool notifications retain `Async::Notification` because they require
61
+ multi-waiter coordination.
62
+
63
+ ### Reliability
64
+
65
+ - Completion-before-wait still returns immediately through the settled guard, so
66
+ no wakeup can be lost.
67
+ - Timeout or task cancellation clears the stored waiter in an `ensure`, preventing
68
+ a late query completion from retaining or waking an abandoned fiber.
69
+ - A second concurrent waiter is rejected explicitly instead of silently replacing
70
+ the first waiter.
71
+ - The scheduler associated with the waiting fiber is stored and used for the
72
+ matching unblock rather than looking up an implicit current scheduler at
73
+ completion time.
74
+ - Added focused coverage for wait-before-completion, completion after an
75
+ interrupted wait, the single-waiter invariant and already-settled waits outside
76
+ an active scheduler.
77
+
3
78
  ## [0.2.3] - 2026-07-30
4
79
 
5
80
  Performance-focused release based on CPU profiles from the live pipeline
data/DESIGN.md CHANGED
@@ -126,7 +126,7 @@ driver that lacks a successfully registered handle.
126
126
  Registration is explicit: there is no unbounded automatic SQL cache. A failed
127
127
  registration is removed from the reconnect catalog; statements that had already
128
128
  been prepared on another connection may remain there under an unreachable
129
- generated name until that connection is replaced or closed. Version 0.2.3 does
129
+ generated name until that connection is replaced or closed. Version 0.2.3 and later do
130
130
  not expose `DEALLOCATE` for multiplexed handles. Server-side plan invalidation is
131
131
  reported as the ordinary request-local `QueryError`.
132
132
 
@@ -221,15 +221,64 @@ without reset would leak that state to the next caller. After each pinned block:
221
221
 
222
222
  Immediately resuming a waiter from inside the connection owner is unsafe: a
223
223
  resumed caller could re-enter the driver (submit, or close the client and wait on
224
- the owner) while the owner is mid-`signal`. Internal wait/notify points therefore
225
- use `Async::Notification`, which resumes waiters on a later reactor turn, avoiding
226
- reentrancy into the owner. The same rule applies to request completion,
227
- bounded-queue backpressure and pinned-pool idle notifications.
224
+ the owner) while the owner is still completing the current pipeline unit.
225
+
226
+ A `Request` has exactly one consumer, so request completion stores that waiter
227
+ fiber and its scheduler directly. `Scheduler#block` parks it, and
228
+ `Scheduler#unblock` pushes it onto the selector for a later reactor turn. This
229
+ preserves deferred wakeup without allocating a general-purpose
230
+ `Async::Notification` and its queue objects for every query. The settled flag
231
+ handles completion-before-wait, and an `ensure` removes a waiter interrupted by
232
+ timeout or task cancellation before a late result can try to wake it.
233
+
234
+ Multi-waiter coordination points still use `Async::Notification`: bounded-queue
235
+ backpressure and pinned-pool idle notifications need queue/broadcast semantics
236
+ that the request-specific one-shot waiter deliberately does not provide.
228
237
 
229
238
  > Note: the supported Async range starts at 2.42 and stays below 3. The lockfile
230
239
  > exercises the current compatible 2.x release, while CI separately runs the unit
231
- > suite against the exact 2.42.0 floor so this coordination behavior is not merely
232
- > assumed from a broad pessimistic dependency range.
240
+ > suite against the exact 2.42.0 floor so `Scheduler#block`/`#unblock` behavior is
241
+ > not merely assumed from a broad pessimistic dependency range.
242
+
243
+ ## 10a. Guard evaluation shape
244
+
245
+ `SessionGuard` validates the session-neutral SQL contract of §6 before a query is
246
+ allowed onto the shared path, so its cost lands on the reactor thread and its
247
+ stalls are visible in every other fiber's latency, not just the caller's.
248
+
249
+ Evaluation is therefore staged so that the expensive stage runs only when it can
250
+ change the answer:
251
+
252
+ 1. **Masking is skipped when it is provably an identity transform.** `code_only`
253
+ blanks string literals, quoted identifiers, comments and dollar-quoted bodies.
254
+ If none of `'`, `"`, `--`, `/*` or a `$tag$` delimiter appears in the SQL,
255
+ there is nothing to blank and the raw SQL is validated directly.
256
+ 2. **The forbidden-pattern scan is skipped when no pattern can match.** Every
257
+ pattern in `FORBIDDEN_PATTERNS` and `STRICT_FORBIDDEN` is anchored on either a
258
+ parenthesis or the word `into`, so SQL containing neither cannot match any of
259
+ them.
260
+ 3. **Verdicts are cached by SQL string**, and the cache evicts a single oldest
261
+ entry at its limit. Clearing the whole cache would turn one insertion into a
262
+ full recompute for every subsequent statement.
263
+
264
+ Stage 1 and stage 2 are correctness-preserving only as long as their premises
265
+ hold. Both are asserted in `spec/pg_pipeline/session_guard_fast_path_spec.rb`;
266
+ **adding a pattern that is not anchored on `(` or `into` requires updating the
267
+ prefilter in the same change.**
268
+
269
+ Masking is deliberately monotone in the wrong direction to be reordered: blanking
270
+ a comment can *create* a match (`nextval/* c */('s')` becomes `nextval ('s')`),
271
+ so the raw SQL can never be used as a negative filter for stage 2. Only the
272
+ absence of maskable syntax justifies skipping stage 1.
273
+
274
+ ## 10b. Fill observability
275
+
276
+ Pipelining pays off only when one reactor wakeup is amortised over several units.
277
+ `ConnectionDriver#stats` therefore reports `units_per_readable` (and related
278
+ counters) alongside the queue depths. A value near 1.0 means each query costs a
279
+ full socket wait and a scheduler round trip, which bounds throughput
280
+ independently of control-plane work; values well above 1.0 mean the pipeline is
281
+ filling. Read this number before attributing throughput to Ruby-side cost.
233
282
 
234
283
  ## 11. Version policy
235
284
 
data/README.md CHANGED
@@ -40,6 +40,14 @@ gem "pg_pipeline"
40
40
 
41
41
  Requires Ruby ≥ 3.3, `async ~> 2.42`, `pg ≥ 1.5`, and **libpq ≥ 14** at runtime.
42
42
 
43
+ **libpq ≥ 17 is recommended for maximum local throughput.** Below 17 there is no
44
+ `PQsendPipelineSync`, so `PQpipelineSync` couples Sync with a flush and the driver
45
+ cannot batch writes across a dispatch burst. Correctness is identical and RTT
46
+ amortisation — the main reason to pipeline — still works on libpq 14; only local
47
+ throughput is capped. The driver detects this at connect time and warns once per
48
+ process; set `PG_PIPELINE_SILENCE_WARNINGS=1` to suppress it, and check
49
+ `db.stats[:pipeline][:drivers].first[:fast_sync]` to see which path is active.
50
+
43
51
  ## Usage
44
52
 
45
53
  ### Multiplexed queries
@@ -155,6 +163,49 @@ db.stats
155
163
 
156
164
  `load` (pending + in-flight + submitting + dispatching) per driver is the routing/head-of-line signal.
157
165
 
166
+ Each driver also reports how well the pipeline is filling:
167
+
168
+ ```ruby
169
+ db.stats[:pipeline][:drivers].first
170
+ # => { ..., fast_sync: true,
171
+ # units_per_readable: 7.9, results_per_readable: 22.2,
172
+ # flush_calls_per_unit: 0.17, flush_incomplete: 0 }
173
+ ```
174
+
175
+ `units_per_readable` is the number to watch. Around `1.0` means every query pays a
176
+ full socket wait plus scheduler round trip and the pipeline is not filling — that is
177
+ the expected shape for a single fiber issuing one query at a time, and no amount of
178
+ Ruby-side optimisation will change it. Values well above `1.0` mean one reactor
179
+ wakeup is amortised over many queries, which is the regime pipelining is for. Check
180
+ this before attributing a throughput number to control-plane cost.
181
+
182
+ The numbers above are from a saturated HTTP benchmark (4 workers, 4 connections
183
+ each, ~20k requests/second against a local server): roughly eight queries per
184
+ reactor wakeup and one flush per six queries. `results_per_readable` runs at
185
+ three times `units_per_readable` because a completed unit yields three protocol
186
+ results -- the data, the query boundary and the Sync.
187
+
188
+ ## Head-of-line blocking
189
+
190
+ Results on a pipelined connection arrive in FIFO order, and PostgreSQL offers no
191
+ safe way to cancel one request out of a multiplexed pipeline (see `DESIGN.md` §7).
192
+ Two consequences worth designing around:
193
+
194
+ - **A slow query delays everything behind it on the same connection.** With the
195
+ default `max_in_flight: 64`, one multi-second query can hold up to 63 unrelated
196
+ queries on that driver. Driver selection balances by queue depth, not by expected
197
+ cost, so a slow query counts the same as a fast one.
198
+ - **A timeout is not a cancellation.** Wrapping `db.query` in `with_timeout` returns
199
+ control to your fiber, but the unit stays in the pipeline until the server answers
200
+ it, and the requests behind it still wait.
201
+
202
+ If your workload mixes fast and slow queries, prefer one of:
203
+
204
+ - lower `max_in_flight` so a stall cannot capture a deep queue;
205
+ - a second `Client` with its own connections for the slow queries;
206
+ - `Client#session` for anything long-running, which uses an exclusive pinned
207
+ connection and cannot block multiplexed traffic.
208
+
158
209
  ## Failure model
159
210
 
160
211
  | Error | Meaning | Retry safe? |
@@ -187,6 +238,8 @@ See `examples/falcon_config.ru` for a complete setup.
187
238
 
188
239
  ## Benchmark
189
240
 
241
+ [Performance test results and methodology](docs/PERFORMANCE.md).
242
+
190
243
  ```bash
191
244
  docker compose up -d pg17
192
245
  export PG_PIPELINE_URL=postgres://postgres:postgres@127.0.0.1:5417/postgres
@@ -27,7 +27,7 @@ module PgPipeline
27
27
  end
28
28
 
29
29
  def start(parent: Async::Task.current) = ClientOps.start(self, parent)
30
- def query(sql, params = []) = ClientOps.query(self, sql, params)
30
+ def query(sql, params = RequestOps::EMPTY_PARAMS) = ClientOps.query(self, sql, params)
31
31
  def prepare(name, sql, param_types = nil) = ClientOps.prepare(self, name, sql, param_types)
32
32
  def stats = ClientOps.stats(self)
33
33
 
@@ -77,7 +77,7 @@ module PgPipeline
77
77
  SessionGuard.assert_multiplexable_normalized!(sql, mode: client.guard)
78
78
 
79
79
  wait_for_request do
80
- submit_with_failover(client) { Request.new(sql: sql, params: params) }
80
+ submit_with_failover(client) { Request.build(sql, params) }
81
81
  end
82
82
  end
83
83
 
@@ -105,8 +105,6 @@ module PgPipeline
105
105
  end
106
106
  end
107
107
 
108
- # NotDispatchedError is safe to retry on a fresh Request by contract.
109
- # ShutdownError is retried only while the current Request is still pre-dispatch.
110
108
  def submit_with_failover(client)
111
109
  attempts = 0
112
110
  limit = [pool(client).pipeline_size, 1].max
@@ -20,6 +20,16 @@ module PgPipeline
20
20
  :draining, :needs_flush, :writer_armed, :request_event_pending,
21
21
  :owner_task, :reader_task, :writer_task
22
22
 
23
+ attr_writer :readable_events, :results_read, :units_completed, :flush_calls,
24
+ :flush_incomplete, :dispatches
25
+
26
+ def readable_events = @readable_events || 0
27
+ def results_read = @results_read || 0
28
+ def units_completed = @units_completed || 0
29
+ def flush_calls = @flush_calls || 0
30
+ def flush_incomplete = @flush_incomplete || 0
31
+ def dispatches = @dispatches || 0
32
+
23
33
  def initialize(conn, max_pending: DEFAULT_MAX_PENDING, max_in_flight: DEFAULT_MAX_IN_FLIGHT)
24
34
  @max_pending = DriverOps.positive_integer!(max_pending, :max_pending)
25
35
  @max_in_flight = DriverOps.positive_integer!(max_in_flight, :max_in_flight)
@@ -27,6 +37,7 @@ module PgPipeline
27
37
  @conn = conn
28
38
  @caps = ServerCaps.from_connection(conn)
29
39
  @caps.assert_supported!
40
+ DriverOps.warn_flush_coupling_once(@caps)
30
41
 
31
42
  @requests = BoundedQueue.new(@max_pending)
32
43
  @events = Async::Queue.new
@@ -44,6 +55,13 @@ module PgPipeline
44
55
  @writer_armed = false
45
56
  @request_event_pending = false
46
57
 
58
+ @readable_events = 0
59
+ @results_read = 0
60
+ @units_completed = 0
61
+ @flush_calls = 0
62
+ @flush_incomplete = 0
63
+ @dispatches = 0
64
+
47
65
  @socket = nil
48
66
  @owner_task = nil
49
67
  @reader_task = nil
@@ -63,14 +81,24 @@ module PgPipeline
63
81
  pending: @requests.size,
64
82
  in_flight: @inflight.size,
65
83
  submitting: @submitting,
66
- needs_flush: @needs_flush
84
+ needs_flush: @needs_flush,
85
+ fast_sync: @caps.fast_sync?,
86
+ readable_events: readable_events,
87
+ results_read: results_read,
88
+ units_completed: units_completed,
89
+ flush_calls: flush_calls,
90
+ flush_incomplete: flush_incomplete,
91
+ dispatches: dispatches,
92
+ units_per_readable: DriverOps.ratio(units_completed, readable_events),
93
+ results_per_readable: DriverOps.ratio(results_read, readable_events),
94
+ flush_calls_per_unit: DriverOps.ratio(flush_calls, units_completed)
67
95
  }
68
96
  end
69
97
 
70
98
  def health_check(timeout)
71
99
  return true unless available?
72
100
 
73
- probe = Request.new(sql: "SELECT 1", params: [])
101
+ probe = Request.build("SELECT 1", nil)
74
102
  begin
75
103
  submit(probe)
76
104
  Async::Task.current.with_timeout(timeout) { probe.wait }
@@ -98,17 +126,25 @@ module PgPipeline
98
126
  module DriverOps
99
127
  module_function
100
128
 
101
- SUCCESS_STATUSES = [
102
- PG::PGRES_EMPTY_QUERY,
103
- PG::PGRES_COMMAND_OK,
104
- PG::PGRES_TUPLES_OK
105
- ].freeze
129
+ def ratio(numerator, denominator)
130
+ return 0.0 if denominator.zero?
131
+
132
+ (numerator.to_f / denominator).round(3)
133
+ end
134
+
135
+ def warn_flush_coupling_once(caps)
136
+ return if @flush_coupling_warned
137
+ return if caps.fast_sync?
138
+ return if ENV["PG_PIPELINE_SILENCE_WARNINGS"]
106
139
 
107
- COPY_STATUSES = [
108
- PG::PGRES_COPY_IN,
109
- PG::PGRES_COPY_OUT,
110
- PG::PGRES_COPY_BOTH
111
- ].freeze
140
+ @flush_coupling_warned = true
141
+ warn(
142
+ "pg_pipeline: libpq #{caps.libpq_version} couples pipeline Sync with flush " \
143
+ "(no PQsendPipelineSync). Queries still pipeline and still amortise RTT, but " \
144
+ "one flush per unit caps local throughput; libpq >= 17 is recommended for " \
145
+ "maximum throughput. Set PG_PIPELINE_SILENCE_WARNINGS=1 to silence this."
146
+ )
147
+ end
112
148
 
113
149
  def positive_integer!(value, name)
114
150
  integer = Integer(value)
@@ -213,6 +249,7 @@ module PgPipeline
213
249
  nil
214
250
  when :readable
215
251
  begin
252
+ d.readable_events += 1
216
253
  read_available(d)
217
254
  input_changed = true
218
255
  ensure
@@ -260,6 +297,7 @@ module PgPipeline
260
297
  request.dispatched!
261
298
  d.inflight << request
262
299
  d.dispatching = nil
300
+ d.dispatches += 1
263
301
  dispatched = true
264
302
  end
265
303
 
@@ -286,9 +324,6 @@ module PgPipeline
286
324
  rescue ProtocolError
287
325
  raise
288
326
  rescue StandardError => e
289
- # ruby-pg prepares/encodes query parameters before calling PQsend*. A
290
- # Ruby-side encoder/coercion exception therefore belongs only to this
291
- # request and must not poison unrelated work already in the pipeline.
292
327
  request.reject!(e)
293
328
  return false
294
329
  end
@@ -327,10 +362,13 @@ module PgPipeline
327
362
  def flush_output(d)
328
363
  return unless d.running
329
364
 
365
+ d.flush_calls += 1
366
+
330
367
  if d.conn.sync_flush
331
368
  d.needs_flush = false
332
369
  else
333
370
  d.needs_flush = true
371
+ d.flush_incomplete += 1
334
372
  arm_writer(d)
335
373
  end
336
374
  rescue PG::Error => e
@@ -351,42 +389,52 @@ module PgPipeline
351
389
  end
352
390
 
353
391
  def drain_results(d)
354
- while !d.inflight.empty? && !d.conn.is_busy
355
- result = d.conn.sync_get_result
356
- request = d.inflight.first
357
- raise ProtocolError, "result without an in-flight request" unless request
358
-
359
- if result.nil?
360
- request.query_boundary!
361
- next
362
- end
392
+ read = 0
363
393
 
364
- status = result.result_status
365
-
366
- case status
367
- when PG::PGRES_PIPELINE_SYNC
368
- clear_result(result)
369
- complete_front(d, request)
370
- when PG::PGRES_PIPELINE_ABORTED
371
- ensure_before_query_boundary!(request, status)
372
- clear_result(result)
373
- request.record_error!(PipelineAbortedError.new("pipeline unit aborted"))
374
- when PG::PGRES_BAD_RESPONSE
375
- clear_result(result)
376
- raise ProtocolError, "server response was not understood"
377
- when PG::PGRES_FATAL_ERROR
378
- ensure_before_query_boundary!(request, status)
379
- request.record_error!(query_error(result), result: result)
380
- when *COPY_STATUSES
381
- clear_result(result)
382
- raise ProtocolError, "COPY is not supported on the multiplexed pipeline"
383
- when *SUCCESS_STATUSES
384
- ensure_before_query_boundary!(request, status)
385
- request.accept_result(result)
386
- else
387
- clear_result(result)
388
- raise ProtocolError, "unexpected pipeline result status #{status}"
394
+ begin
395
+ while !d.inflight.empty? && !d.conn.is_busy
396
+ result = d.conn.sync_get_result
397
+ read += 1
398
+ request = d.inflight.first
399
+ raise ProtocolError, "result without an in-flight request" unless request
400
+
401
+ if result.nil?
402
+ request.query_boundary!
403
+ next
404
+ end
405
+
406
+ status = result.result_status
407
+
408
+ case status
409
+ when PG::PGRES_TUPLES_OK
410
+ ensure_before_query_boundary!(request, status)
411
+ request.accept_result(result)
412
+ when PG::PGRES_PIPELINE_SYNC
413
+ clear_result(result)
414
+ complete_front(d, request)
415
+ when PG::PGRES_COMMAND_OK, PG::PGRES_EMPTY_QUERY
416
+ ensure_before_query_boundary!(request, status)
417
+ request.accept_result(result)
418
+ when PG::PGRES_FATAL_ERROR
419
+ ensure_before_query_boundary!(request, status)
420
+ request.record_error!(query_error(result), result: result)
421
+ when PG::PGRES_PIPELINE_ABORTED
422
+ ensure_before_query_boundary!(request, status)
423
+ clear_result(result)
424
+ request.record_error!(PipelineAbortedError.new("pipeline unit aborted"))
425
+ when PG::PGRES_BAD_RESPONSE
426
+ clear_result(result)
427
+ raise ProtocolError, "server response was not understood"
428
+ when PG::PGRES_COPY_IN, PG::PGRES_COPY_OUT, PG::PGRES_COPY_BOTH
429
+ clear_result(result)
430
+ raise ProtocolError, "COPY is not supported on the multiplexed pipeline"
431
+ else
432
+ clear_result(result)
433
+ raise ProtocolError, "unexpected pipeline result status #{status}"
434
+ end
389
435
  end
436
+ ensure
437
+ d.results_read += read if read.positive?
390
438
  end
391
439
  end
392
440
 
@@ -400,6 +448,7 @@ module PgPipeline
400
448
  raise ProtocolError, "sync does not match FIFO front" unless request.equal?(d.inflight.first)
401
449
 
402
450
  d.inflight.shift
451
+ d.units_completed += 1
403
452
  request.finish!
404
453
  end
405
454
 
@@ -53,6 +53,7 @@ module PgPipeline
53
53
  @driver_attempts = []
54
54
  @driver_last_health = []
55
55
  @rr = 0
56
+ @rr_slot = [0]
56
57
  @reconnects = 0
57
58
  @health_failures = 0
58
59
  @supervisor_error = nil
@@ -106,7 +107,8 @@ module PgPipeline
106
107
  def pipeline_driver
107
108
  ensure_available!
108
109
 
109
- driver, @rr = PoolOps.select_driver(@drivers, @rr)
110
+ driver = PoolOps.select_driver_into(@drivers, @rr, @rr_slot)
111
+ @rr = @rr_slot[0]
110
112
  raise NotDispatchedError, "no live pipeline connections; request was not dispatched" unless driver
111
113
 
112
114
  driver
@@ -557,28 +559,42 @@ module PgPipeline
557
559
  raise ArgumentError, "#{name} must be an integer >= 0"
558
560
  end
559
561
 
560
- def select_driver(drivers, rr)
562
+ def select_driver_into(drivers, rr, slot)
561
563
  size = drivers.length
562
- return [nil, rr] if size.zero?
564
+ if size.zero?
565
+ slot[0] = rr
566
+ return nil
567
+ end
563
568
 
569
+ start = rr % size
564
570
  best = nil
565
- best_index = nil
566
- best_load = nil
571
+ best_index = 0
572
+ best_load = 0
573
+ offset = 0
567
574
 
568
- size.times do |offset|
569
- index = (rr + offset) % size
575
+ while offset < size
576
+ index = start + offset
577
+ index -= size if index >= size
570
578
  driver = drivers[index]
579
+ offset += 1
571
580
  next unless driver.available?
572
581
 
573
582
  load = driver.load
574
- if best.nil? || load < best_load
575
- best = driver
576
- best_index = index
577
- best_load = load
578
- end
583
+ next unless best.nil? || load < best_load
584
+
585
+ best = driver
586
+ best_index = index
587
+ best_load = load
579
588
  end
580
589
 
581
- best ? [best, (best_index + 1) % size] : [nil, rr]
590
+ slot[0] = best ? (best_index + 1) % size : rr
591
+ best
592
+ end
593
+
594
+ def select_driver(drivers, rr)
595
+ slot = [rr]
596
+ driver = select_driver_into(drivers, rr, slot)
597
+ [driver, slot[0]]
582
598
  end
583
599
 
584
600
  def new_connection(connection_args)
@@ -16,7 +16,7 @@ module PgPipeline
16
16
  freeze
17
17
  end
18
18
 
19
- def query(params = []) = PreparedStatementOps.query(self, params)
19
+ def query(params = RequestOps::EMPTY_PARAMS) = PreparedStatementOps.query(self, params)
20
20
  alias call query
21
21
 
22
22
  def inspect
@@ -1,32 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "async/notification"
4
-
5
3
  require_relative "errors"
6
4
 
7
5
  module PgPipeline
8
6
  class Request
9
- attr_reader :sql, :params, :condition
7
+ attr_reader :sql, :params
10
8
  attr_accessor :state, :cancelled, :settled, :result_seen, :query_boundary_seen,
11
- :result, :error
9
+ :result, :error, :waiter, :waiter_scheduler
12
10
 
13
11
  def initialize(sql:, params: nil)
14
12
  @sql = RequestOps.snapshot_sql(sql)
15
13
  @params = RequestOps.snapshot_params(params)
16
14
  @state = :new
17
- @condition = Async::Notification.new
18
15
  @cancelled = false
19
16
  @settled = false
20
17
  @result_seen = false
21
18
  @query_boundary_seen = false
22
19
  @result = nil
23
20
  @error = nil
21
+ @waiter = nil
22
+ @waiter_scheduler = nil
23
+ end
24
+
25
+ def self.build(sql, params)
26
+ request = allocate
27
+ request.__send__(:init_query, sql, params)
28
+ request
24
29
  end
25
30
 
26
31
  def self.prepare(statement) = PrepareRequest.new(statement)
27
32
 
28
33
  def self.prepared_query(statement, params: nil)
29
- PreparedQueryRequest.new(statement, params: params)
34
+ PreparedQueryRequest.build(statement, params)
30
35
  end
31
36
 
32
37
  def operation = :query
@@ -44,6 +49,23 @@ module PgPipeline
44
49
  def query_boundary_seen? = @query_boundary_seen
45
50
  def cancelled? = @cancelled
46
51
  def settled? = @settled
52
+
53
+ private
54
+
55
+ def init_query(sql, params)
56
+ @sql = RequestOps.snapshot_sql(sql)
57
+ @params = RequestOps.snapshot_params(params)
58
+ @state = :new
59
+ @cancelled = false
60
+ @settled = false
61
+ @result_seen = false
62
+ @query_boundary_seen = false
63
+ @result = nil
64
+ @error = nil
65
+ @waiter = nil
66
+ @waiter_scheduler = nil
67
+ self
68
+ end
47
69
  end
48
70
 
49
71
  class PrepareRequest < Request
@@ -66,7 +88,21 @@ module PgPipeline
66
88
  @statement_name = RequestOps.snapshot_name(statement.physical_name)
67
89
  end
68
90
 
91
+ def self.build(statement, params)
92
+ request = allocate
93
+ request.__send__(:init_prepared, statement, params)
94
+ request
95
+ end
96
+
69
97
  def operation = :prepared_query
98
+
99
+ private
100
+
101
+ def init_prepared(statement, params)
102
+ init_query(statement.sql, params)
103
+ @statement_name = RequestOps.snapshot_name(statement.physical_name)
104
+ self
105
+ end
70
106
  end
71
107
 
72
108
  module RequestOps
@@ -77,11 +113,34 @@ module PgPipeline
77
113
  value.frozen? ? value : value.dup.freeze
78
114
  end
79
115
 
116
+ EMPTY_PARAMS = [].freeze
117
+
80
118
  def snapshot_params(params)
81
- values = params.nil? ? [] : params
82
- raise ArgumentError, "params must be an Array" unless values.is_a?(Array)
119
+ return EMPTY_PARAMS if params.nil?
120
+ raise ArgumentError, "params must be an Array" unless params.is_a?(Array)
121
+ return EMPTY_PARAMS if params.empty?
122
+
123
+ return params if params.frozen? && immutable_values?(params)
83
124
 
84
- values.map { |value| snapshot_value(value) }.freeze
125
+ params.map { |value| snapshot_value(value) }.freeze
126
+ end
127
+
128
+ def immutable_values?(values)
129
+ index = 0
130
+ size = values.size
131
+ while index < size
132
+ value = values[index]
133
+ case value
134
+ when Integer, Float, Symbol, NilClass, TrueClass, FalseClass
135
+ nil
136
+ when String
137
+ return false unless value.frozen?
138
+ else
139
+ return false
140
+ end
141
+ index += 1
142
+ end
143
+ true
85
144
  end
86
145
 
87
146
  def snapshot_value(value)
@@ -156,7 +215,7 @@ module PgPipeline
156
215
 
157
216
  req.settled = true
158
217
  req.state = :done
159
- req.condition.signal unless req.cancelled
218
+ wake_waiter(req) unless req.cancelled
160
219
  end
161
220
 
162
221
  def reject!(req, error)
@@ -167,7 +226,7 @@ module PgPipeline
167
226
  req.error ||= error
168
227
  req.settled = true
169
228
  req.state = :done
170
- req.condition.signal unless req.cancelled
229
+ wake_waiter(req) unless req.cancelled
171
230
  end
172
231
 
173
232
  def cancel!(req)
@@ -180,12 +239,42 @@ module PgPipeline
180
239
  end
181
240
 
182
241
  def wait(req)
183
- req.condition.wait unless req.settled
242
+ park_waiter(req) unless req.settled
184
243
  raise req.error if req.error
185
244
 
186
245
  req.result
187
246
  end
188
247
 
248
+ def park_waiter(req)
249
+ raise ProtocolError, "request already has a waiter" if req.waiter
250
+
251
+ scheduler = Fiber.scheduler
252
+ raise Error, "request wait requires an active Fiber scheduler" unless scheduler
253
+
254
+ waiter = Fiber.current
255
+ req.waiter = waiter
256
+ req.waiter_scheduler = scheduler
257
+
258
+ begin
259
+ scheduler.block(req, nil) until req.settled
260
+ ensure
261
+ if req.waiter.equal?(waiter)
262
+ req.waiter = nil
263
+ req.waiter_scheduler = nil
264
+ end
265
+ end
266
+ end
267
+
268
+ def wake_waiter(req)
269
+ waiter = req.waiter
270
+ scheduler = req.waiter_scheduler
271
+
272
+ req.waiter = nil
273
+ req.waiter_scheduler = nil
274
+
275
+ scheduler.unblock(req, waiter) if scheduler && waiter
276
+ end
277
+
189
278
  def assert_result_slot!(req)
190
279
  raise ProtocolError, "result arrived after query boundary" if req.query_boundary_seen
191
280
  raise ProtocolError, "multiple results for one pipeline unit" if req.result_seen
@@ -4,6 +4,8 @@ require_relative "errors"
4
4
 
5
5
  module PgPipeline
6
6
  class Session
7
+ EMPTY_PARAMS = [].freeze
8
+
7
9
  attr_reader :owner_fiber
8
10
 
9
11
  def initialize(conn)
@@ -12,10 +14,10 @@ module PgPipeline
12
14
  @owner_fiber = Fiber.current
13
15
  end
14
16
 
15
- def query(sql, params = []) = SessionOps.query(self, sql, params)
17
+ def query(sql, params = EMPTY_PARAMS) = SessionOps.query(self, sql, params)
16
18
  def exec(sql, params = nil) = SessionOps.exec(self, sql, params)
17
19
  def prepare(name, sql, param_types = nil) = SessionOps.prepare(self, name, sql, param_types)
18
- def exec_prepared(name, params = []) = SessionOps.exec_prepared(self, name, params)
20
+ def exec_prepared(name, params = EMPTY_PARAMS) = SessionOps.exec_prepared(self, name, params)
19
21
  def active? = @active
20
22
 
21
23
  private
@@ -29,6 +29,11 @@ module PgPipeline
29
29
  "pg_export_snapshot" => /\bpg_export_snapshot\s*\(/i
30
30
  }.freeze
31
31
 
32
+ PATTERN_PREFILTER = /\binto\b/i
33
+ NEEDS_MASK = /['"]|--|\/\*|\$[A-Za-z_0-9]*\$/
34
+ LEADING_KEYWORD = /\A\s*([a-zA-Z_]+)/
35
+ WHITESPACE_BYTES = [9, 10, 11, 12, 13, 32].freeze
36
+
32
37
  def assert_multiplexable!(sql, mode: :default)
33
38
  assert_multiplexable_normalized!(sql, mode: normalize_mode!(mode))
34
39
  end
@@ -45,6 +50,7 @@ module PgPipeline
45
50
  end
46
51
 
47
52
  GUARD_CACHE_LIMIT = 2048
53
+ SAFE = :safe
48
54
 
49
55
  def unsafe_reason(sql, mode: :default)
50
56
  unsafe_reason_normalized(sql, mode: normalize_mode!(mode))
@@ -53,11 +59,12 @@ module PgPipeline
53
59
  def unsafe_reason_normalized(sql, mode:)
54
60
  key = sql.to_s
55
61
  cache = guard_cache.fetch(mode)
56
- return cache[key] if cache.key?(key)
62
+ cached = cache[key]
63
+ return (cached.equal?(SAFE) ? nil : cached) if cached
57
64
 
58
65
  reason = compute_unsafe_reason(key, mode)
59
- cache.clear if cache.size >= GUARD_CACHE_LIMIT
60
- cache[key] = reason
66
+ cache.shift if cache.size >= GUARD_CACHE_LIMIT
67
+ cache[key] = reason || SAFE
61
68
  reason
62
69
  end
63
70
 
@@ -66,12 +73,13 @@ module PgPipeline
66
73
  end
67
74
 
68
75
  def compute_unsafe_reason(sql, mode)
69
- code = code_only(sql)
70
- lead = code[/\A\s*([a-zA-Z_]+)/, 1]&.downcase
76
+ code = NEEDS_MASK.match?(sql) ? code_only(sql) : sql
77
+ lead = code[LEADING_KEYWORD, 1]&.downcase
71
78
 
72
79
  return "empty" unless lead
73
80
  return "leading:#{lead}" unless ALLOWED_LEADING.include?(lead)
74
81
  return "multiple-statements" if multiple_statements?(code)
82
+ return nil unless code.include?("(") || code.match?(PATTERN_PREFILTER)
75
83
 
76
84
  FORBIDDEN_PATTERNS.each do |name, pattern|
77
85
  return name if code.match?(pattern)
@@ -95,47 +103,50 @@ module PgPipeline
95
103
 
96
104
  def code_only(sql)
97
105
  source = sql.to_s.b
98
- output = String.new(capacity: source.bytesize, encoding: Encoding::BINARY)
106
+ size = source.bytesize
107
+ output = String.new(capacity: size, encoding: Encoding::BINARY)
99
108
  index = 0
100
109
  block_depth = 0
101
110
 
102
- while index < source.bytesize
111
+ while index < size
112
+ byte = source.getbyte(index)
113
+ nxt = index + 1 < size ? source.getbyte(index + 1) : nil
114
+
103
115
  if block_depth.positive?
104
- if source.byteslice(index, 2) == "/*"
116
+ if byte == 47 && nxt == 42
105
117
  block_depth += 1
106
118
  output << " "
107
119
  index += 2
108
- elsif source.byteslice(index, 2) == "*/"
120
+ elsif byte == 42 && nxt == 47
109
121
  block_depth -= 1
110
122
  output << " "
111
123
  index += 2
112
124
  else
113
- output << (source.getbyte(index) == 10 ? "\n" : " ")
125
+ output << (byte == 10 ? 10 : 32)
114
126
  index += 1
115
127
  end
116
128
  next
117
129
  end
118
130
 
119
- if source.byteslice(index, 2) == "--"
131
+ if byte == 45 && nxt == 45
120
132
  newline = source.index("\n", index + 2)
121
133
  if newline
122
- output << " " * (newline - index) << "\n"
134
+ output << (" " * (newline - index)) << "\n"
123
135
  index = newline + 1
124
136
  else
125
- output << " " * (source.bytesize - index)
137
+ output << (" " * (size - index))
126
138
  break
127
139
  end
128
140
  next
129
141
  end
130
142
 
131
- if source.byteslice(index, 2) == "/*"
143
+ if byte == 47 && nxt == 42
132
144
  block_depth = 1
133
145
  output << " "
134
146
  index += 2
135
147
  next
136
148
  end
137
149
 
138
- byte = source.getbyte(index)
139
150
  if byte == 39
140
151
  index = mask_quoted(source, output, index, 39, escape_backslash: escape_string_prefix?(source, index))
141
152
  next
@@ -147,18 +158,18 @@ module PgPipeline
147
158
  end
148
159
 
149
160
  if byte == 36
150
- remainder = source.byteslice(index, source.bytesize - index)
161
+ remainder = source.byteslice(index, size - index)
151
162
  tag = remainder.match(/\A\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)&.[](0)
152
163
  if tag
153
164
  closing = source.index(tag, index + tag.bytesize)
154
- finish = closing ? closing + tag.bytesize : source.bytesize
155
- output << " " * (finish - index)
165
+ finish = closing ? closing + tag.bytesize : size
166
+ output << (" " * (finish - index))
156
167
  index = finish
157
168
  next
158
169
  end
159
170
  end
160
171
 
161
- output << source.getbyte(index)
172
+ output << byte
162
173
  index += 1
163
174
  end
164
175
 
@@ -209,12 +220,17 @@ module PgPipeline
209
220
  private_class_method :escape_string_prefix?
210
221
 
211
222
  def multiple_statements?(code)
212
- semicolons = []
213
- code.each_char.with_index { |char, i| semicolons << i if char == ";" }
214
- return false if semicolons.empty?
223
+ first = code.index(";")
224
+ return false unless first
225
+
226
+ index = first + 1
227
+ size = code.bytesize
228
+ while index < size
229
+ return true unless WHITESPACE_BYTES.include?(code.getbyte(index))
215
230
 
216
- last_non_space = code.rstrip.length - 1
217
- semicolons.length > 1 || semicolons.first != last_non_space
231
+ index += 1
232
+ end
233
+ false
218
234
  end
219
235
  end
220
236
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgPipeline
4
- VERSION = "0.2.3"
4
+ VERSION = "0.2.5"
5
5
  end
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.2.3
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov