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,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pg"
4
- require "async"
5
- require "async/semaphore"
6
- require "async/notification"
7
4
 
8
5
  require_relative "errors"
6
+ require_relative "runtime"
9
7
  require_relative "connection_driver"
10
8
  require_relative "prepared_statement"
11
9
  require_relative "server_caps"
@@ -19,7 +17,9 @@ module PgPipeline
19
17
  DEFAULT_HEALTH_INTERVAL = 10.0
20
18
  DEFAULT_HEALTH_TIMEOUT = 5.0
21
19
  DISCARD_SEQUENCES_SERVER_VERSION = 90_400
22
- CANCEL_SIGNAL = Async::Cancel
20
+ CANCEL_SIGNAL = Runtime::Cancel
21
+ SUPERVISOR_JOIN_TIMEOUT = 5.0
22
+ MAX_PAUSE_FAILURES = 3
23
23
 
24
24
  attr_reader :reconnects, :pipeline_size
25
25
 
@@ -53,11 +53,13 @@ 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
59
60
  @supervisor = nil
60
- @task_parent = nil
61
+ @supervisor_wake = Runtime::Notification.new
62
+ @pause_failures = 0
61
63
 
62
64
  @prepared_statements = {}
63
65
  @prepared_generation = 0
@@ -69,30 +71,28 @@ module PgPipeline
69
71
  @pinned_error = nil
70
72
  @pinned_active = 0
71
73
  @pinned_owners = Hash.new(0)
72
- @pinned_idle = Async::Notification.new
74
+ @pinned_idle = Runtime::Notification.new
73
75
 
74
76
  @started = false
75
77
  @closing = false
76
78
  @closed = false
77
79
  end
78
80
 
79
- def start(parent: nil)
81
+ def start
80
82
  raise Error, "pool already started" if @started
81
83
  if @closing || @closed
82
84
  raise ShutdownError, "pool is closing or was closed and cannot be restarted; create a new Pool"
83
85
  end
84
-
85
- parent ||= Async::Task.current
86
- @task_parent = parent
86
+ raise Error, "pool start requires an active Fiber scheduler" unless Fiber.scheduler
87
87
 
88
88
  begin
89
- @pipeline_size.times { @drivers << start_pipeline_driver(parent) }
89
+ @pipeline_size.times { @drivers << start_pipeline_driver }
90
90
  @driver_backoff = Array.new(@drivers.size, 0.0)
91
91
  @driver_attempts = Array.new(@drivers.size, 0)
92
92
  @driver_last_health = Array.new(@drivers.size, monotonic)
93
- @pinned_gate = Async::Semaphore.new(@pinned_size) if @pinned_size.positive?
93
+ @pinned_gate = Runtime::Semaphore.new(@pinned_size) if @pinned_size.positive?
94
94
  @started = true
95
- @supervisor = parent.async { supervise } if @reconnect || @health_check
95
+ @supervisor = Runtime.spawn(name: :supervisor) { supervise } if @reconnect || @health_check
96
96
  rescue Exception
97
97
  cleanup_partial_start
98
98
  raise
@@ -106,7 +106,8 @@ module PgPipeline
106
106
  def pipeline_driver
107
107
  ensure_available!
108
108
 
109
- driver, @rr = PoolOps.select_driver(@drivers, @rr)
109
+ driver = PoolOps.select_driver_into(@drivers, @rr, @rr_slot)
110
+ @rr = @rr_slot[0]
110
111
  raise NotDispatchedError, "no live pipeline connections; request was not dispatched" unless driver
111
112
 
112
113
  driver
@@ -172,9 +173,10 @@ module PgPipeline
172
173
  raise @pinned_error if @pinned_error
173
174
  raise Error, "pinned pool is disabled (pinned_size=0)" if @pinned_size.zero?
174
175
 
175
- if @pinned_owners[Async::Task.current].positive?
176
+ owner = Fiber.current
177
+ if @pinned_owners[owner].positive?
176
178
  raise RecursiveCheckoutError,
177
- "nested Client#session/transaction on the same task is not allowed; " \
179
+ "nested Client#session/transaction on the same fiber is not allowed; " \
178
180
  "use Transaction#savepoint for nested atomicity"
179
181
  end
180
182
 
@@ -182,7 +184,6 @@ module PgPipeline
182
184
  raise @pinned_error if @pinned_error
183
185
  raise ShutdownError, "pool is closing" if @closing
184
186
 
185
- owner = Async::Task.current
186
187
  conn = nil
187
188
  @pinned_active += 1
188
189
  @pinned_owners[owner] += 1
@@ -218,6 +219,7 @@ module PgPipeline
218
219
  reconnects: @reconnects,
219
220
  health_failures: @health_failures,
220
221
  supervisor_error: @supervisor_error&.message,
222
+ supervisor_alive: supervisor_alive?,
221
223
  closing: @closing,
222
224
  closed: @closed,
223
225
  pinned_error: @pinned_error&.message
@@ -227,7 +229,7 @@ module PgPipeline
227
229
  def graceful_close
228
230
  return unless @started
229
231
 
230
- if @pinned_owners[Async::Task.current].positive?
232
+ if @pinned_owners[Fiber.current].positive?
231
233
  raise Error, "cannot close the pool from inside Client#session/transaction"
232
234
  end
233
235
 
@@ -282,7 +284,7 @@ module PgPipeline
282
284
  def supervise
283
285
  until @closing
284
286
  begin
285
- reap_and_replace(@task_parent) if @reconnect
287
+ reap_and_replace if @reconnect
286
288
  health_probe if @health_check
287
289
  @supervisor_error = nil
288
290
  rescue StandardError => e
@@ -290,9 +292,29 @@ module PgPipeline
290
292
  end
291
293
 
292
294
  break if @closing
295
+ break unless supervisor_pause
296
+ end
297
+ end
298
+
299
+ def supervisor_pause
300
+ @supervisor_wake.wait(supervisor_sleep_interval)
301
+ @pause_failures = 0
302
+ true
303
+ rescue StandardError => e
304
+ @supervisor_error = e
305
+ @pause_failures = (@pause_failures || 0) + 1
293
306
 
294
- sleep(supervisor_sleep_interval)
307
+ unless ENV["PG_PIPELINE_SILENCE_WARNINGS"]
308
+ warn("pg_pipeline: supervisor pause failed (#{e.class}: #{e.message}) " \
309
+ "[#{@pause_failures}/#{MAX_PAUSE_FAILURES}]")
295
310
  end
311
+
312
+ @pause_failures < MAX_PAUSE_FAILURES
313
+ end
314
+
315
+ def supervisor_alive?
316
+ supervisor = @supervisor
317
+ !supervisor.nil? && !supervisor.finished?
296
318
  end
297
319
 
298
320
  def supervisor_sleep_interval
@@ -303,7 +325,7 @@ module PgPipeline
303
325
  interval.positive? ? interval : @reconnect_interval
304
326
  end
305
327
 
306
- def reap_and_replace(parent)
328
+ def reap_and_replace
307
329
  now = monotonic
308
330
  ensure_driver_slots!
309
331
 
@@ -314,7 +336,7 @@ module PgPipeline
314
336
  next if now < (@driver_backoff[index] || 0.0)
315
337
 
316
338
  begin
317
- @drivers[index] = start_pipeline_driver(parent)
339
+ @drivers[index] = start_pipeline_driver
318
340
  @driver_backoff[index] = 0.0
319
341
  @driver_attempts[index] = 0
320
342
  @driver_last_health[index] = monotonic
@@ -337,10 +359,16 @@ module PgPipeline
337
359
  next if now - (@driver_last_health[index] || 0.0) < @health_interval
338
360
 
339
361
  @driver_last_health[index] = now
340
- next if driver.health_check(@health_timeout)
341
362
 
342
- @health_failures += 1
343
- driver.abort!
363
+ begin
364
+ next if driver.health_check(@health_timeout)
365
+
366
+ @health_failures += 1
367
+ driver.abort!
368
+ rescue StandardError => e
369
+ @health_failures += 1
370
+ @supervisor_error = e
371
+ end
344
372
  end
345
373
  end
346
374
 
@@ -374,10 +402,21 @@ module PgPipeline
374
402
  def stop_supervisor
375
403
  supervisor = @supervisor
376
404
  @supervisor = nil
377
- supervisor&.stop
405
+ return nil unless supervisor
406
+
407
+ @supervisor_wake.signal
408
+
409
+ begin
410
+ supervisor.wait(SUPERVISOR_JOIN_TIMEOUT)
411
+ rescue Runtime::TimeoutError
412
+ unless ENV["PG_PIPELINE_SILENCE_WARNINGS"]
413
+ warn("pg_pipeline: supervisor did not exit within #{SUPERVISOR_JOIN_TIMEOUT}s and has been leaked")
414
+ end
415
+ rescue CANCEL_SIGNAL, StandardError
416
+ nil
417
+ end
418
+
378
419
  nil
379
- rescue CANCEL_SIGNAL => e
380
- e
381
420
  rescue StandardError
382
421
  nil
383
422
  end
@@ -411,11 +450,11 @@ module PgPipeline
411
450
  raise Error, "pool not started" unless @started
412
451
  end
413
452
 
414
- def start_pipeline_driver(parent)
453
+ def start_pipeline_driver
415
454
  conn = PoolOps.new_connection(@connection_args)
416
455
  prepare_registered_statements(conn)
417
456
  driver = ConnectionDriver.new(conn, max_pending: @max_pending, max_in_flight: @max_in_flight)
418
- driver.start(parent: parent)
457
+ driver.start
419
458
  rescue Exception
420
459
  PoolOps.safe_close(conn) if conn
421
460
  raise
@@ -445,28 +484,26 @@ module PgPipeline
445
484
  end
446
485
 
447
486
  def release_pinned(owner, conn)
448
- begin
449
- if conn
450
- if @closing
451
- PoolOps.safe_close(conn)
452
- else
453
- recycled = recycle_pinned_connection(conn)
454
-
455
- if recycled
456
- if @closing
457
- PoolOps.safe_close(recycled)
458
- else
459
- @pinned_free << recycled
460
- end
487
+ if conn
488
+ if @closing
489
+ PoolOps.safe_close(conn)
490
+ else
491
+ recycled = recycle_pinned_connection(conn)
492
+
493
+ if recycled
494
+ if @closing
495
+ PoolOps.safe_close(recycled)
496
+ else
497
+ @pinned_free << recycled
461
498
  end
462
499
  end
463
500
  end
464
- ensure
465
- @pinned_active -= 1
466
- @pinned_owners[owner] -= 1
467
- @pinned_owners.delete(owner) if @pinned_owners[owner].zero?
468
- @pinned_idle.signal if @pinned_active.zero?
469
501
  end
502
+ ensure
503
+ @pinned_active -= 1
504
+ @pinned_owners[owner] -= 1
505
+ @pinned_owners.delete(owner) if @pinned_owners[owner].zero?
506
+ @pinned_idle.signal if @pinned_active.zero?
470
507
  end
471
508
 
472
509
  def recycle_pinned_connection(conn)
@@ -511,7 +548,6 @@ module PgPipeline
511
548
  close_free_pinned
512
549
  @pinned_gate = nil
513
550
  @supervisor = nil
514
- @task_parent = nil
515
551
  @started = false
516
552
  @closing = false
517
553
  @closed = false
@@ -557,28 +593,42 @@ module PgPipeline
557
593
  raise ArgumentError, "#{name} must be an integer >= 0"
558
594
  end
559
595
 
560
- def select_driver(drivers, rr)
596
+ def select_driver_into(drivers, rr, slot)
561
597
  size = drivers.length
562
- return [nil, rr] if size.zero?
598
+ if size.zero?
599
+ slot[0] = rr
600
+ return nil
601
+ end
563
602
 
603
+ start = rr % size
564
604
  best = nil
565
- best_index = nil
566
- best_load = nil
605
+ best_index = 0
606
+ best_load = 0
607
+ offset = 0
567
608
 
568
- size.times do |offset|
569
- index = (rr + offset) % size
609
+ while offset < size
610
+ index = start + offset
611
+ index -= size if index >= size
570
612
  driver = drivers[index]
613
+ offset += 1
571
614
  next unless driver.available?
572
615
 
573
616
  load = driver.load
574
- if best.nil? || load < best_load
575
- best = driver
576
- best_index = index
577
- best_load = load
578
- end
617
+ next unless best.nil? || load < best_load
618
+
619
+ best = driver
620
+ best_index = index
621
+ best_load = load
579
622
  end
580
623
 
581
- best ? [best, (best_index + 1) % size] : [nil, rr]
624
+ slot[0] = best ? (best_index + 1) % size : rr
625
+ best
626
+ end
627
+
628
+ def select_driver(drivers, rr)
629
+ slot = [rr]
630
+ driver = select_driver_into(drivers, rr, slot)
631
+ [driver, slot[0]]
582
632
  end
583
633
 
584
634
  def new_connection(connection_args)
@@ -16,7 +16,7 @@ module PgPipeline
16
16
  freeze
17
17
  end
18
18
 
19
- def query(params = []) = PreparedStatementOps.query(self, params)
19
+ def query(params = RequestOps::EMPTY_PARAMS) = PreparedStatementOps.query(self, params)
20
20
  alias call query
21
21
 
22
22
  def inspect
@@ -22,10 +22,16 @@ module PgPipeline
22
22
  @waiter_scheduler = nil
23
23
  end
24
24
 
25
+ def self.build(sql, params)
26
+ request = allocate
27
+ request.__send__(:init_query, sql, params)
28
+ request
29
+ end
30
+
25
31
  def self.prepare(statement) = PrepareRequest.new(statement)
26
32
 
27
33
  def self.prepared_query(statement, params: nil)
28
- PreparedQueryRequest.new(statement, params: params)
34
+ PreparedQueryRequest.build(statement, params)
29
35
  end
30
36
 
31
37
  def operation = :query
@@ -43,6 +49,23 @@ module PgPipeline
43
49
  def query_boundary_seen? = @query_boundary_seen
44
50
  def cancelled? = @cancelled
45
51
  def settled? = @settled
52
+
53
+ private
54
+
55
+ def init_query(sql, params)
56
+ @sql = RequestOps.snapshot_sql(sql)
57
+ @params = RequestOps.snapshot_params(params)
58
+ @state = :new
59
+ @cancelled = false
60
+ @settled = false
61
+ @result_seen = false
62
+ @query_boundary_seen = false
63
+ @result = nil
64
+ @error = nil
65
+ @waiter = nil
66
+ @waiter_scheduler = nil
67
+ self
68
+ end
46
69
  end
47
70
 
48
71
  class PrepareRequest < Request
@@ -65,7 +88,21 @@ module PgPipeline
65
88
  @statement_name = RequestOps.snapshot_name(statement.physical_name)
66
89
  end
67
90
 
91
+ def self.build(statement, params)
92
+ request = allocate
93
+ request.__send__(:init_prepared, statement, params)
94
+ request
95
+ end
96
+
68
97
  def operation = :prepared_query
98
+
99
+ private
100
+
101
+ def init_prepared(statement, params)
102
+ init_query(statement.sql, params)
103
+ @statement_name = RequestOps.snapshot_name(statement.physical_name)
104
+ self
105
+ end
69
106
  end
70
107
 
71
108
  module RequestOps
@@ -76,11 +113,34 @@ module PgPipeline
76
113
  value.frozen? ? value : value.dup.freeze
77
114
  end
78
115
 
116
+ EMPTY_PARAMS = [].freeze
117
+
79
118
  def snapshot_params(params)
80
- values = params.nil? ? [] : params
81
- raise ArgumentError, "params must be an Array" unless values.is_a?(Array)
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)
82
124
 
83
- values.map { |value| snapshot_value(value) }.freeze
125
+ params.map { |value| snapshot_value(value) }.freeze
126
+ end
127
+
128
+ def immutable_values?(values)
129
+ index = 0
130
+ size = values.size
131
+ while index < size
132
+ value = values[index]
133
+ case value
134
+ when Integer, Float, Symbol, NilClass, TrueClass, FalseClass
135
+ nil
136
+ when String
137
+ return false unless value.frozen?
138
+ else
139
+ return false
140
+ end
141
+ index += 1
142
+ end
143
+ true
84
144
  end
85
145
 
86
146
  def snapshot_value(value)
@@ -195,13 +255,11 @@ module PgPipeline
195
255
  req.waiter = waiter
196
256
  req.waiter_scheduler = scheduler
197
257
 
198
- begin
199
- scheduler.block(req, nil) until req.settled
200
- ensure
201
- if req.waiter.equal?(waiter)
202
- req.waiter = nil
203
- req.waiter_scheduler = nil
204
- end
258
+ scheduler.block(req, nil) until req.settled
259
+ ensure
260
+ if waiter && req.waiter.equal?(waiter)
261
+ req.waiter = nil
262
+ req.waiter_scheduler = nil
205
263
  end
206
264
  end
