pg_pipeline 0.2.4 → 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: e6537ec11ece29a487aa35449c264d2bae69089022a28c473f71523e52fd2acd
4
- data.tar.gz: 231b7285d0ddf23bb686836217ff35d1c89cc3872a5ac4ef20df28cea7e781bb
3
+ metadata.gz: 9bc5e0b7e4d8a274bc84e16fc7205774f0c20a0d4b2ee2ed9482a676eb15b986
4
+ data.tar.gz: fd0f6c75af1815f9574b2133caff20c092db8f45f4ee04085bf975f51be231e0
5
5
  SHA512:
6
- metadata.gz: 31ef9eeacef9208296c2df0d29f5a83e0a2ee8a2278715bd4b67d261fb1561a5b0e11771856b5dfb27d34a28cb21034e7b6cda5408757f6e13672a3db65c8d3d
7
- data.tar.gz: b686d8bd24fb976e8d9e6842b92db04988b1ae6f5981b297de57fc7d32b0add212d200f5b2906d5a7ea450b47bf46b9373a4404d5449ab24b702b189fb2a9131
6
+ metadata.gz: 3a284e77a674fc31d6e1b0263ff32cd254814f8f535e84060227e30d5e7ffe284b104dc764dfb9cd9062dc4f56b5319b088d9649ff6213e60498775806930637
7
+ data.tar.gz: fa814de3830232f0f9136d3f582917bd91c426fc540f1d4cc19ded4872eb9eed4d45f936738d35f34c9377beb6057fe9e4c204b413f1654aa31b3d431e2c3266
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
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
+
3
44
  ## [0.2.4] - 2026-07-31
4
45
 
5
46
  Request-completion allocation patch. It replaces the general-purpose
data/DESIGN.md CHANGED
@@ -240,6 +240,46 @@ that the request-specific one-shot waiter deliberately does not provide.
240
240
  > suite against the exact 2.42.0 floor so `Scheduler#block`/`#unblock` behavior is
241
241
  > not merely assumed from a broad pessimistic dependency range.
242
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.
282
+
243
283
  ## 11. Version policy
244
284
 
245
285
  | Component | Minimum | Reason |
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? |
@@ -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
@@ -22,10 +22,16 @@ module PgPipeline
22
22
  @waiter_scheduler = nil
23
23
  end
24
24
 
25
+ def self.build(sql, params)
26
+ request = allocate
27
+ request.__send__(:init_query, sql, params)
28
+ request
29
+ end
30
+
25
31
  def self.prepare(statement) = PrepareRequest.new(statement)
26
32
 
27
33
  def self.prepared_query(statement, params: nil)
28
- PreparedQueryRequest.new(statement, params: params)
34
+ PreparedQueryRequest.build(statement, params)
29
35
  end
30
36
 
31
37
  def operation = :query
@@ -43,6 +49,23 @@ module PgPipeline
43
49
  def query_boundary_seen? = @query_boundary_seen
44
50
  def cancelled? = @cancelled
45
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
46
69
  end
47
70
 
48
71
  class PrepareRequest < Request
@@ -65,7 +88,21 @@ module PgPipeline
65
88
  @statement_name = RequestOps.snapshot_name(statement.physical_name)
66
89
  end
67
90
 
91
+ def self.build(statement, params)
92
+ request = allocate
93
+ request.__send__(:init_prepared, statement, params)
94
+ request
95
+ end
96
+
68
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
69
106
  end
70
107
 
71
108
  module RequestOps
@@ -76,11 +113,34 @@ module PgPipeline
76
113
  value.frozen? ? value : value.dup.freeze
77
114
  end
78
115
 
116
+ EMPTY_PARAMS = [].freeze
117
+
79
118
  def snapshot_params(params)
80
- values = params.nil? ? [] : params
81
- 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?
82
122
 
83
- values.map { |value| snapshot_value(value) }.freeze
123
+ return params if params.frozen? && immutable_values?(params)
124
+
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
84
144
  end
85
145
 
86
146
  def snapshot_value(value)
@@ -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.4"
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.4
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov