farce 0.4.1 → 0.5.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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -2
  3. data/README.md +3 -1
  4. data/docs/benchmarks.md +37 -21
  5. data/docs/compatibility.md +13 -0
  6. data/docs/data-structures.md +37 -0
  7. data/docs/integrations.md +1 -0
  8. data/docs/introduction.md +19 -0
  9. data/docs/modes.md +2 -0
  10. data/docs/scheduling.md +35 -3
  11. data/docs/scopes.md +3 -1
  12. data/docs/shims.md +53 -0
  13. data/docs/utilities.md +31 -0
  14. data/docs/variants.md +9 -0
  15. data/ext/farce/farce.c +9 -0
  16. data/ext/farce/map.c +14 -3
  17. data/ext/farce/priority_queue.c +46 -1
  18. data/ext/farce/queue.c +141 -7
  19. data/ext/farce/vector.c +3 -3
  20. data/ext/farce/weak_map.c +12 -3
  21. data/ext/java/org/farce/PriorityQueue.java +18 -2
  22. data/lib/farce/abstract/concurrent_map.rb +122 -0
  23. data/lib/farce/abstract/counter.rb +35 -1
  24. data/lib/farce/abstract/executor.rb +30 -7
  25. data/lib/farce/abstract/flag.rb +12 -0
  26. data/lib/farce/abstract/pool.rb +158 -47
  27. data/lib/farce/abstract/priority_queue.rb +1 -1
  28. data/lib/farce/abstract/publisher.rb +189 -0
  29. data/lib/farce/abstract/queue.rb +29 -3
  30. data/lib/farce/abstract/scheduler.rb +41 -14
  31. data/lib/farce/abstract/timer_queue.rb +3 -3
  32. data/lib/farce/abstract/vector.rb +3 -1
  33. data/lib/farce/atom.rb +9 -2
  34. data/lib/farce/clock.rb +12 -0
  35. data/lib/farce/concurrent/count_down_latch.rb +37 -0
  36. data/lib/farce/concurrent/cyclic_barrier.rb +109 -0
  37. data/lib/farce/concurrent/event.rb +60 -0
  38. data/lib/farce/concurrent/fiber_local_var.rb +31 -0
  39. data/lib/farce/concurrent/local_var.rb +65 -0
  40. data/lib/farce/concurrent/thread_local_var.rb +28 -0
  41. data/lib/farce/concurrent/tuple.rb +74 -0
  42. data/lib/farce/concurrent.rb +50 -0
  43. data/lib/farce/config.rb +112 -2
  44. data/lib/farce/counter.rb +0 -16
  45. data/lib/farce/engine/jruby/map.rb +8 -6
  46. data/lib/farce/engine/jruby/signal.rb +2 -2
  47. data/lib/farce/engine/jvm/farce.jar +0 -0
  48. data/lib/farce/engine/jvm/priority_queue.rb +14 -4
  49. data/lib/farce/engine/ruby/shared/parallel_scheduler.rb +9 -1
  50. data/lib/farce/engine/ruby/shared/ractor_selector.rb +1 -4
  51. data/lib/farce/engine/ruby/shared/weak_map.rb +9 -12
  52. data/lib/farce/engine/shared/exchanger.rb +2 -2
  53. data/lib/farce/engine/shared/queue.rb +43 -5
  54. data/lib/farce/engine/shared/ractor_selector.rb +1 -1
  55. data/lib/farce/engine/shared/strict_map.rb +5 -0
  56. data/lib/farce/engine/shared/unshared_atom.rb +1 -1
  57. data/lib/farce/engine/shared/unshared_vector.rb +8 -7
  58. data/lib/farce/engine/shared/unshared_weak_map.rb +8 -6
  59. data/lib/farce/engine/shared/weak_atom/base.rb +5 -10
  60. data/lib/farce/engine/shared/weak_map/cell.rb +1 -1
  61. data/lib/farce/engine/truffleruby/map.rb +8 -6
  62. data/lib/farce/engine/truffleruby/native/priority_queue.rb +18 -4
  63. data/lib/farce/engine/truffleruby/signal.rb +1 -1
  64. data/lib/farce/integrations/concurrent.rb +16 -3
  65. data/lib/farce/internal/blocking_priority_queue.rb +4 -9
  66. data/lib/farce/internal/flag_waiting.rb +1 -1
  67. data/lib/farce/internal/lease_initialization.rb +1 -1
  68. data/lib/farce/internal/lease_pool_state.rb +1 -1
  69. data/lib/farce/internal/lease_state.rb +2 -2
  70. data/lib/farce/internal/local_executor_methods.rb +3 -3
  71. data/lib/farce/internal/map_value_modes.rb +9 -2
  72. data/lib/farce/internal/pool_settings.rb +9 -0
  73. data/lib/farce/internal/pool_worker.rb +34 -21
  74. data/lib/farce/internal/reservation_waiting.rb +1 -1
  75. data/lib/farce/internal/scheduler_lifecycle.rb +2 -2
  76. data/lib/farce/internal/weak_map_value_modes.rb +5 -0
  77. data/lib/farce/internal.rb +8 -8
  78. data/lib/farce/lazy.rb +3 -0
  79. data/lib/farce/lazy_ref.rb +3 -0
  80. data/lib/farce/local/lease.rb +2 -9
  81. data/lib/farce/local/lease_map.rb +2 -9
  82. data/lib/farce/local/pool.rb +38 -5
  83. data/lib/farce/local/publisher.rb +30 -0
  84. data/lib/farce/local/scheduler.rb +18 -5
  85. data/lib/farce/local/vector.rb +3 -2
  86. data/lib/farce/memoize.rb +314 -0
  87. data/lib/farce/pool.rb +5 -0
  88. data/lib/farce/priority_queue.rb +1 -1
  89. data/lib/farce/publisher.rb +57 -0
  90. data/lib/farce/signal.rb +1 -1
  91. data/lib/farce/strict/publisher.rb +31 -0
  92. data/lib/farce/strict/vector.rb +3 -2
  93. data/lib/farce/subscription/definitions.rb +27 -0
  94. data/lib/farce/subscription.rb +121 -0
  95. data/lib/farce/transaction/map.rb +35 -1
  96. data/lib/farce/unshared/pool.rb +1 -1
  97. data/lib/farce/unshared/publisher.rb +25 -0
  98. data/lib/farce/unshared/scheduler.rb +3 -3
  99. data/lib/farce/unshared/vector.rb +3 -2
  100. data/lib/farce/vector.rb +12 -9
  101. data/lib/farce/version.rb +1 -1
  102. data/lib/farce/walker/definitions.rb +13 -2
  103. data/lib/farce/weak_atom.rb +5 -2
  104. data/lib/farce.rb +3 -1
  105. metadata +18 -1
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+ # warn_indent: true
4
+
5
+ module Farce
6
+ module Abstract
7
+ # @abstract Shared superclass for all publisher implementations.
8
+ #
9
+ # Implements a publisher-subscriber (pub-sub) pattern.
10
+ # @see Farce::Publisher
11
+ class Publisher
12
+ include Internal::Freeze::Unfreezable
13
+ include Internal::Noncopyable
14
+
15
+ # @param subscribers [Array]
16
+ # the initial subscribers to add to the publisher, see {#subscribe}
17
+ def initialize(*subscribers)
18
+ @closed = Farce::Flag.new
19
+ super()
20
+ subscribers.each { subscribe(it) }
21
+ end
22
+
23
+ # @return [Boolean] whether the publisher is closed.
24
+ def closed? = @closed.value
25
+
26
+ # Publishes the given payload to all current subscribers.
27
+ #
28
+ # Full bounded queues drop new messages.
29
+ # Failed publishing attempts to closed or frozen receivers cancel their subscriptions.
30
+ #
31
+ # @param payload [BasicObject] the payload to publish
32
+ # @return [self] the publisher itself
33
+ def publish(payload = nil)
34
+ raise "Cannot publish to a closed publisher" if closed?
35
+ map.each_value { it.publish(payload) }
36
+ self
37
+ end
38
+ alias send publish
39
+ alias << publish
40
+ alias broadcast publish
41
+
42
+ # @overload subscribe(subscriber)
43
+ # Adds a subscriber to the publisher.
44
+ # Register additional subscriber classes with {Subscription.define}.
45
+ # Publisher subscribers forward messages synchronously to their own subscribers.
46
+ # Avoid subscribing publishers in a cycle.
47
+ #
48
+ # @example Forward messages to another publisher
49
+ # upstream.subscribe(downstream)
50
+ # upstream.publish(:message) # also publishes to downstream's subscribers
51
+ #
52
+ # @param subscriber [BasicObject]
53
+ # The subscriber to add.
54
+ # Standard-library queues are supported by unshared and local publishers and retain the original payload.
55
+ # A full `Thread::SizedQueue` drops new messages. Publishing to a closed queue cancels its subscription.
56
+ #
57
+ # Calls one of the following methods:
58
+ # * {Ractor#send}
59
+ # * {Ractor::Port#send}
60
+ # * {Queue#try_push}
61
+ # * `Thread::Queue#push`
62
+ # * `Thread::SizedQueue#push` (nonblocking)
63
+ # * {Counter#increment} (without arguments)
64
+ # * {Flag#set}
65
+ # * {Atom#value=}
66
+ # * {Set#<<}
67
+ # * {Vector#<<}
68
+ # * {Signal#broadcast} (without arguments)
69
+ # * {Publisher#publish}
70
+ # * {Farce::Concurrent::Event#set} (without arguments)
71
+ # * {Farce::Concurrent::CountDownLatch#count_down} (without arguments)
72
+ # * `Proc#call`
73
+ #
74
+ # The concurrent-ruby integration supports these subscribers with unshared and local publishers:
75
+ # * `Concurrent::AtomicReference#value=`
76
+ # * `Concurrent::AtomicFixnum#increment` (without arguments)
77
+ # * `Concurrent::AtomicBoolean#make_true`
78
+ # * `Concurrent::Event#set` (without arguments)
79
+ # * `Concurrent::CountDownLatch#count_down` (without arguments)
80
+ #
81
+ # Set the default `mode`, `auto_local`, `capacity`, and similar options via initializers for these classes
82
+ # to influence how data transfer is handled.
83
+ #
84
+ # Support for additional subscribers can be added via {Subscription.define}.
85
+ #
86
+ # @overload subscribe(*args, mode: nil, scheduler: nil, capacity: nil)
87
+ # Adds a subscription callback.
88
+ # Messages run in order on the selected scheduler. A full queue drops new messages.
89
+ # Unsubscribing drains queued messages and stops the callback.
90
+ #
91
+ # @example
92
+ # counter = Farce::Counter.new
93
+ #
94
+ # publisher.subscribe(counter) do |counter, payload|
95
+ # counter.increment
96
+ # puts "Message ##{counter}: #{payload.inspect}"
97
+ # end
98
+ #
99
+ # @param args [Array] additional arguments to pass to the subscriber
100
+ # @param mode [Symbol, nil] The transfer mode to use
101
+ # @param scheduler [#schedule, nil] The scheduler to use for running the block
102
+ # @param capacity [Integer, nil] the maximum queued messages, or nil for an unbounded queue
103
+ # @yield [*args, payload] the block to be called with the given arguments and payload
104
+ # @yieldparam args [Array] the additional arguments passed to the block
105
+ # @yieldparam payload [Object] the payload sent via {#publish}
106
+ #
107
+ # @return [Subscription] the created subscription
108
+ def subscribe(...) = block_given? ? create_subscription(...) : add_subscription(...)
109
+ alias monitor subscribe
110
+
111
+ # @param subscriber [BasicObject] the subscriber to retrieve the subscription for
112
+ # @return [Subscription, nil] the subscription for the given subscriber, or nil if none exists.
113
+ def subscription_for(subscriber)
114
+ if subscriber.is_a?(Subscription)
115
+ return unless subscriber.publisher.equal?(self)
116
+ subscription = map[subscriber.key]
117
+ subscription if subscription.equal?(subscriber)
118
+ else
119
+ map[subscriber.__id__]
120
+ end
121
+ end
122
+
123
+ # @return [Boolean] whether the given subscriber is currently subscribed.
124
+ def subscribed?(subscriber) = !!subscription_for(subscriber)&.subscribed?
125
+
126
+ # Unsubscribes the given subscriber from the publisher.
127
+ # @param subscriber [BasicObject] the subscriber to unsubscribe
128
+ # @return [void]
129
+ def unsubscribe(subscriber) = subscription_for(subscriber)&.unsubscribe
130
+ alias cancel unsubscribe
131
+ alias unmonitor unsubscribe
132
+
133
+ private
134
+
135
+ def default_mode = :smart
136
+ def map = raise NoMethodError, "Subclasses must implement the private `map` method"
137
+
138
+ def add_subscription(subscriber)
139
+ if subscriber.is_a?(Subscription)
140
+ existing = subscription_for(subscriber)
141
+ return existing if existing
142
+ subscriber = subscriber.subscriber
143
+ end
144
+ return create_subscription(source: subscriber, &subscriber) if subscriber.is_a?(Proc)
145
+
146
+ map = self.map
147
+ map.store_if_absent(subscriber.__id__) { build_subscription(subscriber, map) }
148
+ end
149
+
150
+ def create_subscription(*, capacity: nil, mode: nil, scheduler: nil, source: nil, &callback)
151
+ mode ||= default_mode
152
+ callback = Ractor.shareable_proc(&callback) unless mode == :local || Ractor.shareable?(callback)
153
+ scheduler ||= Farce
154
+ queue = Farce::Queue.new(capacity:, mode:)
155
+ map = self.map
156
+ created = false
157
+ subscription = map.store_if_absent((source || queue).__id__) do
158
+ created = true
159
+ build_subscription(queue, map, callback: true, source:)
160
+ end
161
+ return subscription unless created
162
+
163
+ scheduler.schedule(queue, callback, subscription, *, mode:) do |queue, callback, subscription, *args|
164
+ loop do
165
+ begin
166
+ payload = queue.pop
167
+ rescue ClosedQueueError
168
+ break
169
+ end
170
+ callback.call(*args, payload)
171
+ end
172
+ ensure
173
+ subscription.unsubscribe
174
+ queue.close
175
+ end
176
+
177
+ scheduled = true
178
+ subscription
179
+ ensure
180
+ unless scheduled
181
+ subscription&.unsubscribe if created
182
+ queue&.close
183
+ end
184
+ end
185
+
186
+ def build_subscription(...) = Subscription.build(self, ...)
187
+ end
188
+ end
189
+ end
@@ -11,8 +11,11 @@ module Farce
11
11
  #