207
265
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgPipeline
4
+ module Runtime
5
+ class Notification
6
+ def initialize
7
+ @waiters = []
8
+ end
9
+
10
+ def wait(timeout = nil)
11
+ Runtime.with_waiter(self, @waiters) do |waiter|
12
+ Runtime.with_timeout(timeout) { Runtime.park(self, waiter) { false } }
13
+ true
14
+ rescue TimeoutError
15
+ false
16
+ end
17
+ end
18
+
19
+ def signal
20
+ waiter = @waiters.shift or return false
21
+ Runtime.wake_dequeued(waiter, self)
22
+ true
23
+ end
24
+
25
+ def signal_all
26
+ pending, @waiters = @waiters, []
27
+ pending.each { |waiter| Runtime.wake_dequeued(waiter, self) }
28
+ nil
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgPipeline
4
+ module Runtime
5
+ class Queue
6
+ def initialize
7
+ @queue = ::Thread::Queue.new
8
+ end
9
+
10
+ def enqueue(item)
11
+ @queue.push(item)
12
+ item
13
+ rescue ClosedQueueError
14
+ nil
15
+ end
16
+
17
+ def dequeue
18
+ @queue.pop
19
+ end
20
+
21
+ def close
22
+ @queue.close
23
+ nil
24
+ end
25
+
26
+ def closed? = @queue.closed?
27
+ def empty? = @queue.empty?
28
+ def size = @queue.size
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgPipeline
4
+ module Runtime
5
+ class Semaphore
6
+ def initialize(limit)
7
+ @limit = Integer(limit)
8
+ raise ArgumentError, "limit must be >= 0" if @limit.negative?
9
+
10
+ @available = @limit
11
+ @waiting = WaitList.new
12
+ end
13
+
14
+ def acquire
15
+ wait
16
+ return @available unless block_given?
17
+
18
+ begin
19
+ yield
20
+ ensure
21
+ release
22
+ end
23
+ end
24
+
25
+ def release
26
+ if (node = @waiting.shift)
27
+ node.grant!
28
+ node.resume
29
+ else
30
+ @available += 1
31
+ end
32
+ @available
33
+ end
34
+
35
+ private
36
+
37
+ def wait
38
+ return (@available -= 1) if @available.positive?
39
+
40
+ task = Runtime.current_task
41
+ node = FiberNode.new(Fiber.current, self)
42
+ @waiting.push(node)
43
+ task&.enter_block(node.waiter)
44
+
45
+ begin
46
+ until node.granted?
47
+ node.suspend
48
+ abandon_wait!(node, task) if task&.cancelled?
49
+ end
50
+ abandon_wait!(node, task) if task&.cancelled?
51
+ ensure
52
+ task&.exit_block
53
+ @waiting.remove(node) unless node.granted? || node.list.nil?
54
+ end
55
+ end
56
+
57
+ def abandon_wait!(node, task)
58
+ node.granted? ? release : @waiting.remove(node)
59
+ task.raise_if_cancelled!
60
+ end
61
+
62
+ class WaitList
63
+ attr_reader :size
64
+
65
+ def initialize
66
+ @head = @tail = nil
67
+ @size = 0
68
+ end
69
+
70
+ def first = @head
71
+ def empty? = @head.nil?
72
+
73
+ def push(node)
74
+ raise ArgumentError, "node already queued" if node.list
75
+
76
+ node.list = self
77
+ node.prev = @tail
78
+ node.next = nil
79
+ @tail ? (@tail.next = node) : (@head = node)
80
+ @tail = node
81
+ @size += 1
82
+ node
83
+ end
84
+
85
+ def shift
86
+ node = @head or return nil
87
+ remove(node)
88
+ end
89
+
90
+ def remove(node)
91
+ return nil unless node.list.equal?(self)
92
+
93
+ node.prev ? (node.prev.next = node.next) : (@head = node.next)
94
+ node.next ? (node.next.prev = node.prev) : (@tail = node.prev)
95
+ node.list = node.prev = node.next = nil
96
+ @size -= 1
97
+ node
98
+ end
99
+ end
100
+
101
+ class FiberNode
102
+ attr_accessor :list, :prev, :next
103
+ attr_reader :fiber, :waiter, :blocker
104
+
105
+ def initialize(fiber, blocker)
106
+ @fiber = fiber
107
+ @blocker = blocker
108
+ @list = @prev = @next = nil
109
+ @granted = false
110
+ @scheduler = Fiber.scheduler
111
+ @waiter = {fiber: fiber, scheduler: @scheduler, ready: false, blocker: blocker, queued: true}
112
+ end
113
+
114
+ def granted? = @granted
115
+
116
+ def grant!
117
+ @granted = true
118
+ @waiter[:ready] = true
119
+ @waiter[:queued] = false
120
+ self
121
+ end
122
+
123
+ def suspend
124
+ @scheduler.block(@blocker, nil)
125
+ end
126
+
127
+ def resume
128
+ fiber = @fiber
129
+ return unless fiber.alive?
130
+
131
+ @scheduler.unblock(@blocker, fiber)
132
+ end
133
+ end
134
+
135
+ private_constant :WaitList, :FiberNode
136
+ end
137
+ end
138
+ end