pg_pipeline 0.2.4 → 0.3.0

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.
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pg"
4
- require "async"
5
- require "async/queue"
6
4
 
7
5
  require_relative "errors"
6
+ require_relative "runtime"
8
7
  require_relative "bounded_queue"
9
8
  require_relative "server_caps"
10
9
  require_relative "request"
@@ -20,6 +19,17 @@ module PgPipeline
20
19
  :draining, :needs_flush, :writer_armed, :request_event_pending,
21
20
  :owner_task, :reader_task, :writer_task
22
21
 
22
+ attr_writer :readable_events, :results_read, :units_completed, :flush_calls,
23
+ :flush_incomplete, :dispatches, :leaked_watchers
24
+
25
+ def readable_events = @readable_events || 0
26
+ def results_read = @results_read || 0
27
+ def units_completed = @units_completed || 0
28
+ def flush_calls = @flush_calls || 0
29
+ def flush_incomplete = @flush_incomplete || 0
30
+ def dispatches = @dispatches || 0
31
+ def leaked_watchers = @leaked_watchers || 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,30 +37,22 @@ 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
- @events = Async::Queue.new
33
- @reader_rearm = Async::Queue.new
34
- @writer_commands = Async::Queue.new
43
+ @events = Runtime::Queue.new
44
+ @reader_rearm = Runtime::Queue.new
45
+ @writer_commands = Runtime::Queue.new
35
46
 
36
- @inflight = []
37
- @dispatching = nil
38
- @submitting = 0
47
+ @accepting = @running = @draining = @needs_flush = @writer_armed = @request_event_pending = false
48
+ @readable_events = @results_read = @units_completed = @flush_calls =
49
+ @flush_incomplete = @dispatches = @leaked_watchers = @submitting = 0
50
+ @socket = @owner_task = @reader_task = @writer_task = @dispatching = nil
39
51
 
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
52
+ @inflight = []
51
53
  end
52
54
 
53
- def start(parent: Async::Task.current) = DriverOps.start(self, parent)
55
+ def start = DriverOps.start(self)
54
56
  def submit(request) = DriverOps.submit(self, request)
55
57
  def load = @requests.size + @inflight.size + @submitting + (@dispatching ? 1 : 0)
56
58
  def available? = @accepting && @running
@@ -63,29 +65,38 @@ module PgPipeline
63
65
  pending: @requests.size,
64
66
  in_flight: @inflight.size,
65
67
  submitting: @submitting,
66
- needs_flush: @needs_flush
68
+ needs_flush: @needs_flush,
69
+ fast_sync: @caps.fast_sync?,
70
+ readable_events: readable_events,
71
+ results_read: results_read,
72
+ units_completed: units_completed,
73
+ flush_calls: flush_calls,
74
+ flush_incomplete: flush_incomplete,
75
+ dispatches: dispatches,
76
+ units_per_readable: DriverOps.ratio(units_completed, readable_events),
77
+ results_per_readable: DriverOps.ratio(results_read, readable_events),
78
+ flush_calls_per_unit: DriverOps.ratio(flush_calls, units_completed),
79
+ leaked_watchers: leaked_watchers
67
80
  }
68
81
  end
69
82
 
70
83
  def health_check(timeout)
71
84
  return true unless available?
72
85
 
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
86
+ probe = Request.build("SELECT 1", nil)
87
+ submit(probe)
88
+ Runtime.with_timeout(timeout) { probe.wait }
89
+ true
90
+ rescue Runtime::TimeoutError
91
+ DriverOps.abort_timed_out_health_probe(self, probe)
92
+ rescue QueryError, PipelineAbortedError
93
+ true
94
+ rescue ShutdownError, NotDispatchedError
95
+ true
96
+ rescue ConnectionLostError, PG::Error
97
+ false
98
+ ensure
99
+ probe.cancel! if probe && !probe.settled?
89
100
  end
90
101
 
91
102
  def graceful_close = DriverOps.graceful_close(self)
@@ -96,19 +107,30 @@ module PgPipeline
96
107
  end
97
108
 
98
109
  module DriverOps
110
+ WATCHER_JOIN_TIMEOUT = 2.0
111
+ OWNER_JOIN_TIMEOUT = 5.0
112
+
99
113
  module_function
100
114
 
101
- SUCCESS_STATUSES = [
102
- PG::PGRES_EMPTY_QUERY,
103
- PG::PGRES_COMMAND_OK,
104
- PG::PGRES_TUPLES_OK
105
- ].freeze
115
+ def ratio(numerator, denominator)
116
+ return 0.0 if denominator.zero?
106
117
 
107
- COPY_STATUSES = [
108
- PG::PGRES_COPY_IN,
109
- PG::PGRES_COPY_OUT,
110
- PG::PGRES_COPY_BOTH
111
- ].freeze
118
+ (numerator.to_f / denominator).round(3)
119
+ end
120
+
121
+ def warn_flush_coupling_once(caps)
122
+ return if @flush_coupling_warned
123
+ return if caps.fast_sync?
124
+ return if ENV["PG_PIPELINE_SILENCE_WARNINGS"]
125
+
126
+ @flush_coupling_warned = true
127
+ warn(
128
+ "pg_pipeline: libpq #{caps.libpq_version} couples pipeline Sync with flush " \
129
+ "(no PQsendPipelineSync). Queries still pipeline and still amortise RTT, but " \
130
+ "one flush per unit caps local throughput; libpq >= 17 is recommended for " \
131
+ "maximum throughput. Set PG_PIPELINE_SILENCE_WARNINGS=1 to silence this."
132
+ )
133
+ end
112
134
 
113
135
  def positive_integer!(value, name)
114
136
  integer = Integer(value)
@@ -119,8 +141,9 @@ module PgPipeline
119
141
  raise ArgumentError, "#{name} must be an integer >= 1"
120
142
  end
121
143
 
122
- def start(d, parent)
144
+ def start(d)
123
145
  raise Error, "driver already started" if d.running
146
+ raise Error, "driver start requires an active Fiber scheduler" unless Fiber.scheduler
124
147
 
125
148
  d.conn.setnonblocking(true)
126
149
  d.conn.enter_pipeline_mode
@@ -129,15 +152,15 @@ module PgPipeline
129
152
  d.accepting = true
130
153
  d.running = true
131
154
 
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) }
155
+ d.reader_task = Runtime.spawn(name: :reader) { reader_watcher(d) }
156
+ d.writer_task = Runtime.spawn(name: :writer) { writer_watcher(d) }
157
+ d.owner_task = Runtime.spawn(name: :owner) { owner_loop(d) }
135
158
  d
136
159
  rescue Exception
137
160
  d.accepting = false
138
161
  d.running = false
139
- stop_watchers(d)
140
- safe_close_conn(d)
162
+ teardown_watchers(d)
163
+ join_owner(d)
141
164
  raise
142
165
  end
143
166
 
@@ -164,7 +187,7 @@ module PgPipeline
164
187
  d.accepting = false
165
188
  d.requests.close(ShutdownError.new("driver is closing"))
166
189
  d.events.enqueue(:close)
167
- d.owner_task.wait
190
+ join_owner(d)
168
191
  nil
169
192
  end
170
193
 
@@ -174,7 +197,7 @@ module PgPipeline
174
197
  d.accepting = false
175
198
  d.requests.close(not_dispatched_error(error))
176
199
  d.events.enqueue([:abort, error])
177
- d.owner_task.wait unless Async::Task.current.equal?(d.owner_task)
200
+ join_owner(d)
178
201
  nil
179
202
  end
180
203
 
@@ -196,7 +219,12 @@ module PgPipeline
196
219
  end
197
220
 
198
221
  def owner_loop(d)
199
- process_event(d, d.events.dequeue) while d.running
222
+ while d.running
223
+ event = d.events.dequeue
224
+ break if event.nil?
225
+
226
+ process_event(d, event)
227
+ end
200
228
  rescue StandardError => e
201
229
  fatal_close(d, ConnectionLostError.new("driver crashed: #{e.class}: #{e.message}"))
202
230
  ensure
@@ -213,6 +241,7 @@ module PgPipeline
213
241
  nil
214
242
  when :readable
215
243
  begin
244
+ d.readable_events += 1
216
245
  read_available(d)
217
246
  input_changed = true
218
247
  ensure
@@ -238,6 +267,7 @@ module PgPipeline
238
267
 
239
268
  def notify_requests(d)
240
269
  return if d.request_event_pending
270
+ return unless d.running
241
271
 
242
272
  d.request_event_pending = true
243
273
  d.events.enqueue(:requests)
@@ -260,6 +290,7 @@ module PgPipeline
260
290
  request.dispatched!
261
291
  d.inflight << request
262
292
  d.dispatching = nil
293
+ d.dispatches += 1
263
294
  dispatched = true
264
295
  end
265
296
 
@@ -286,9 +317,6 @@ module PgPipeline
286
317
  rescue ProtocolError