12
12
  # Queues have a blocking and non-blocking interface.
13
13
  #
14
- # @!attribute [r] capacity
15
- # @abstract
14
+ # @!attribute [rw] capacity
15
+ # Assign a positive limit, or `nil` for an unbounded queue.
16
+ # Changes preserve queued values. Local queues change only the current scope.
17
+ # On JRuby and TruffleRuby, normal FIFO queues retain their initial bounded or unbounded choice.
18
+ # Switching between these states may raise `ArgumentError`. Priority and timer queues support both.
16
19
  # @return [Integer, nil] The maximum number of items the queue can hold.
17
20
  #
18
21
  # @!attribute [r] num_waiting
@@ -173,10 +176,33 @@ module Farce
173
176
 
174
177
  # Whether the queue is at capacity.
175
178
  # @return [Boolean] `true` if the queue is at capacity, `false` otherwise.
176
- def full? = capacity && size >= capacity
179
+ def full?
180
+ limit = capacity
181
+ limit && size >= limit
182
+ end
177
183
 
178
184
  def capacity = internal_queue.capacity
179
185
 
186
+ # Change the limit without discarding queued values. Returns immediately.
187
+ #
188
+ # New pushes wait while the queue is at or above the new limit.
189
+ # Increasing or removing the limit wakes blocked producers.
190
+ #
191
+ # Local queues change only the current scope. New scopes retain the constructor limit.
192
+ #
193
+ # Priority and timer queues support bounded and unbounded transitions on every engine.
194
+ #
195
+ # FIFO queues on JRuby and TruffleRuby retain their initial bounded or unbounded choice.
196
+ #
197
+ # @param value [Integer, nil] the positive capacity, or `nil` to remove the limit
198
+ # @return [Integer, nil] the new capacity
199
+ # @raise [ArgumentError] if the capacity is not positive
200
+ # @raise [ArgumentError] on JRuby and TruffleRuby, normal FIFO queues may reject transitions
201
+ # between bounded and unbounded states
202
+ def capacity=(value)
203
+ internal_queue.capacity = value.nil? ? nil : Integer(value)
204
+ end
205
+
180
206
  def clear
