pg_pipeline 0.3.1 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca8a6402d9ee49ae0393f519c135f4174248df64aa27332f0773e095eb3179c8
4
- data.tar.gz: 3c01ed0c9be6950a3b4d264bc613e5fef94c56dc81cb8916c791902ffeac61b0
3
+ metadata.gz: c7fdda2e5d806be070a3255e8cef4a63b72acaeac87d7a7777d580bfb0fc0d0a
4
+ data.tar.gz: 2b23bf8808241eb64396672959a86ae86c26af05bb0d72049de646907cc9e99d
5
5
  SHA512:
6
- metadata.gz: dc55189c229e3958e40781e30577ac82c3645f70fa55c2bce894baf9e0a423caa67d3ba533f77d5fce627943b424e0c7de2cea5f99a2ea25b0903022f438b3df
7
- data.tar.gz: 508c35a99f8a9f5f3fa3665b741adaf58f593b6cc6c95fbfb86b48ded121adb010d4203fce6d9063274e510171e9f401ed0295b8691f2211ce273395cd7dcb9d
6
+ metadata.gz: 37a5b065506fb61a675a72023057b0d24254ea7fa411ea358c68e255e0ffabf53079ab3a7214be59b2d09b02063aa2c09875576868a213df8c91526f90834ebd
7
+ data.tar.gz: fb0e3b15f73e8de66b56d09c7791b8e357477c859b83293474ec48c15d41d12298f91c974bd8ebeb0ae2cc6a3437ed598c442e6eb26e8a13787e71a3d35856f7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.2] - 2026-08-17
4
+
5
+ Runtime correctness. Public SQL, Sync-per-unit, failure model, and the
6
+ `Fiber::Scheduler` host contract are unchanged.
7
+
8
+ ### Fixed
9
+
10
+ - Bounded waits (`Notification#wait`, `Task#wait`) park through
11
+ `scheduler.block(blocker, remaining)` and a monotonic deadline. They no
12
+ longer wrap `park` in `Timeout.timeout`. A wait timeout is not a work
13
+ timeout: `Notification#wait` no longer rescues `TimeoutError`, so an
14
+ enclosing `Runtime.with_timeout` cannot be swallowed by
15
+ `BoundedQueue#wait_on` or `wait_for_pinned_idle`. `wait(0)` polls.
16
+ - `Runtime.with_timeout` (health probes and other work limits) calls
17
+ `scheduler.timeout_after(duration, Deadline, message)` when the hook
18
+ exists. `Deadline < Exception`, so `rescue => e` cannot eat it; the
19
+ method still raises `Runtime::TimeoutError` at its own boundary. A
20
+ scheduler without the hook falls back to stdlib `Timeout` and warns
21
+ once — `Thread#raise` can hit the wrong fiber.
22
+ - `Notification#signal` and `Semaphore#release` skip a waiter that
23
+ `Task#stop` already released, or a fiber that is no longer alive. On a
24
+ deferred `unblock` the previous `shift` + wake handed the signal to the
25
+ stopped waiter and left the live one parked; a dead semaphore waiter
26
+ burned a pinned-pool slot. `park` also raises `Cancel` after a stop-wake
27
+ so the wait is not mistaken for a successful signal.
28
+
29
+ ### Unchanged
30
+
31
+ - Per-fiber waiter-hash reuse, `Request` one-shot completion, watcher
32
+ `fiber_interrupt` / poll shutdown, and the host-installed scheduler
33
+ rule.
34
+
3
35
  ## [0.3.1] - 2026-08-09
4
36
 
5
37
  Reliability and correctness fixes on top of 0.3.0. No public API changes other
@@ -7,19 +7,24 @@ module PgPipeline
7
7
  @waiters = []
8
8
  end
9
9
 
10
+ def waiting = @waiters.size
11
+
10
12
  def wait(timeout = nil)
13
+ wait_until(Runtime.deadline_for(timeout))
14
+ end
15
+
16
+ def wait_until(deadline = nil)
11
17
  Runtime.with_waiter(self, @waiters) do |waiter|
12
- Runtime.with_timeout(timeout) { Runtime.park(self, waiter) { false } }
13
- true
14
- rescue TimeoutError
15
- false
18
+ Runtime.park(self, waiter, deadline) { false }
16
19
  end
17
20
  end
18
21
 
19
22
  def signal
20
- waiter = @waiters.shift or return false
21
- Runtime.wake_dequeued(waiter, self)
22
- true
23
+ while (waiter = @waiters.shift)
24
+ return true if Runtime.wake_dequeued(waiter, self)
25
+ end
26
+
27
+ false
23
28
  end
24
29
 
25
30
  def signal_all
@@ -23,12 +23,12 @@ module PgPipeline
23
23
  end
24
24
 
25
25
  def release
26
- if (node = @waiting.shift)
26
+ while (node = @waiting.shift)
27
27
  node.grant!
28
- node.resume
29
- else
30
- @available += 1
28
+ return @available if node.resume
31
29
  end
30
+
31
+ @available += 1
32
32
  @available
33
33
  end
34
34
 
@@ -126,9 +126,10 @@ module PgPipeline
126
126
 
127
127
  def resume
128
128
  fiber = @fiber
129
- return unless fiber.alive?
129
+ return false unless fiber.alive?
130
130
 
131
131
  @scheduler.unblock(@blocker, fiber)
132
+ true
132
133
  end
133
134
  end
134
135
 
@@ -22,7 +22,10 @@ module PgPipeline
22
22
  end
23
23
 
24
24
  def wait(timeout = nil)
25
- Runtime.with_timeout(timeout) { join }
25
+ unless @done
26
+ deadline = Runtime.deadline_for(timeout)
27
+ raise TimeoutError, "task did not finish in time" unless join(deadline)
28
+ end
26
29
  raise @error if @error
27
30
 
28
31
  @result
@@ -94,8 +97,8 @@ module PgPipeline
94
97
 
95
98
  def release_blocker
96
99
  waiter = @blocker or return false
97
- Runtime.wake(waiter, waiter[:blocker])
98
- waiter[:fiber].alive?
100
+
101
+ Runtime.wake(waiter, waiter[:blocker]) && waiter[:fiber].alive?
99
102
  end
100
103
 
101
104
  def complete(result, error)
@@ -107,11 +110,11 @@ module PgPipeline
107
110
  wake_waiters
108
111
  end
109
112
 
110
- def join
111
- return if @done
113
+ def join(deadline)
114
+ return true if @done
112
115
 
113
116
  Runtime.with_waiter(self, @waiters) do |waiter|
114
- Runtime.park(self, waiter) { @done }
117
+ Runtime.park(self, waiter, deadline) { @done }
115
118
  end
116
119
  end
117
120
 
@@ -7,12 +7,25 @@ require_relative "errors"
7
7
  module PgPipeline
8
8
  module Runtime
9
9
  class Cancel < Exception; end
10
+ class Deadline < Exception; end
10
11
  class TimeoutError < Error; end
11
12
 
12
13
  module_function
13
14
 
14
15
  CURRENT_TASK_KEY = :pg_pipeline_current_task
15
16
  WAITER_KEY = :pg_pipeline_waiter
17
+ DEADLINE_MESSAGE = "execution expired"
18
+
19
+ MISSING_TIMEOUT_HOOK_WARNING = <<~MESSAGE
20
+ pg_pipeline: %s does not implement #timeout_after.
21
+
22
+ Falling back to stdlib Timeout, which uses Thread#raise and can deliver
23
+ the timeout to an unrelated fiber. Use a scheduler that implements
24
+ #timeout_after (async, itsi-scheduler) or avoid Runtime.with_timeout
25
+ on this host.
26
+ MESSAGE
27
+
28
+ @warned_schedulers = {}
16
29
 
17
30
  def spawn(name: nil, &block)
18
31
  Task.spawn(name: name, &block)
@@ -22,6 +35,10 @@ module PgPipeline
22
35
  Fiber.scheduler or raise Error, "operation requires an active Fiber scheduler"
23
36
  end
24
37
 
38
+ def native_timeouts?(target = Fiber.scheduler)
39
+ !target.nil? && target.respond_to?(:timeout_after)
40
+ end
41
+
25
42
  def current_task
26
43
  Thread.current[CURRENT_TASK_KEY]
27
44
  end
@@ -44,16 +61,20 @@ module PgPipeline
44
61
  waiter
45
62
  end
46
63
 
47
- def park(blocker, waiter)
64
+ def park(blocker, waiter, deadline = nil)
48
65
  scheduler = waiter[:scheduler]
49
66
  task = current_task
50
67
  task&.enter_block(waiter)
51
68
 
52
69
  until waiter[:ready] || yield
53
- scheduler.block(blocker, nil)
70
+ remaining = deadline ? (deadline - monotonic_now) : nil
71
+ return false if deadline && remaining <= 0
72
+
73
+ scheduler.block(blocker, remaining)
54
74
  task&.raise_if_cancelled!
55
75
  end
56
- nil
76
+ task&.raise_if_cancelled!
77
+ true
57
78
  ensure
58
79
  waiter[:blocker] = nil
59
80
  task&.exit_block
@@ -65,7 +86,10 @@ module PgPipeline
65
86
  waiters << waiter
66
87
  yield waiter
67
88
  ensure
68
- waiters.delete(waiter) if waiter[:queued]
89
+ if waiter[:queued]
90
+ waiters.delete(waiter)
91
+ waiter[:queued] = false
92
+ end
69
93
  end
70
94
 
71
95
  def wake_dequeued(waiter, blocker)
@@ -73,21 +97,59 @@ module PgPipeline
73
97
  wake(waiter, blocker)
74
98
  end
75
99
 
100
+ def wake(waiter, blocker)
101
+ return false if waiter[:ready]
102
+
103
+ waiter[:ready] = true
104
+ fiber = waiter[:fiber]
105
+ return false unless fiber.alive?
106
+
107
+ waiter[:scheduler].unblock(blocker, fiber)
108
+ true
109
+ end
110
+
111
+ def monotonic_now
112
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ end
114
+
115
+ def deadline_for(timeout)
116
+ return nil if timeout.nil?
117
+
118
+ seconds = Float(timeout)
119
+ raise ArgumentError, "timeout must be non-negative and finite" unless seconds.finite? && seconds >= 0
120
+
121
+ monotonic_now + seconds
122
+ end
123
+
76
124
  def with_timeout(duration)
77
125
  return yield if duration.nil?
78
126
 
79
- timeout = Float(duration)
80
- raise ArgumentError, "timeout must be non-negative and finite" unless timeout.finite? && timeout >= 0
127
+ seconds = Float(duration)
128
+ raise ArgumentError, "timeout must be non-negative and finite" unless seconds.finite? && seconds >= 0
81
129
 
82
- ::Timeout.timeout(timeout, TimeoutError) { yield }
130
+ begin
131
+ arm_deadline(seconds) { yield }
132
+ rescue Deadline => e
133
+ raise TimeoutError, e.message
134
+ end
83
135
  end
84
136
 
85
- def wake(waiter, blocker)
86
- waiter[:ready] = true
87
- fiber = waiter[:fiber]
88
- return unless fiber.alive?
137
+ def arm_deadline(seconds, &block)
138
+ target = Fiber.scheduler
139
+ if native_timeouts?(target)
140
+ return target.timeout_after(seconds, Deadline, DEADLINE_MESSAGE, &block)
141
+ end
89
142
 
90
- waiter[:scheduler].unblock(blocker, fiber)
143
+ warn_missing_timeout_hook(target) if target
144
+ ::Timeout.timeout(seconds, Deadline, DEADLINE_MESSAGE, &block)
145
+ end
146
+
147
+ def warn_missing_timeout_hook(target)
148
+ key = target.class
149
+ return if @warned_schedulers[key]
150
+
151
+ @warned_schedulers[key] = true
152
+ warn(format(MISSING_TIMEOUT_HOOK_WARNING, key))
91
153
  end
92
154
  end
93
155
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgPipeline
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_pipeline
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov