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 +4 -4
- data/CHANGELOG.md +75 -0
- data/DESIGN.md +56 -7
- data/README.md +53 -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 +101 -12
- 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,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
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
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
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
82
|
-
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?
|
|
122
|
+
|
|
123
|
+
return params if params.frozen? && immutable_values?(params)
|
|
83
124
|
|
|
84
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
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