181
207
  internal_queue.clear
182
208
  self
@@ -169,6 +169,15 @@ module Farce
169
169
  super()
170
170
  end
171
171
 
172
+ # Pending-task capacity. Nil means the queue is unbounded.
173
+ def capacity = @queue.capacity
174
+
175
+ # Resize the pending queue without discarding tasks.
176
+ # JRuby and TruffleRuby cannot switch between bounded and unbounded queues.
177
+ def capacity=(value)
178
+ @queue.capacity = value.nil? ? nil : Integer(value)
179
+ end
180
+
172
181
  # @return [Exception, nil] The error encountered by the scheduler, if any.
173
182
  def error
174
183
  value = @error.value
@@ -305,7 +314,10 @@ module Farce
305
314
  # Otherwise, the block must be convertible to a Ractor-shareable proc.
306
315
  # Pass task data as arguments so the selected transfer mode can be applied to it.
307
316
  #
308
- # @overload schedule(*args, mode: :smart, auto_local: true)
317
+ # A `timeout` limits the combined wait for ownership and queue space.
318
+ # Expiry returns `nil` without enqueuing the task. Accepted tasks have no execution timeout.
319
+ #
320
+ # @overload schedule(*args, mode: :smart, auto_local: true, timeout: nil)
309
321
  # @!macro modes