287
318
  raise
288
319
  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
320
  request.reject!(e)
293
321
  return false
294
322
  end
@@ -327,10 +355,13 @@ module PgPipeline
327
355
  def flush_output(d)
328
356
  return unless d.running
329
357
 
358
+ d.flush_calls += 1
359
+
330
360
  if d.conn.sync_flush
331
361
  d.needs_flush = false
332
362
  else
333
363
  d.needs_flush = true
364
+ d.flush_incomplete += 1
334
365
  arm_writer(d)
335
366
  end
336
367
  rescue PG::Error => e
@@ -339,6 +370,7 @@ module PgPipeline
339
370
 
340
371
  def arm_writer(d)
341
372
  return if d.writer_armed
373
+ return unless d.running
342
374
 
343
375
  d.writer_armed = true
344
376
  d.writer_commands.enqueue(:wait_writable)
@@ -351,42 +383,52 @@ module PgPipeline
351
383
  end
352
384
 
353
385
  def drain_results(d)
354
- while !d.inflight.empty? && !d.conn.is_busy
355
- result = d.conn.sync_get_result
356
- request = d.inflight.first
357
- raise ProtocolError, "result without an in-flight request" unless request
386
+ read = 0
358
387
 
359
- if result.nil?
360
- request.query_boundary!
361
- next
362
- end
363
-
364
- status = result.result_status
365
-
366
- case status
367
- when PG::PGRES_PIPELINE_SYNC
368
- clear_result(result)
369
- complete_front(d, request)
370
- when PG::PGRES_PIPELINE_ABORTED
371
- ensure_before_query_boundary!(request, status)
372
- clear_result(result)
373
- request.record_error!(PipelineAbortedError.new("pipeline unit aborted"))
374
- when PG::PGRES_BAD_RESPONSE
375
- clear_result(result)
376
- raise ProtocolError, "server response was not understood"
377
- when PG::PGRES_FATAL_ERROR
378
- ensure_before_query_boundary!(request, status)
379
- request.record_error!(query_error(result), result: result)
380
- when *COPY_STATUSES
381
- clear_result(result)
382
- raise ProtocolError, "COPY is not supported on the multiplexed pipeline"
383
- when *SUCCESS_STATUSES
384
- ensure_before_query_boundary!(request, status)
385
- request.accept_result(result)
386
- else
387
- clear_result(result)
388
- raise ProtocolError, "unexpected pipeline result status #{status}"
388
+ begin
389
+ while !d.inflight.empty? && !d.conn.is_busy
390
+ result = d.conn.sync_get_result
391
+ read += 1
392
+ request = d.inflight.first
393
+ raise ProtocolError, "result without an in-flight request" unless request
394
+
395
+ if result.nil?
396
+ request.query_boundary!
397
+ next
398
+ end
399
+
400
+ status = result.result_status
401
+
402
+ case status
403
+ when PG::PGRES_TUPLES_OK
404
+ ensure_before_query_boundary!(request, status)
405
+ request.accept_result(result)
406
+ when PG::PGRES_PIPELINE_SYNC
407
+ clear_result(result)
408
+ complete_front(d, request)
409
+ when PG::PGRES_COMMAND_OK, PG::PGRES_EMPTY_QUERY
410
+ ensure_before_query_boundary!(request, status)
411
+ request.accept_result(result)
412
+ when PG::PGRES_FATAL_ERROR
413
+ ensure_before_query_boundary!(request, status)
414
+ request.record_error!(query_error(result), result: result)
415
+ when PG::PGRES_PIPELINE_ABORTED
416
+ ensure_before_query_boundary!(request, status)
417
+ clear_result(result)
418
+ request.record_error!(PipelineAbortedError.new("pipeline unit aborted"))
419
+ when PG::PGRES_BAD_RESPONSE
420
+ clear_result(result)
421
+ raise ProtocolError, "server response was not understood"
422
+ when PG::PGRES_COPY_IN, PG::PGRES_COPY_OUT, PG::PGRES_COPY_BOTH
423
+ clear_result(result)
424
+ raise ProtocolError, "COPY is not supported on the multiplexed pipeline"
425
+ else
426
+ clear_result(result)
427
+ raise ProtocolError, "unexpected pipeline result status #{status}"
428
+ end
389
429
  end
430
+ ensure
431
+ d.results_read += read if read.positive?
390
432
  end
391
433
  end
392
434
 
@@ -400,6 +442,7 @@ module PgPipeline
400
442
  raise ProtocolError, "sync does not match FIFO front" unless request.equal?(d.inflight.first)
