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 +4 -4
- data/CHANGELOG.md +41 -0
- data/DESIGN.md +40 -0
- data/README.md +51 -0
- data/lib/pg_pipeline/client.rb +2 -4
- data/lib/pg_pipeline/connection_driver.rb +98 -49
- data/lib/pg_pipeline/pool.rb +29 -13
- data/lib/pg_pipeline/prepared_statement.rb +1 -1
- data/lib/pg_pipeline/request.rb +64 -4
- data/lib/pg_pipeline/session.rb +4 -2
- data/lib/pg_pipeline/session_guard.rb +40 -24
- data/lib/pg_pipeline/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9bc5e0b7e4d8a274bc84e16fc7205774f0c20a0d4b2ee2ed9482a676eb15b986
|
|
4
|
+
data.tar.gz: fd0f6c75af1815f9574b2133caff20c092db8f45f4ee04085bf975f51be231e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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? |
|
data/lib/pg_pipeline/client.rb
CHANGED
|
@@ -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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
data/lib/pg_pipeline/pool.rb
CHANGED
|
@@ -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
|
|
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
|
|
562
|
+
def select_driver_into(drivers, rr, slot)
|
|
561
563
|
size = drivers.length
|
|
562
|
-
|
|
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 =
|
|
566
|
-
best_load =
|
|
571
|
+
best_index = 0
|
|
572
|
+
best_load = 0
|
|
573
|
+
offset = 0
|
|
567
574
|
|
|
568
|
-
|
|
569
|
-
index =
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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 ?
|
|
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)
|
data/lib/pg_pipeline/request.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
81
|
-
raise ArgumentError, "params must be an Array" unless
|
|
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
|
-
|
|
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)
|
data/lib/pg_pipeline/session.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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[
|
|
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
|
-
|
|
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 <
|
|
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
|
|
116
|
+
if byte == 47 && nxt == 42
|
|
105
117
|
block_depth += 1
|
|
106
118
|
output << " "
|
|
107
119
|
index += 2
|
|
108
|
-
elsif
|
|
120
|
+
elsif byte == 42 && nxt == 47
|
|
109
121
|
block_depth -= 1
|
|
110
122
|
output << " "
|
|
111
123
|
index += 2
|
|
112
124
|
else
|
|
113
|
-
output << (
|
|
125
|
+
output << (byte == 10 ? 10 : 32)
|
|
114
126
|
index += 1
|
|
115
127
|
end
|
|
116
128
|
next
|
|
117
129
|
end
|
|
118
130
|
|
|
119
|
-
if
|
|
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 << " " * (
|
|
137
|
+
output << (" " * (size - index))
|
|
126
138
|
break
|
|
127
139
|
end
|
|
128
140
|
next
|
|
129
141
|
end
|
|
130
142
|
|
|
131
|
-
if
|
|
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,
|
|
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 :
|
|
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 <<
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
231
|
+
index += 1
|
|
232
|
+
end
|
|
233
|
+
false
|
|
218
234
|
end
|
|
219
235
|
end
|
|
220
236
|
end
|
data/lib/pg_pipeline/version.rb
CHANGED