310
322
  #
311
323
  # @param args [Array<Object>] Positional arguments passed to the task block.
@@ -313,8 +325,9 @@ module Farce
313
325
  # Argument transfer mode from {Farce::MODES}, including `:copy`, `:marshal`, and `:local`.
314
326
  # See {ModeManager#wrap}.
315
327
  # @param auto_local [Boolean] Whether to override mode with `:local` in the owning Ractor.
328
+ # @param timeout [Numeric, nil] Maximum scheduling wait in seconds. Nil waits indefinitely, zero polls.
316
329
  # @yield [*args] The task to execute.
317
- # @return [Scheduler] self.
330
+ # @return [self, nil] self when submitted, or nil when the scheduling timeout expires.
318
331
  # @raise [SchedulerClosedError] If the handle is closing, closed, or in an error state.
319
332
  # @see Ractor.shareable_proc
320
333
  def schedule(...) = enqueue_task(false, ...)
@@ -324,9 +337,13 @@ module Farce
324
337
  # it still waits for ownership to be assigned.
325
338
  #
326
339
  # @overload try_schedule(*args, mode: :smart, auto_local: true)
327
- # @param (see Farce::Abstract::Executor#schedule)
340
+ # @param args (see Farce::Abstract::Executor#schedule)
341
+ # @param mode (see Farce::Abstract::Executor#schedule)
342
+ # @param auto_local (see Farce::Abstract::Executor#schedule)
328
343
  # @return [self, nil] self when submitted, or nil when the queue is full.
