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.
@@ -0,0 +1,502 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg"
4
+ require "async"
5
+ require "async/queue"
6
+
7
+ require_relative "errors"
8
+ require_relative "bounded_queue"
9
+ require_relative "server_caps"
10
+ require_relative "request"
11
+
12
+ module PgPipeline
13
+ class ConnectionDriver
14
+ DEFAULT_MAX_PENDING = 256
15
+ DEFAULT_MAX_IN_FLIGHT = 64
16
+
17
+ attr_reader :conn, :caps, :max_pending, :max_in_flight
18
+ attr_accessor :socket, :requests, :events, :reader_rearm, :writer_commands,
19
+ :inflight, :dispatching, :submitting, :accepting, :running,
20
+ :draining, :needs_flush, :writer_armed, :request_event_pending,
21
+ :owner_task, :reader_task, :writer_task
22
+
23
+ def initialize(conn, max_pending: DEFAULT_MAX_PENDING, max_in_flight: DEFAULT_MAX_IN_FLIGHT)
24
+ @max_pending = DriverOps.positive_integer!(max_pending, :max_pending)
25
+ @max_in_flight = DriverOps.positive_integer!(max_in_flight, :max_in_flight)
26
+
27
+ @conn = conn
28
+ @caps = ServerCaps.from_connection(conn)
29
+ @caps.assert_supported!
30
+
31
+ @requests = BoundedQueue.new(@max_pending)
32
+ @events = Async::Queue.new
33
+ @reader_rearm = Async::Queue.new
34
+ @writer_commands = Async::Queue.new
35
+
36
+ @inflight = []
37
+ @dispatching = nil
38
+ @submitting = 0
39
+
40
+ @accepting = false
41
+ @running = false
42
+ @draining = false
43
+ @needs_flush = false
44
+ @writer_armed = false
45
+ @request_event_pending = false
46
+
47
+ @socket = nil
48
+ @owner_task = nil
49
+ @reader_task = nil
50
+ @writer_task = nil
51
+ end
52
+
53
+ def start(parent: Async::Task.current) = DriverOps.start(self, parent)
54
+ def submit(request) = DriverOps.submit(self, request)
55
+ def load = @requests.size + @inflight.size + @submitting + (@dispatching ? 1 : 0)
56
+ def available? = @accepting && @running
57
+ def dead? = !@running && !@accepting
58
+
59
+ def stats
60
+ {
61
+ available: available?,
62
+ load: load,
63
+ pending: @requests.size,
64
+ in_flight: @inflight.size,
65
+ submitting: @submitting,
66
+ needs_flush: @needs_flush
67
+ }
68
+ end
69
+
70
+ def health_check(timeout)
71
+ return true unless available?
72
+
73
+ probe = Request.new(sql: "SELECT 1", params: [])
74
+ begin
75
+ submit(probe)
76
+ Async::Task.current.with_timeout(timeout) { probe.wait }
77
+ true
78
+ rescue Async::TimeoutError
79
+ DriverOps.abort_timed_out_health_probe(self, probe)
80
+ rescue QueryError, PipelineAbortedError
81
+ true
82
+ rescue ShutdownError, NotDispatchedError
83
+ true
84
+ rescue ConnectionLostError, PG::Error
85
+ false
86
+ ensure
87
+ probe.cancel! unless probe.settled?
88
+ end
89
+ end
90
+
91
+ def graceful_close = DriverOps.graceful_close(self)
92
+
93
+ def abort!(error = ConnectionLostError.new("connection aborted"))
94
+ DriverOps.abort!(self, error)
95
+ end
96
+ end
97
+
98
+ module DriverOps
99
+ module_function
100
+
101
+ SUCCESS_STATUSES = [
102
+ PG::PGRES_EMPTY_QUERY,
103
+ PG::PGRES_COMMAND_OK,
104
+ PG::PGRES_TUPLES_OK
105
+ ].freeze
106
+
107
+ COPY_STATUSES = [
108
+ PG::PGRES_COPY_IN,
109
+ PG::PGRES_COPY_OUT,
110
+ PG::PGRES_COPY_BOTH
111
+ ].freeze
112
+
113
+ def positive_integer!(value, name)
114
+ integer = Integer(value)
115
+ raise ArgumentError, "#{name} must be >= 1" if integer < 1
116
+
117
+ integer
118
+ rescue ArgumentError, TypeError
119
+ raise ArgumentError, "#{name} must be an integer >= 1"
120
+ end
121
+
122
+ def start(d, parent)
123
+ raise Error, "driver already started" if d.running
124
+
125
+ d.conn.setnonblocking(true)
126
+ d.conn.enter_pipeline_mode
127
+
128
+ d.socket = d.conn.socket_io
129
+ d.accepting = true
130
+ d.running = true
131
+
132
+ d.reader_task = parent.async { reader_watcher(d) }
133
+ d.writer_task = parent.async { writer_watcher(d) }
134
+ d.owner_task = parent.async { owner_loop(d) }
135
+ d
136
+ rescue Exception
137
+ d.accepting = false
138
+ d.running = false
139
+ stop_watchers(d)
140
+ safe_close_conn(d)
141
+ raise
142
+ end
143
+
144
+ def submit(d, request)
145
+ raise ShutdownError, "driver is not accepting work" unless d.accepting
146
+ raise ProtocolError, "request must be new before submit" unless request.state == :new
147
+
148
+ d.submitting += 1
149
+ begin
150
+ d.requests.enqueue(request)
151
+ request.queued!
152
+ notify_requests(d)
153
+ ensure
154
+ d.submitting -= 1
155
+ d.events.enqueue(:submission_finished) if d.draining && d.running && d.submitting.zero?
156
+ end
157
+
158
+ request
159
+ end
160
+
161
+ def graceful_close(d)
162
+ return unless d.running
163
+
164
+ d.accepting = false
165
+ d.requests.close(ShutdownError.new("driver is closing"))
166
+ d.events.enqueue(:close)
167
+ d.owner_task.wait
168
+ nil
169
+ end
170
+
171
+ def abort!(d, error)
172
+ return unless d.running
173
+
174
+ d.accepting = false
175
+ d.requests.close(not_dispatched_error(error))
176
+ d.events.enqueue([:abort, error])
177
+ d.owner_task.wait unless Async::Task.current.equal?(d.owner_task)
178
+ nil
179
+ end
180
+
181
+ def abort_timed_out_health_probe(d, probe)
182
+ return true if probe.settled? || !d.running
183
+ return true unless exclusive_health_probe?(d, probe)
184
+
185
+ abort!(d, ConnectionLostError.new("idle health check timed out"))
186
+ false
187
+ end
188
+
189
+ def exclusive_health_probe?(d, probe)
190
+ d.accepting &&
191
+ d.dispatching.nil? &&
192
+ d.submitting.zero? &&
193
+ d.requests.empty? &&
194
+ d.inflight.length == 1 &&
195
+ d.inflight.first.equal?(probe)
196
+ end
197
+
198
+ def owner_loop(d)
199
+ while d.running
200
+ event = d.events.dequeue
201
+
202
+ case event
203
+ when :requests
204
+ d.request_event_pending = false
205
+ when :submission_finished
206
+ nil
207
+ when :readable
208
+ begin
209
+ read_available(d)
210
+ ensure
211
+ d.reader_rearm.enqueue(:rearm) if d.running
212
+ end
213
+ when :writable
214
+ d.writer_armed = false
215
+ flush_output(d)
216
+ when :close
217
+ d.draining = true
218
+ when Array
219
+ tag, payload = event
220
+ if tag == :abort
221
+ fatal_close(d, payload)
222
+ break
223
+ end
224
+ end
225
+
226
+ drain_results(d)
227
+ pump_requests(d)
228
+ drain_results(d)
229
+ finish_graceful_close(d) if d.draining && drained?(d)
230
+ end
231
+ rescue StandardError => e
232
+ fatal_close(d, ConnectionLostError.new("driver crashed: #{e.class}: #{e.message}"))
233
+ ensure
234
+ fatal_close(d, ShutdownError.new("driver owner stopped before shutdown completed")) if d.running
235
+ end
236
+
237
+ def notify_requests(d)
238
+ return if d.request_event_pending
239
+
240
+ d.request_event_pending = true
241
+ d.events.enqueue(:requests)
242
+ end
243
+
244
+ def pump_requests(d)
245
+ dispatched = false
246
+
247
+ while d.inflight.size < d.max_in_flight && !d.requests.empty?
248
+ request = d.requests.dequeue
249
+ break unless request
250
+ next if request.cancelled?
251
+
252
+ d.dispatching = request
253
+ unless send_unit(d, request)
254
+ d.dispatching = nil
255
+ next
256
+ end
257
+
258
+ request.dispatched!
259
+ d.inflight << request
260
+ d.dispatching = nil
261
+ dispatched = true
262
+ end
263
+
264
+ flush_output(d) if dispatched || d.needs_flush
265
+ end
266
+
267
+ def send_unit(d, request)
268
+ begin
269
+ d.conn.send_query_params(request.sql, request.params)
270
+ rescue PG::UnableToSend => e
271
+ d.dispatching = nil
272
+ request.reject!(
273
+ NotDispatchedError.new("query was rejected before libpq accepted it: #{e.class}: #{e.message}")
274
+ )
275
+ return false if reusable_after_send_rejection?(d)
276
+
277
+ raise ConnectionLostError, "dispatch failed: #{e.class}: #{e.message}"
278
+ rescue PG::Error => e
279
+ d.dispatching = nil
280
+ request.reject!(
281
+ NotDispatchedError.new("query was rejected before libpq accepted it: #{e.class}: #{e.message}")
282
+ )
283
+ raise ConnectionLostError, "dispatch failed: #{e.class}: #{e.message}"
284
+ rescue StandardError => e
285
+ # ruby-pg prepares/encodes query parameters before calling PQsendQueryParams.
286
+ # A Ruby-side encoder/coercion exception therefore belongs only to this
287
+ # request and must not poison unrelated work already in the pipeline.
288
+ request.reject!(e)
289
+ return false
290
+ end
291
+
292
+ d.caps.place_sync(d.conn)
293
+ true
294
+ rescue PG::Error => e
295
+ raise ConnectionLostError, "dispatch Sync failed: #{e.class}: #{e.message}"
296
+ end
297
+
298
+ def reusable_after_send_rejection?(d)
299
+ !d.conn.finished? &&
300
+ d.conn.status == PG::CONNECTION_OK &&
301
+ d.conn.pipeline_status != PG::PQ_PIPELINE_OFF
302
+ rescue PG::Error
303
+ false
304
+ end
305
+
306
+ def flush_output(d)
307
+ return unless d.running
308
+
309
+ if d.conn.sync_flush
310
+ d.needs_flush = false
311
+ else
312
+ d.needs_flush = true
313
+ arm_writer(d)
314
+ end
315
+ rescue PG::Error => e
316
+ raise ConnectionLostError, "flush failed: #{e.class}: #{e.message}"
317
+ end
318
+
319
+ def arm_writer(d)
320
+ return if d.writer_armed
321
+
322
+ d.writer_armed = true
323
+ d.writer_commands.enqueue(:wait_writable)
324
+ end
325
+
326
+ def read_available(d)
327
+ d.conn.consume_input
328
+ drain_results(d)
329
+ rescue PG::Error => e
330
+ raise ConnectionLostError, "read failed: #{e.class}: #{e.message}"
331
+ end
332
+
333
+ def drain_results(d)
334
+ while !d.inflight.empty? && !d.conn.is_busy
335
+ result = d.conn.sync_get_result
336
+ request = d.inflight.first
337
+ raise ProtocolError, "result without an in-flight request" unless request
338
+
339
+ if result.nil?
340
+ request.query_boundary!
341
+ next
342
+ end
343
+
344
+ status = result.result_status
345
+
346
+ case status
347
+ when PG::PGRES_PIPELINE_SYNC
348
+ clear_result(result)
349
+ complete_front(d, request)
350
+ when PG::PGRES_PIPELINE_ABORTED
351
+ ensure_before_query_boundary!(request, status)
352
+ clear_result(result)
353
+ request.record_error!(PipelineAbortedError.new("pipeline unit aborted"))
354
+ when PG::PGRES_BAD_RESPONSE
355
+ clear_result(result)
356
+ raise ProtocolError, "server response was not understood"
357
+ when PG::PGRES_FATAL_ERROR
358
+ ensure_before_query_boundary!(request, status)
359
+ request.record_error!(query_error(result), result: result)
360
+ when *COPY_STATUSES
361
+ clear_result(result)
362
+ raise ProtocolError, "COPY is not supported on the multiplexed pipeline"
363
+ when *SUCCESS_STATUSES
364
+ ensure_before_query_boundary!(request, status)
365
+ request.accept_result(result)
366
+ else
367
+ clear_result(result)
368
+ raise ProtocolError, "unexpected pipeline result status #{status}"
369
+ end
370
+ end
371
+ end
372
+
373
+ def ensure_before_query_boundary!(request, status)
374
+ return unless request.query_boundary_seen?
375
+
376
+ raise ProtocolError, "result status #{status} arrived after query boundary"
377
+ end
378
+
379
+ def complete_front(d, request)
380
+ raise ProtocolError, "sync does not match FIFO front" unless request.equal?(d.inflight.first)
381
+
382
+ d.inflight.shift
383
+ request.finish!
384
+ end
385
+
386
+ def query_error(result)
387
+ message = result.error_message.to_s.strip
388
+ message = "query failed" if message.empty?
389
+ QueryError.new(message, cause_result: result)
390
+ end
391
+
392
+ def drained?(d)
393
+ d.submitting.zero? && d.requests.empty? && d.inflight.empty? && !d.needs_flush && d.dispatching.nil?
394
+ end
395
+
396
+ def finish_graceful_close(d)
397
+ d.accepting = false
398
+ d.running = false
399
+
400
+ begin
401
+ d.conn.exit_pipeline_mode
402
+ rescue PG::Error => e
403
+ fail_all(d, ConnectionLostError.new("failed to exit pipeline mode: #{e.message}"))
404
+ ensure
405
+ stop_watchers(d)
406
+ safe_close_conn(d)
407
+ end
408
+ end
409
+
410
+ def fatal_close(d, error)
411
+ return unless d.running || d.accepting
412
+
413
+ d.accepting = false
414
+ d.running = false
415
+ d.requests.close(not_dispatched_error(error))
416
+ fail_all(d, error)
417
+ stop_watchers(d)
418
+ safe_close_conn(d)
419
+ end
420
+
421
+ def fail_all(d, error)
422
+ uncertain = []
423
+ uncertain << d.dispatching if d.dispatching
424
+ uncertain.concat(d.inflight)
425
+ queued = d.requests.drain
426
+
427
+ uncertain.compact.uniq.each do |request|
428
+ request.reject!(indeterminate_error(error)) unless request.settled?
429
+ end
430
+
431
+ queued.each do |request|
432
+ request.reject!(not_dispatched_error(error)) unless request.settled?
433
+ end
434
+
435
+ d.dispatching = nil
436
+ d.inflight.clear
437
+ end
438
+
439
+ def not_dispatched_error(error)
440
+ return error if error.is_a?(NotDispatchedError)
441
+
442
+ NotDispatchedError.new("#{error.message}; request was not dispatched")
443
+ end
444
+
445
+ def indeterminate_error(error)
446
+ return error if error.is_a?(IndeterminateResultError)
447
+
448
+ IndeterminateResultError.new(
449
+ "#{error.message}; request was dispatched but its Sync was not observed, " \
450
+ "so execution/commit outcome is indeterminate"
451
+ )
452
+ end
453
+
454
+ def reader_watcher(d)
455
+ while d.running
456
+ d.socket.wait_readable
457
+ break unless d.running
458
+
459
+ d.events.enqueue(:readable)
460
+ command = d.reader_rearm.dequeue
461
+ break unless command == :rearm && d.running
462
+ end
463
+ rescue StandardError => e
464
+ if d.running
465
+ d.events.enqueue([:abort, ConnectionLostError.new("reader watcher failed: #{e.class}: #{e.message}")])
466
+ end
467
+ end
468
+
469
+ def writer_watcher(d)
470
+ while d.running
471
+ command = d.writer_commands.dequeue
472
+ break unless command == :wait_writable && d.running
473
+
474
+ d.socket.wait_writable
475
+ d.events.enqueue(:writable) if d.running
476
+ end
477
+ rescue StandardError => e
478
+ if d.running
479
+ d.events.enqueue([:abort, ConnectionLostError.new("writer watcher failed: #{e.class}: #{e.message}")])
480
+ end
481
+ end
482
+
483
+ def stop_watchers(d)
484
+ d.reader_task&.stop
485
+ d.writer_task&.stop
486
+ d.reader_task = nil
487
+ d.writer_task = nil
488
+ rescue StandardError
489
+ nil
490
+ end
491
+
492
+ def safe_close_conn(d)
493
+ d.conn.close unless d.conn.finished?
494
+ rescue StandardError
495
+ nil
496
+ end
497
+
498
+ def clear_result(result)
499
+ result.clear if result.respond_to?(:clear)
500
+ end
501
+ end
502
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgPipeline
4
+ class Error < StandardError; end
5
+ class UnsupportedServerError < Error; end
6
+ class UnsafeMultiplexError < Error; end
7
+ class PipelineAbortedError < Error; end
8
+ class ConnectionLostError < Error; end
9
+ class NotDispatchedError < ConnectionLostError; end
10
+ class IndeterminateResultError < ConnectionLostError; end
11
+ class ShutdownError < Error; end
12
+ class ProtocolError < Error; end
13
+
14
+ class QueryError < Error
15
+ attr_reader :cause_result
16
+
17
+ def initialize(message, cause_result: nil)
18
+ super(message)
19
+ @cause_result = cause_result
20
+ end
21
+
22
+ def clear_result!
23
+ @cause_result&.clear if @cause_result.respond_to?(:clear)
24
+ @cause_result = nil
25
+ end
26
+ end
27
+ end