pg_pipeline 0.2.1
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 +7 -0
- data/CHANGELOG.md +156 -0
- data/DESIGN.md +298 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/lib/pg_pipeline/bounded_queue.rb +102 -0
- data/lib/pg_pipeline/client.rb +180 -0
- data/lib/pg_pipeline/connection_driver.rb +502 -0
- data/lib/pg_pipeline/errors.rb +27 -0
- data/lib/pg_pipeline/pool.rb +420 -0
- data/lib/pg_pipeline/request.rb +146 -0
- data/lib/pg_pipeline/server_caps.rb +83 -0
- data/lib/pg_pipeline/session.rb +71 -0
- data/lib/pg_pipeline/session_guard.rb +213 -0
- data/lib/pg_pipeline/transaction.rb +92 -0
- data/lib/pg_pipeline/version.rb +5 -0
- data/lib/pg_pipeline.rb +15 -0
- metadata +155 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pg"
|
|
4
|
+
require "async"
|
|
5
|
+
require "async/semaphore"
|
|
6
|
+
require "async/notification"
|
|
7
|
+
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "connection_driver"
|
|
10
|
+
require_relative "server_caps"
|
|
11
|
+
|
|
12
|
+
module PgPipeline
|
|
13
|
+
class Pool
|
|
14
|
+
DEFAULT_PIPELINE_SIZE = 4
|
|
15
|
+
DEFAULT_PINNED_SIZE = 2
|
|
16
|
+
DEFAULT_RECONNECT_INTERVAL = 0.5
|
|
17
|
+
DEFAULT_RECONNECT_BACKOFF_MAX = 30.0
|
|
18
|
+
DEFAULT_HEALTH_INTERVAL = 10.0
|
|
19
|
+
DEFAULT_HEALTH_TIMEOUT = 5.0
|
|
20
|
+
DISCARD_SEQUENCES_SERVER_VERSION = 90_400
|
|
21
|
+
|
|
22
|
+
attr_reader :reconnects, :pipeline_size
|
|
23
|
+
|
|
24
|
+
def initialize(connection_args,
|
|
25
|
+
pipeline_size: DEFAULT_PIPELINE_SIZE,
|
|
26
|
+
pinned_size: DEFAULT_PINNED_SIZE,
|
|
27
|
+
max_pending: ConnectionDriver::DEFAULT_MAX_PENDING,
|
|
28
|
+
max_in_flight: ConnectionDriver::DEFAULT_MAX_IN_FLIGHT,
|
|
29
|
+
reconnect: true,
|
|
30
|
+
reconnect_interval: DEFAULT_RECONNECT_INTERVAL,
|
|
31
|
+
reconnect_backoff_max: DEFAULT_RECONNECT_BACKOFF_MAX,
|
|
32
|
+
health_check: true,
|
|
33
|
+
health_interval: DEFAULT_HEALTH_INTERVAL,
|
|
34
|
+
health_timeout: DEFAULT_HEALTH_TIMEOUT,
|
|
35
|
+
cancel_pinned_on_abort: true)
|
|
36
|
+
@connection_args = connection_args
|
|
37
|
+
@pipeline_size = PoolOps.positive_integer!(pipeline_size, :pipeline_size)
|
|
38
|
+
@pinned_size = PoolOps.nonnegative_integer!(pinned_size, :pinned_size)
|
|
39
|
+
@max_pending = max_pending
|
|
40
|
+
@max_in_flight = max_in_flight
|
|
41
|
+
@reconnect = reconnect
|
|
42
|
+
@reconnect_interval = Float(reconnect_interval)
|
|
43
|
+
@reconnect_backoff_max = Float(reconnect_backoff_max)
|
|
44
|
+
@health_check = health_check
|
|
45
|
+
@health_interval = Float(health_interval)
|
|
46
|
+
@health_timeout = Float(health_timeout)
|
|
47
|
+
@cancel_pinned_on_abort = cancel_pinned_on_abort
|
|
48
|
+
|
|
49
|
+
@drivers = []
|
|
50
|
+
@driver_backoff = []
|
|
51
|
+
@driver_attempts = []
|
|
52
|
+
@driver_last_health = []
|
|
53
|
+
@rr = 0
|
|
54
|
+
@reconnects = 0
|
|
55
|
+
@health_failures = 0
|
|
56
|
+
@supervisor_error = nil
|
|
57
|
+
@supervisor = nil
|
|
58
|
+
|
|
59
|
+
@pinned_free = []
|
|
60
|
+
@pinned_in_use = {}
|
|
61
|
+
@pinned_gate = nil
|
|
62
|
+
@pinned_error = nil
|
|
63
|
+
@pinned_active = 0
|
|
64
|
+
@pinned_owners = Hash.new(0)
|
|
65
|
+
@pinned_idle = Async::Notification.new
|
|
66
|
+
|
|
67
|
+
@started = false
|
|
68
|
+
@closing = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def start(parent: Async::Task.current)
|
|
72
|
+
raise Error, "pool already started" if @started
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
@pipeline_size.times { @drivers << start_pipeline_driver(parent) }
|
|
76
|
+
@driver_backoff = Array.new(@drivers.size, 0.0)
|
|
77
|
+
@driver_attempts = Array.new(@drivers.size, 0)
|
|
78
|
+
@driver_last_health = Array.new(@drivers.size, monotonic)
|
|
79
|
+
@pinned_gate = Async::Semaphore.new(@pinned_size) if @pinned_size.positive?
|
|
80
|
+
rescue Exception
|
|
81
|
+
cleanup_partial_start
|
|
82
|
+
raise
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
@started = true
|
|
86
|
+
@supervisor = parent.async { supervise } if @reconnect || @health_check
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def closing? = @closing
|
|
91
|
+
|
|
92
|
+
def pipeline_driver
|
|
93
|
+
ensure_available!
|
|
94
|
+
|
|
95
|
+
driver, @rr = PoolOps.select_driver(@drivers, @rr)
|
|
96
|
+
raise NotDispatchedError, "no live pipeline connections; request was not dispatched" unless driver
|
|
97
|
+
|
|
98
|
+
driver
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def with_pinned
|
|
102
|
+
ensure_available!
|
|
103
|
+
raise @pinned_error if @pinned_error
|
|
104
|
+
raise Error, "pinned pool is disabled (pinned_size=0)" if @pinned_size.zero?
|
|
105
|
+
|
|
106
|
+
@pinned_gate.acquire do
|
|
107
|
+
raise @pinned_error if @pinned_error
|
|
108
|
+
raise ShutdownError, "pool is closing" if @closing
|
|
109
|
+
|
|
110
|
+
owner = Async::Task.current
|
|
111
|
+
conn = nil
|
|
112
|
+
@pinned_active += 1
|
|
113
|
+
@pinned_owners[owner] += 1
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
conn = @pinned_free.pop || PoolOps.new_connection(@connection_args)
|
|
117
|
+
raise @pinned_error if @pinned_error
|
|
118
|
+
raise ShutdownError, "pool is closing" if @closing
|
|
119
|
+
|
|
120
|
+
@pinned_in_use[conn] = owner
|
|
121
|
+
yield conn
|
|
122
|
+
ensure
|
|
123
|
+
@pinned_in_use.delete(conn) if conn
|
|
124
|
+
release_pinned(owner, conn)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def stats
|
|
130
|
+
{
|
|
131
|
+
pipeline: {
|
|
132
|
+
size: @pipeline_size,
|
|
133
|
+
live: @drivers.count(&:available?),
|
|
134
|
+
drivers: @drivers.map(&:stats)
|
|
135
|
+
},
|
|
136
|
+
pinned: {
|
|
137
|
+
size: @pinned_size,
|
|
138
|
+
active: @pinned_active,
|
|
139
|
+
free: @pinned_free.size,
|
|
140
|
+
in_use: @pinned_in_use.size
|
|
141
|
+
},
|
|
142
|
+
reconnects: @reconnects,
|
|
143
|
+
health_failures: @health_failures,
|
|
144
|
+
supervisor_error: @supervisor_error&.message,
|
|
145
|
+
closing: @closing,
|
|
146
|
+
pinned_error: @pinned_error&.message
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def graceful_close
|
|
151
|
+
return unless @started
|
|
152
|
+
|
|
153
|
+
if @pinned_owners[Async::Task.current].positive?
|
|
154
|
+
raise Error, "cannot close the pool from inside Client#session/transaction"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@closing = true
|
|
158
|
+
@started = false
|
|
159
|
+
stop_supervisor
|
|
160
|
+
@drivers.each(&:graceful_close)
|
|
161
|
+
wait_for_pinned_idle
|
|
162
|
+
close_free_pinned
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def abort!
|
|
167
|
+
return unless @started || @closing
|
|
168
|
+
|
|
169
|
+
@closing = true
|
|
170
|
+
@started = false
|
|
171
|
+
stop_supervisor
|
|
172
|
+
cancel_in_use_pinned if @cancel_pinned_on_abort
|
|
173
|
+
@drivers.each(&:abort!)
|
|
174
|
+
close_free_pinned
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def supervise
|
|
181
|
+
parent = Async::Task.current
|
|
182
|
+
until @closing
|
|
183
|
+
begin
|
|
184
|
+
reap_and_replace(parent) if @reconnect
|
|
185
|
+
health_probe if @health_check
|
|
186
|
+
@supervisor_error = nil
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
# Keep the loop alive: one probe/reconnect failure must not permanently
|
|
189
|
+
# disable health checks and replacement for the worker lifetime.
|
|
190
|
+
@supervisor_error = e
|
|
191
|
+
end
|
|
192
|
+
parent.sleep(@reconnect_interval)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def reap_and_replace(parent)
|
|
197
|
+
now = monotonic
|
|
198
|
+
ensure_driver_slots!
|
|
199
|
+
|
|
200
|
+
@drivers.each_index do |index|
|
|
201
|
+
driver = @drivers[index]
|
|
202
|
+
next if driver.available?
|
|
203
|
+
next unless driver.dead?
|
|
204
|
+
next if now < (@driver_backoff[index] || 0.0)
|
|
205
|
+
|
|
206
|
+
begin
|
|
207
|
+
@drivers[index] = start_pipeline_driver(parent)
|
|
208
|
+
@driver_backoff[index] = 0.0
|
|
209
|
+
@driver_attempts[index] = 0
|
|
210
|
+
@driver_last_health[index] = monotonic
|
|
211
|
+
@reconnects += 1
|
|
212
|
+
rescue StandardError
|
|
213
|
+
@driver_attempts[index] = (@driver_attempts[index] || 0) + 1
|
|
214
|
+
@driver_backoff[index] = now + next_backoff(@driver_attempts[index])
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def health_probe
|
|
220
|
+
now = monotonic
|
|
221
|
+
ensure_driver_slots!
|
|
222
|
+
|
|
223
|
+
@drivers.each_index do |index|
|
|
224
|
+
driver = @drivers[index]
|
|
225
|
+
next unless driver.available?
|
|
226
|
+
next unless driver.load.zero?
|
|
227
|
+
next if now - (@driver_last_health[index] || 0.0) < @health_interval
|
|
228
|
+
|
|
229
|
+
@driver_last_health[index] = now
|
|
230
|
+
next if driver.health_check(@health_timeout)
|
|
231
|
+
|
|
232
|
+
@health_failures += 1
|
|
233
|
+
driver.abort!
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def ensure_driver_slots!
|
|
238
|
+
size = @drivers.size
|
|
239
|
+
@driver_backoff = Array.new(size, 0.0) if @driver_backoff.nil? || @driver_backoff.size != size
|
|
240
|
+
@driver_attempts = Array.new(size, 0) if @driver_attempts.nil? || @driver_attempts.size != size
|
|
241
|
+
@driver_last_health = Array.new(size, 0.0) if @driver_last_health.nil? || @driver_last_health.size != size
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def next_backoff(attempts)
|
|
245
|
+
delay = @reconnect_interval * (2**(attempts - 1))
|
|
246
|
+
delay < @reconnect_backoff_max ? delay : @reconnect_backoff_max
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def cancel_in_use_pinned
|
|
250
|
+
@pinned_in_use.keys.each do |conn|
|
|
251
|
+
conn.cancel if conn.respond_to?(:cancel)
|
|
252
|
+
rescue StandardError
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def stop_supervisor
|
|
258
|
+
@supervisor&.stop
|
|
259
|
+
@supervisor = nil
|
|
260
|
+
rescue StandardError
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def ensure_available!
|
|
265
|
+
raise Error, "pool not started" unless @started
|
|
266
|
+
raise ShutdownError, "pool is closing" if @closing
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def start_pipeline_driver(parent)
|
|
270
|
+
conn = PoolOps.new_connection(@connection_args)
|
|
271
|
+
driver = ConnectionDriver.new(conn, max_pending: @max_pending, max_in_flight: @max_in_flight)
|
|
272
|
+
driver.start(parent: parent)
|
|
273
|
+
rescue Exception
|
|
274
|
+
PoolOps.safe_close(conn) if conn
|
|
275
|
+
raise
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def release_pinned(owner, conn)
|
|
279
|
+
begin
|
|
280
|
+
if conn
|
|
281
|
+
if @closing
|
|
282
|
+
PoolOps.safe_close(conn)
|
|
283
|
+
else
|
|
284
|
+
recycled = recycle_pinned_connection(conn)
|
|
285
|
+
@pinned_free << recycled if recycled
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
ensure
|
|
289
|
+
@pinned_active -= 1
|
|
290
|
+
@pinned_owners[owner] -= 1
|
|
291
|
+
@pinned_owners.delete(owner) if @pinned_owners[owner].zero?
|
|
292
|
+
@pinned_idle.signal if @pinned_active.zero?
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def recycle_pinned_connection(conn)
|
|
297
|
+
if conn.server_version < DISCARD_SEQUENCES_SERVER_VERSION
|
|
298
|
+
PoolOps.safe_close(conn)
|
|
299
|
+
return PoolOps.new_connection(@connection_args)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
PoolOps.sanitize_pinned_connection(conn)
|
|
303
|
+
conn
|
|
304
|
+
rescue StandardError => cleanup_error
|
|
305
|
+
PoolOps.safe_close(conn)
|
|
306
|
+
|
|
307
|
+
begin
|
|
308
|
+
PoolOps.new_connection(@connection_args)
|
|
309
|
+
rescue StandardError => replacement_error
|
|
310
|
+
@pinned_error = ConnectionLostError.new(
|
|
311
|
+
"pinned connection reset failed (#{cleanup_error.class}: #{cleanup_error.message}) " \
|
|
312
|
+
"and replacement failed (#{replacement_error.class}: #{replacement_error.message})"
|
|
313
|
+
)
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def wait_for_pinned_idle
|
|
319
|
+
@pinned_idle.wait while @pinned_active.positive?
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def cleanup_partial_start
|
|
323
|
+
@drivers.each do |driver|
|
|
324
|
+
driver.abort!
|
|
325
|
+
rescue StandardError
|
|
326
|
+
nil
|
|
327
|
+
end
|
|
328
|
+
@drivers.clear
|
|
329
|
+
close_free_pinned
|
|
330
|
+
@started = false
|
|
331
|
+
@closing = false
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def close_free_pinned
|
|
335
|
+
@pinned_free.each { |conn| PoolOps.safe_close(conn) }
|
|
336
|
+
@pinned_free.clear
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
module PoolOps
|
|
343
|
+
module_function
|
|
344
|
+
|
|
345
|
+
def positive_integer!(value, name)
|
|
346
|
+
integer = Integer(value)
|
|
347
|
+
raise ArgumentError, "#{name} must be >= 1" if integer < 1
|
|
348
|
+
|
|
349
|
+
integer
|
|
350
|
+
rescue ArgumentError, TypeError
|
|
351
|
+
raise ArgumentError, "#{name} must be an integer >= 1"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def nonnegative_integer!(value, name)
|
|
355
|
+
integer = Integer(value)
|
|
356
|
+
raise ArgumentError, "#{name} must be >= 0" if integer.negative?
|
|
357
|
+
|
|
358
|
+
integer
|
|
359
|
+
rescue ArgumentError, TypeError
|
|
360
|
+
raise ArgumentError, "#{name} must be an integer >= 0"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def select_driver(drivers, rr)
|
|
364
|
+
min_load = nil
|
|
365
|
+
count = 0
|
|
366
|
+
drivers.each do |driver|
|
|
367
|
+
next unless driver.available?
|
|
368
|
+
|
|
369
|
+
load = driver.load
|
|
370
|
+
if min_load.nil? || load < min_load
|
|
371
|
+
min_load = load
|
|
372
|
+
count = 1
|
|
373
|
+
elsif load == min_load
|
|
374
|
+
count += 1
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
return [nil, rr] if count.zero?
|
|
378
|
+
|
|
379
|
+
target = rr % count
|
|
380
|
+
index = 0
|
|
381
|
+
drivers.each do |driver|
|
|
382
|
+
next unless driver.available?
|
|
383
|
+
next unless driver.load == min_load
|
|
384
|
+
|
|
385
|
+
return [driver, rr + 1] if index == target
|
|
386
|
+
|
|
387
|
+
index += 1
|
|
388
|
+
end
|
|
389
|
+
[nil, rr]
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def new_connection(connection_args)
|
|
393
|
+
connection_args.nil? ? PG::Connection.new : PG::Connection.new(connection_args)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def sanitize_pinned_connection(conn)
|
|
397
|
+
raise ConnectionLostError, "pinned connection is closed" if conn.finished?
|
|
398
|
+
raise ConnectionLostError, "pinned connection is bad" unless conn.status == PG::CONNECTION_OK
|
|
399
|
+
|
|
400
|
+
case conn.transaction_status
|
|
401
|
+
when PG::PQTRANS_IDLE
|
|
402
|
+
nil
|
|
403
|
+
when PG::PQTRANS_INTRANS, PG::PQTRANS_INERROR
|
|
404
|
+
conn.exec("ROLLBACK")
|
|
405
|
+
else
|
|
406
|
+
raise ConnectionLostError,
|
|
407
|
+
"pinned connection returned in unsafe transaction state #{conn.transaction_status}"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
conn.exec("DISCARD ALL")
|
|
411
|
+
true
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def safe_close(conn)
|
|
415
|
+
conn.close unless conn.finished?
|
|
416
|
+
rescue StandardError
|
|
417
|
+
nil
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/notification"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module PgPipeline
|
|
8
|
+
class Request
|
|
9
|
+
attr_reader :sql, :params, :condition
|
|
10
|
+
attr_accessor :state, :cancelled, :settled, :result_seen, :query_boundary_seen,
|
|
11
|
+
:result, :error
|
|
12
|
+
|
|
13
|
+
def initialize(sql:, params: nil)
|
|
14
|
+
@sql = sql.to_s.dup.freeze
|
|
15
|
+
@params = RequestOps.snapshot_params(params)
|
|
16
|
+
@state = :new
|
|
17
|
+
@condition = Async::Notification.new
|
|
18
|
+
@cancelled = false
|
|
19
|
+
@settled = false
|
|
20
|
+
@result_seen = false
|
|
21
|
+
@query_boundary_seen = false
|
|
22
|
+
@result = nil
|
|
23
|
+
@error = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def queued! = RequestOps.transition!(self, :new, :queued)
|
|
27
|
+
def dispatched! = RequestOps.transition!(self, :queued, :dispatched)
|
|
28
|
+
def accept_result(result) = RequestOps.accept_result(self, result)
|
|
29
|
+
def record_error!(error, result: nil) = RequestOps.record_error!(self, error, result)
|
|
30
|
+
def query_boundary! = RequestOps.query_boundary!(self)
|
|
31
|
+
def finish! = RequestOps.finish!(self)
|
|
32
|
+
def reject!(error) = RequestOps.reject!(self, error)
|
|
33
|
+
def cancel! = RequestOps.cancel!(self)
|
|
34
|
+
def wait = RequestOps.wait(self)
|
|
35
|
+
|
|
36
|
+
def query_boundary_seen? = @query_boundary_seen
|
|
37
|
+
def cancelled? = @cancelled
|
|
38
|
+
def settled? = @settled
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module RequestOps
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
def snapshot_params(params)
|
|
45
|
+
values = params.nil? ? [] : params
|
|
46
|
+
raise ArgumentError, "params must be an Array" unless values.is_a?(Array)
|
|
47
|
+
|
|
48
|
+
values.map { |value| snapshot_value(value) }.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def snapshot_value(value)
|
|
52
|
+
case value
|
|
53
|
+
when String
|
|
54
|
+
value.dup.freeze
|
|
55
|
+
when Hash
|
|
56
|
+
value.each_with_object({}) do |(key, item), copy|
|
|
57
|
+
copy[key] = item.is_a?(String) ? item.dup.freeze : item
|
|
58
|
+
end.freeze
|
|
59
|
+
else
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def transition!(req, from, to)
|
|
65
|
+
unless req.state == from
|
|
66
|
+
raise ProtocolError, "invalid request transition #{req.state.inspect} -> #{to.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
req.state = to
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def accept_result(req, result)
|
|
73
|
+
assert_result_slot!(req)
|
|
74
|
+
req.result_seen = true
|
|
75
|
+
|
|
76
|
+
if req.cancelled
|
|
77
|
+
clear_result(result)
|
|
78
|
+
else
|
|
79
|
+
req.result = result
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def record_error!(req, error, result)
|
|
84
|
+
assert_result_slot!(req)
|
|
85
|
+
req.result_seen = true
|
|
86
|
+
|
|
87
|
+
if req.cancelled
|
|
88
|
+
clear_result(result)
|
|
89
|
+
else
|
|
90
|
+
req.error ||= error
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def query_boundary!(req)
|
|
95
|
+
raise ProtocolError, "query boundary before query result" unless req.result_seen
|
|
96
|
+
raise ProtocolError, "duplicate query boundary" if req.query_boundary_seen
|
|
97
|
+
|
|
98
|
+
req.query_boundary_seen = true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def finish!(req)
|
|
102
|
+
raise ProtocolError, "sync arrived before query boundary" unless req.query_boundary_seen
|
|
103
|
+
return if req.settled
|
|
104
|
+
|
|
105
|
+
req.settled = true
|
|
106
|
+
req.state = :done
|
|
107
|
+
req.condition.signal unless req.cancelled
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def reject!(req, error)
|
|
111
|
+
return if req.settled
|
|
112
|
+
|
|
113
|
+
clear_result(req.result)
|
|
114
|
+
req.result = nil
|
|
115
|
+
req.error ||= error
|
|
116
|
+
req.settled = true
|
|
117
|
+
req.state = :done
|
|
118
|
+
req.condition.signal unless req.cancelled
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def cancel!(req)
|
|
122
|
+
return if req.cancelled
|
|
123
|
+
|
|
124
|
+
req.cancelled = true
|
|
125
|
+
clear_result(req.result)
|
|
126
|
+
req.result = nil
|
|
127
|
+
req.error.clear_result! if req.error.respond_to?(:clear_result!)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def wait(req)
|
|
131
|
+
req.condition.wait unless req.settled
|
|
132
|
+
raise req.error if req.error
|
|
133
|
+
|
|
134
|
+
req.result
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def assert_result_slot!(req)
|
|
138
|
+
raise ProtocolError, "result arrived after query boundary" if req.query_boundary_seen
|
|
139
|
+
raise ProtocolError, "multiple results for one pipeline unit" if req.result_seen
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def clear_result(result)
|
|
143
|
+
result.clear if result.respond_to?(:clear)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pg"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module PgPipeline
|
|
8
|
+
class ServerCaps
|
|
9
|
+
REQUIRED_LIBPQ_VERSION = 140_000
|
|
10
|
+
FAST_SYNC_LIBPQ_VERSION = 170_000
|
|
11
|
+
PROTOCOL_VERSION = 3
|
|
12
|
+
|
|
13
|
+
attr_reader :libpq_version, :protocol_version, :pipeline_api,
|
|
14
|
+
:fast_sync_api, :raw_pipeline_sync_api
|
|
15
|
+
|
|
16
|
+
def initialize(libpq_version:, protocol_version:, pipeline_api: true,
|
|
17
|
+
fast_sync_api: false, raw_pipeline_sync_api: false)
|
|
18
|
+
@libpq_version = Integer(libpq_version)
|
|
19
|
+
@protocol_version = Integer(protocol_version)
|
|
20
|
+
@pipeline_api = pipeline_api
|
|
21
|
+
@fast_sync_api = fast_sync_api
|
|
22
|
+
@raw_pipeline_sync_api = raw_pipeline_sync_api
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_connection(conn) = Caps.from_connection(conn)
|
|
26
|
+
def supported? = Caps.supported?(self)
|
|
27
|
+
def assert_supported! = Caps.assert_supported!(self)
|
|
28
|
+
def fast_sync? = Caps.fast_sync?(self)
|
|
29
|
+
def place_sync(conn) = Caps.place_sync(self, conn)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module Caps
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def from_connection(conn)
|
|
36
|
+
ServerCaps.new(
|
|
37
|
+
libpq_version: PG.library_version,
|
|
38
|
+
protocol_version: conn.protocol_version,
|
|
39
|
+
pipeline_api: pipeline_api?(conn),
|
|
40
|
+
fast_sync_api: conn.respond_to?(:send_pipeline_sync),
|
|
41
|
+
raw_pipeline_sync_api: conn.respond_to?(:sync_pipeline_sync)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def pipeline_api?(conn)
|
|
46
|
+
conn.respond_to?(:enter_pipeline_mode) &&
|
|
47
|
+
conn.respond_to?(:exit_pipeline_mode) &&
|
|
48
|
+
conn.respond_to?(:pipeline_status) &&
|
|
49
|
+
(conn.respond_to?(:sync_pipeline_sync) || conn.respond_to?(:pipeline_sync))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def supported?(caps)
|
|
53
|
+
caps.libpq_version >= ServerCaps::REQUIRED_LIBPQ_VERSION &&
|
|
54
|
+
caps.protocol_version == ServerCaps::PROTOCOL_VERSION &&
|
|
55
|
+
caps.pipeline_api
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def assert_supported!(caps)
|
|
59
|
+
return true if supported?(caps)
|
|
60
|
+
|
|
61
|
+
raise UnsupportedServerError,
|
|
62
|
+
"pg_pipeline requires libpq >= 14, PostgreSQL protocol v3, and " \
|
|
63
|
+
"ruby-pg pipeline bindings; libpq=#{caps.libpq_version}, " \
|
|
64
|
+
"protocol=#{caps.protocol_version}, pipeline_api=#{caps.pipeline_api}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fast_sync?(caps)
|
|
68
|
+
caps.libpq_version >= ServerCaps::FAST_SYNC_LIBPQ_VERSION && caps.fast_sync_api
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def place_sync(caps, conn)
|
|
72
|
+
if fast_sync?(caps)
|
|
73
|
+
conn.send_pipeline_sync
|
|
74
|
+
elsif caps.raw_pipeline_sync_api
|
|
75
|
+
conn.sync_pipeline_sync
|
|
76
|
+
else
|
|
77
|
+
conn.pipeline_sync
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module PgPipeline
|
|
6
|
+
class Session
|
|
7
|
+
attr_reader :owner_fiber
|
|
8
|
+
|
|
9
|
+
def initialize(conn)
|
|
10
|
+
@conn = conn
|
|
11
|
+
@active = true
|
|
12
|
+
@owner_fiber = Fiber.current
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def query(sql, params = []) = SessionOps.query(self, sql, params)
|
|
16
|
+
def exec(sql) = SessionOps.exec(self, sql)
|
|
17
|
+
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)
|
|
19
|
+
def active? = @active
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_accessor :conn, :active
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module SessionOps
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def ensure_active!(session)
|
|
30
|
+
conn = connection(session)
|
|
31
|
+
raise Error, "session handle is no longer active" unless session.active? && conn
|
|
32
|
+
return if Fiber.current.equal?(session.owner_fiber)
|
|
33
|
+
|
|
34
|
+
raise Error, "session handle is fiber-local and cannot be used from another fiber"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def query(session, sql, params)
|
|
38
|
+
ensure_active!(session)
|
|
39
|
+
connection(session).exec_params(sql, params)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def exec(session, sql)
|
|
43
|
+
ensure_active!(session)
|
|
44
|
+
connection(session).exec(sql)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def prepare(session, name, sql, param_types)
|
|
48
|
+
ensure_active!(session)
|
|
49
|
+
if param_types
|
|
50
|
+
connection(session).prepare(name, sql, param_types)
|
|
51
|
+
else
|
|
52
|
+
connection(session).prepare(name, sql)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def exec_prepared(session, name, params)
|
|
57
|
+
ensure_active!(session)
|
|
58
|
+
connection(session).exec_prepared(name, params)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def close!(session)
|
|
62
|
+
session.__send__(:active=, false)
|
|
63
|
+
session.__send__(:conn=, nil)
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def connection(session)
|
|
68
|
+
session.__send__(:conn)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|