329
- def try_schedule(...) = enqueue_task(true, ...)
344
+ def try_schedule(*, mode: :smart, auto_local: true, &)
345
+ enqueue_task(true, *, mode:, auto_local:, &)
346
+ end
330
347
 
331
348
  # (see Farce::Abstract::Executor#local?)
332
349
  def local?(wait: true)
@@ -418,13 +435,15 @@ module Farce
418
435
 
419
436
  private
420
437
 
421
- def enqueue_task(non_block, *args, mode: :smart, auto_local: true, &block)
438
+ def enqueue_task(non_block, *args, mode: :smart, auto_local: true, timeout: nil, &block)
422
439
  raise SchedulerClosedError, "cannot schedule task on a closed scheduler" if closed?
423
440
 
424
441
  raise LocalJumpError, "Cannot schedule without a block" unless block
425
442
 
426
- task = prepare_task(args, block, mode, auto_local)
427
- return unless non_block ? @queue.try_push(task) : @queue.push(task)
443
+ deadline = scheduling_deadline(timeout)
444
+ task = prepare_task(args, block, mode, auto_local, timeout: Clock.remaining(deadline))
445
+ return unless task
446
+ return unless non_block ? @queue.try_push(task) : @queue.push(task, timeout: Clock.remaining(deadline))
428
447
  self
429
448
  rescue ClosedQueueError
430
449
  raise unless closed?
@@ -433,10 +452,11 @@ module Farce
433
452
 
434
453
  def new_queue(capacity:) = Internal::Queue.new(capacity:)
435
454
 
436
- def prepare_task(args, block, mode, auto_local)
455
+ def prepare_task(args, block, mode, auto_local, timeout: nil)
437
456
  if auto_local
438
- owner = @owner.wait_until_non_nil
457
+ owner = @owner.wait_until_non_nil(timeout:)
439
458
  raise SchedulerClosedError, "cannot schedule task on a closed scheduler" if closed?
459
+ return unless owner
440
460
  mode = :local if owner == Ractor.current
441
461
  elsif mode == :local && (owner = @owner.value) && owner != Ractor.current
442
462
  raise Ractor::IsolationError, "cannot schedule local task outside the owning Ractor"
@@ -493,18 +513,25 @@ module Farce
493
513
  failure = nil
494
514
  @state.compare_and_set(:setup, :running)