401
443
 
402
444
  d.inflight.shift
445
+ d.units_completed += 1
403
446
  request.finish!
404
447
  end
405
448
 
@@ -417,14 +460,11 @@ module PgPipeline
417
460
  d.accepting = false
418
461
  d.running = false
419
462
 
420
- begin
421
- d.conn.exit_pipeline_mode
422
- rescue PG::Error => e
423
- fail_all(d, ConnectionLostError.new("failed to exit pipeline mode: #{e.message}"))
424
- ensure
425
- stop_watchers(d)
426
- safe_close_conn(d)
427
- end
463
+ d.conn.exit_pipeline_mode
464
+ rescue PG::Error => e
465
+ fail_all(d, ConnectionLostError.new("failed to exit pipeline mode: #{e.message}"))
466
+ ensure
467
+ teardown_watchers(d)
428
468
  end
429
469
 
430
470
  def fatal_close(d, error)
@@ -434,8 +474,8 @@ module PgPipeline
434
474
  d.running = false
435
475
  d.requests.close(not_dispatched_error(error))
436
476
  fail_all(d, error)
437
- stop_watchers(d)
438
- safe_close_conn(d)
477
+
478
+ teardown_watchers(d)
439
479
  end
440
480
 
441
481
  def fail_all(d, error)
@@ -500,21 +540,87 @@ module PgPipeline
500
540
  end
501
541
  end
502
542
 
503
- def stop_watchers(d)
504
- reader = d.reader_task
505
- writer = d.writer_task
543
+ def teardown_watchers(d)
544
+ tasks = release_watchers(d)
545
+ close_wait_points(d)
546
+ begin
547
+ d.socket&.close
548
+ rescue StandardError
549
+ nil
550
+ end
551
+ stop_watchers(tasks)
552
+ join_watchers(d, tasks)
553
+ d.socket = nil
554
+ safe_close_conn(d)
555
+ nil
556
+ end
557
+
558
+ def release_watchers(d)
559
+ tasks = [d.reader_task, d.writer_task].compact
506
560
  d.reader_task = nil
507
561
  d.writer_task = nil
562
+ tasks
563
+ end
508
564
 
509
- [reader, writer].each do |task|
510
- task&.stop
511
- rescue Async::Cancel, StandardError
565
+ def close_wait_points(d)
566
+ [d.reader_rearm, d.writer_commands, d.events].each do |queue|
567
+ queue&.close
568
+ rescue StandardError
569
+ nil
570
+ end
571
+ end
572
+
573
+ def stop_watchers(tasks)
574
+ Array(tasks).each do |task|
575
+ task.stop
576
+ rescue Runtime::Cancel, StandardError
512
577
  nil
513
578
  end
514
579
 
515
580
  nil
516
581
  end
517
582
 
583
+ def join_watchers(d, tasks)
584
+ Array(tasks).each do |task|
585
+ task.wait(WATCHER_JOIN_TIMEOUT)
586
+ rescue Runtime::TimeoutError
587
+ d.leaked_watchers += 1
588
+ warn_leaked_task(d, task, WATCHER_JOIN_TIMEOUT)
589
+ rescue Runtime::Cancel, StandardError
590
+ nil
591
+ end
592
+
593
+ nil
594
+ end
595
+
596
+ def join_owner(d)
597
+ owner = d.owner_task
598
+ return if owner.nil?
599
+ return if Fiber.current.equal?(owner.fiber)
600
+
601
+ begin
602
+ owner.wait(OWNER_JOIN_TIMEOUT)
603
+ rescue Runtime::TimeoutError
604
+ d.leaked_watchers += 1
605
+ warn_leaked_task(d, owner, OWNER_JOIN_TIMEOUT)
606
+ rescue Runtime::Cancel, StandardError
607
+ nil
608
+ end
609
+
610
+ nil
611
+ end
612
+
613
+ def warn_leaked_task(d, task, timeout)
614
+ return if ENV["PG_PIPELINE_SILENCE_WARNINGS"]
615
+
616
+ warn(
617
+ "pg_pipeline: task #{task.name.inspect} did not exit within " \
618
+ "#{timeout}s and has been leaked (total #{d.leaked_watchers}). " \
619
+ "Closing the duplexed socket_io / connection did not release the fiber " \
620
+ "(often parked in wait_readable/wait_writable)."
621
+ )
622
+ end
623
+
518
624
  def safe_close_conn(d)
519
625
  d.conn.close unless d.conn.finished?
520
626
  rescue StandardError