495
515
  while keep_processing?
496
- break if @pool_worker && !@pool_worker.wait_for_admission(scheduler, runnable_count)
516
+ if @pool_worker
517
+ settings = @pool_worker.settings
518
+ settings = @pool_worker.wait_for_admission(scheduler, runnable_count, settings)
519
+ break unless settings
520
+ end
497
521
  timed_out = false
498
- timeout = @pool_worker&.idle_timeout
499
- task = @queue.pop(timeout:) { timed_out = true }
522
+ task = if @pool_worker
523
+ @queue.pop_interruptible(settings, settings.shrink_after) { timed_out = true }
524
+ else
525
+ @queue.pop { timed_out = true }
526
+ end
500
527
  if timed_out
501
- break if @pool_worker&.retire?
528
+ break if @pool_worker&.retire?(expected: settings)
502
529
  next
503
530
  end
504
531
  next unless task
505
532
  task = task.value if task.is_a?(Envelope)
506
533
  if task
507
- @pool_worker&.task_started
534
+ @pool_worker&.task_started(settings)
508
535
  yield task
509
536
  end
510
537
  end
@@ -156,18 +156,18 @@ module Farce
156
156
  timestamp = internal_queue.peek_priority
157
157
  wake_after = nil
158
158
  if timestamp
159
- delay = timestamp - Clock.now
159
+ delay = Clock.remaining(timestamp)
160
160
  return timestamp unless delay.positive?
161
161
  wake_after = delay if delay.finite?
162
162
  end
163
163
 
164
- remaining = remaining_timeout(deadline)
164
+ remaining = Clock.remaining(deadline)
165
165
 
166
166
  wait_for = wake_after && remaining ?
167
167
  (wake_after < remaining ? wake_after : remaining) :
168
168
  wake_after || remaining
169
169
  changed = internal_signal.wait(generation, timeout: wait_for)
170
- return UNDEFINED if !changed && deadline && remaining_timeout(deadline).zero?
170
+ return UNDEFINED if !changed && deadline && Clock.remaining(deadline).zero?
171
171
  end
172
172
  end
173
173
  end
@@ -1290,7 +1290,7 @@ module Farce
1290
1290
  # @param timeout [Numeric, nil] the total seconds available
1291
1291
  # @return [BasicObject, nil] the matching value, or nil on timeout
1292
1292
  # @raise [LocalJumpError] if no block is given
1293
- def wait_until(index, timeout: nil, &) = Internal.wait_until(self, index, timeout:, &)
1293
+ def wait_until(index, timeout: nil, &) = Internal.wait_until(internal_vector, index, timeout:, &)
1294
1294
 
1295
1295
  # Wait while the block returns a truthy value.
1296
1296
  # @yieldparam value [BasicObject, nil] the current value
@@ -1390,6 +1390,8 @@ module Farce
1390
1390
 
1391
1391
  private
1392
1392
 
1393
+ def normalize_vector_source(source) = source.is_a?(Integer) ? Array.new(source) : source
1394
+
1393
1395
  def mutate_vector(timeout: nil)
1394
1396
  result = nil
1395
1397
  completed = internal_vector.batch_update(timeout:) { |values| result = yield values }
data/lib/farce/atom.rb CHANGED
@@ -217,6 +217,13 @@ module Farce
217
217
  unwrap_value(result)
218
218
  end
219
219
 
220
+ # (see Abstract::Atom#wait_until)
221
+ def wait_until(timeout: nil)
222
+ raise LocalJumpError, "no block given" unless block_given?
223
+ result = Internal.wait_until(@atom, timeout:) { |current| yield(unwrap_value(current)) }
224
+ unwrap_value(result)
225
+ end
226
+
220
227
  # Wait until the current value no longer matches an expected value.
221
228
  # @param expected [BasicObject, nil] the value to compare with the current value
222
229
  # @param timeout [Numeric, nil] the maximum number of seconds to wait
@@ -227,11 +234,11 @@ module Farce
227
234
  deadline = Internal.timeout_deadline(timeout)
228
235
 
229
236
  while true
230
- current = @atom.get(timeout: Internal.remaining_timeout(deadline)) { TIMED_OUT }
237
+ current = @atom.get(timeout: Clock.remaining(deadline)) { TIMED_OUT }
231
238
  return unwrap_result(current, &) if TIMED_OUT.equal?(current)
232
239
  return unwrap_value(current) unless values_equal?(current, expected)
233
240
 
234
- result = @atom.wait_until_changed(current, timeout: Internal.remaining_timeout(deadline)) { TIMED_OUT }
241
+ result = @atom.wait_until_changed(current, timeout: Clock.remaining(deadline)) { TIMED_OUT }
235
242
  return unwrap_result(result, &) if TIMED_OUT.equal?(result)
236
243
  end
237
244
  end
data/lib/farce/clock.rb CHANGED
@@ -31,6 +31,18 @@ module Farce
31
31
  # @return [Float] the clock time in seconds
32
32
  def current = Process.clock_gettime(CLOCK_MONOTONIC) - MONOTONIC
33
33
 
34
+ # Returns the seconds left until a monotonic clock deadline.
35
+ # Expired deadlines return zero. Nil means there is no deadline and returns nil.
36
+ #
37
+ # @param deadline [Numeric, nil] a deadline from {#clock} or {#timeout}
38
+ # @raise [ArgumentError] if the deadline is NaN
39
+ # @return [Float, Integer, nil] the remaining seconds, zero, or nil
40
+ def remaining(deadline)
41
+ return if deadline.nil?
42
+ remaining = validate_timestamp(deadline - now)
43
+ remaining.positive? ? remaining : 0
44
+ end
45
+
34
46
  # Converts the given value to a monotonic clock time in seconds.
35
47
  #
36
48
  # @param value [nil, Numeric, Time, ActiveSupport::Duration, Hash]
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+ # warn_indent: true
4
+
5
+ module Farce
6
+ module Concurrent
7
+ # A replacement for `concurrent-ruby`'s `CountDownLatch` implementation.
8
+ # @see https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/CountDownLatch.html
9
+ class CountDownLatch
10
+ include Shareable::Delegated
11
+ include Internal::Noncopyable
12
+
13
+ # @param count [Integer] the initial count for the latch.
14
+ # @raise [ArgumentError] if the count is not a non-negative integer.
15
+ def initialize(count = 1)
16
+ raise ArgumentError, "count must be a non-negative integer" unless count.is_a?(Integer) && count >= 0
17
+ @counter = Counter.new(count)
18
+ super()
19
+ end
20
+
21
+ # @!attribute [r] count
22
+ # @return [Integer] the current count of the latch.
23
+ def count = @counter.value
24
+
25
+ # Decrements the count of the latch by 1 if it is above 0.
26
+ # @return [void]
27
+ def count_down = @counter.decrement_if_above(0)
28
+
29
+ # Waits until the count reaches 0 or the optional timeout is reached.
30
+ # @param timeout [Numeric, nil] the maximum time to wait in seconds, or nil to wait indefinitely.
31
+ # @return [Boolean] true if the count reached 0, false if the timeout was reached.
32
+ def wait(timeout = nil) = !!@counter.wait_while_above(0, timeout:) # rubocop:disable Naming/PredicateMethod
33
+
34
+ private def freeze_backend = @counter
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+ # warn_indent: true
4
+
5
+ module Farce
6
+ module Concurrent
7
+ # A replacement for `concurrent-ruby`'s `CyclicBarrier` implementation.
8
+ # @see https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/CyclicBarrier.html
9
+ class CyclicBarrier
10
+ include Shareable::Delegated
11
+ include Internal::Noncopyable
12
+
13
+ # @api private
14
+ class Generation
15
+ include Shareable::Delegated
16
+
17
+ attr_reader :waiting
18
+
19
+ def initialize
20
+ @waiting = Counter.new
21
+ @state = Strict::Atom.new(:waiting)
22
+ super
23
+ end
24
+
25
+ def wait(timeout) = @state.wait_until_changed(:waiting, timeout:)
26
+ def state = @state.value
27
+ def number_waiting = state == :waiting ? @waiting.value : 0
28
+
29
+ def state=(value)
30
+ @state.value = value
31
+ end
32
+
33
+ private def freeze_backend = @waiting
34
+ end
35
+ private_constant :Generation
36
+
37
+ # @return [Integer] the number of parties required to trip the barrier.
38
+ attr_reader :parties
39
+
40
+ # @param parties [Integer] the number of parties required to trip the barrier.
41
+ # @raise [ArgumentError] if parties is not a positive integer.
42
+ # @yield
43
+ # Optional block to trigger after the last waiter has arrived at the barrier but before they are released.
44
+ # Will be made Ractor-shareable. If it raises, the barrier breaks and the exception propagates to its caller.
45
+ def initialize(parties, &)
46
+ raise ArgumentError, "parties must be a positive integer" unless parties.is_a?(Integer) && parties.positive?
47
+ @parties = parties
48
+ @action = block_given? ? Ractor.shareable_proc(&) : nil
49
+ @generation = Strict::Atom.new(Generation.new)
50
+ super()
51
+ end
52
+
53
+ # @return [Boolean] whether the barrier is in a broken state.
54
+ def broken? = generation.state == :broken
55
+
56
+ # @return [Integer] the number of parties currently waiting at the barrier.
57
+ def number_waiting = generation.number_waiting
58
+
59
+ # Waits for all parties to reach the barrier. Successful generations can be reused immediately.
60
+ # A timeout breaks the barrier for all waiters until {#reset} is called.
61
+ # @param timeout [Numeric, nil] the maximum time to wait for the barrier.
62
+ # @return [Boolean] true if the barrier was successfully reached, false if it was broken by a timeout or reset.
63
+ def wait(timeout = nil) # rubocop:disable Naming/PredicateMethod
64
+ deadline = Internal.timeout_deadline(timeout)
65
+ waiting = nil
66
+ update do |current|
67
+ waiting = current
68
+ next current unless current.state == :waiting
69
+ current.waiting.increment
70
+ next current if current.waiting.value < @parties
71
+
72
+ completed = false
73
+ begin
74
+ @action&.call
75
+ completed = true
76
+ ensure
77
+ current.state = completed ? :fulfilled : :broken
78
+ end
79
+ Generation.new
80
+ end
81
+
82
+ unless waiting.wait(Clock.remaining(deadline))
83
+ update do |current|
84
+ # A trip or reset may have won the race with this waiter's timeout.
85
+ waiting.state = :broken if waiting.state == :waiting
86
+ current
87
+ end
88
+ end
89
+ waiting.state == :fulfilled
90
+ end
91
+
92
+ # Breaks the current generation and starts a new one. Existing waiters return false.
93
+ # @return [nil]
94
+ def reset
95
+ update do |current|
96
+ current.state = :broken
97
+ Generation.new
98
+ end
99
+ nil
100
+ end
101
+
102
+ private
103
+
104
+ def freeze_backend = @generation
105
+ def generation = @generation.value
106
+ def update(&) = @generation.update(&)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+ # warn_indent: true
4
+
5
+ module Farce
6
+ module Concurrent
7
+ # A replacement for `concurrent-ruby`'s `Event` implementation.
8
+ # @see https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/Event.html
9
+ class Event
10
+ include Shareable::Delegated
11
+ include Internal::Noncopyable
12
+
13
+ def initialize
14
+ # Odd generations are set. Even generations are reset without forgetting earlier signals.
15
+ @state = Strict::Atom.new(0)
16
+ super
17
+ end
18
+
19
+ # @return [Boolean] whether the event is currently set.
20
+ def set? = @state.value.odd?
21
+
22
+ # Sets the event.
23
+ # @return [true] always returns `true`.
24
+ def set # rubocop:disable Naming/PredicateMethod
25
+ try?
26
+ true
27
+ end
28
+
29
+ # @return [Boolean] `true` if the event was successfully set, `false` otherwise.
30
+ def try?
31
+ changed = false
32
+ @state.update do |current|
33
+ next current if current.odd?
34
+ changed = true
35
+ current + 1
36
+ end
37
+ changed
38
+ end
39
+
40
+ # @return [true] always returns `true`.
41
+ def reset # rubocop:disable Naming/PredicateMethod
42
+ @state.update { |current| current.odd? ? current + 1 : current }
43
+ true
44
+ end
45
+
46
+ # Waits for the event to be set or for the specified timeout to elapse.
47
+ # Resetting the event does not retract a signal already sent to current waiters.
48
+ # @param timeout [Numeric, nil] the maximum number of seconds to wait, or `nil` to wait indefinitely.
49
+ # @return [Boolean] `true` if the event was set, `false` if the timeout elapsed.
50
+ def wait(timeout = nil) # rubocop:disable Naming/PredicateMethod
51
+ deadline = Internal.timeout_deadline(timeout)
52
+ observed = @state.value
53
+ return true if observed.odd?
54
+ !!@state.wait_until_changed(observed, timeout: Clock.remaining(deadline))
55
+ end
56
+
57
+ private def freeze_backend = @state
58
+ end
59
+ end
60
+ end