farce 0.4.1-java → 0.5.0-java

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 (98) 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/lib/farce/abstract/concurrent_map.rb +122 -0
  16. data/lib/farce/abstract/counter.rb +35 -1
  17. data/lib/farce/abstract/executor.rb +30 -7
  18. data/lib/farce/abstract/flag.rb +12 -0
  19. data/lib/farce/abstract/pool.rb +158 -47
  20. data/lib/farce/abstract/priority_queue.rb +1 -1
  21. data/lib/farce/abstract/publisher.rb +189 -0
  22. data/lib/farce/abstract/queue.rb +29 -3
  23. data/lib/farce/abstract/scheduler.rb +41 -14
  24. data/lib/farce/abstract/timer_queue.rb +3 -3
  25. data/lib/farce/abstract/vector.rb +3 -1
  26. data/lib/farce/atom.rb +9 -2
  27. data/lib/farce/clock.rb +12 -0
  28. data/lib/farce/concurrent/count_down_latch.rb +37 -0
  29. data/lib/farce/concurrent/cyclic_barrier.rb +109 -0
  30. data/lib/farce/concurrent/event.rb +60 -0
  31. data/lib/farce/concurrent/fiber_local_var.rb +31 -0
  32. data/lib/farce/concurrent/local_var.rb +65 -0
  33. data/lib/farce/concurrent/thread_local_var.rb +28 -0
  34. data/lib/farce/concurrent/tuple.rb +74 -0
  35. data/lib/farce/concurrent.rb +50 -0
  36. data/lib/farce/config.rb +112 -2
  37. data/lib/farce/counter.rb +0 -16
  38. data/lib/farce/engine/jruby/map.rb +8 -6
  39. data/lib/farce/engine/jruby/signal.rb +2 -2
  40. data/lib/farce/engine/jvm/farce.jar +0 -0
  41. data/lib/farce/engine/jvm/priority_queue.rb +14 -4
  42. data/lib/farce/engine/ruby/shared/parallel_scheduler.rb +9 -1
  43. data/lib/farce/engine/ruby/shared/ractor_selector.rb +1 -4
  44. data/lib/farce/engine/ruby/shared/weak_map.rb +9 -12
  45. data/lib/farce/engine/shared/exchanger.rb +2 -2
  46. data/lib/farce/engine/shared/queue.rb +43 -5
  47. data/lib/farce/engine/shared/ractor_selector.rb +1 -1
  48. data/lib/farce/engine/shared/strict_map.rb +5 -0
  49. data/lib/farce/engine/shared/unshared_atom.rb +1 -1
  50. data/lib/farce/engine/shared/unshared_vector.rb +8 -7
  51. data/lib/farce/engine/shared/unshared_weak_map.rb +8 -6
  52. data/lib/farce/engine/shared/weak_atom/base.rb +5 -10
  53. data/lib/farce/engine/shared/weak_map/cell.rb +1 -1
  54. data/lib/farce/engine/truffleruby/map.rb +8 -6
  55. data/lib/farce/engine/truffleruby/native/priority_queue.rb +18 -4
  56. data/lib/farce/engine/truffleruby/signal.rb +1 -1
  57. data/lib/farce/integrations/concurrent.rb +16 -3
  58. data/lib/farce/internal/blocking_priority_queue.rb +4 -9
  59. data/lib/farce/internal/flag_waiting.rb +1 -1
  60. data/lib/farce/internal/lease_initialization.rb +1 -1
  61. data/lib/farce/internal/lease_pool_state.rb +1 -1
  62. data/lib/farce/internal/lease_state.rb +2 -2
  63. data/lib/farce/internal/local_executor_methods.rb +3 -3
  64. data/lib/farce/internal/map_value_modes.rb +9 -2
  65. data/lib/farce/internal/pool_settings.rb +9 -0
  66. data/lib/farce/internal/pool_worker.rb +34 -21
  67. data/lib/farce/internal/reservation_waiting.rb +1 -1
  68. data/lib/farce/internal/scheduler_lifecycle.rb +2 -2
  69. data/lib/farce/internal/weak_map_value_modes.rb +5 -0
  70. data/lib/farce/internal.rb +8 -8
  71. data/lib/farce/lazy.rb +3 -0
  72. data/lib/farce/lazy_ref.rb +3 -0
  73. data/lib/farce/local/lease.rb +2 -9
  74. data/lib/farce/local/lease_map.rb +2 -9
  75. data/lib/farce/local/pool.rb +38 -5
  76. data/lib/farce/local/publisher.rb +30 -0
  77. data/lib/farce/local/scheduler.rb +18 -5
  78. data/lib/farce/local/vector.rb +3 -2
  79. data/lib/farce/memoize.rb +314 -0
  80. data/lib/farce/pool.rb +5 -0
  81. data/lib/farce/priority_queue.rb +1 -1
  82. data/lib/farce/publisher.rb +57 -0
  83. data/lib/farce/signal.rb +1 -1
  84. data/lib/farce/strict/publisher.rb +31 -0
  85. data/lib/farce/strict/vector.rb +3 -2
  86. data/lib/farce/subscription/definitions.rb +27 -0
  87. data/lib/farce/subscription.rb +121 -0
  88. data/lib/farce/transaction/map.rb +35 -1
  89. data/lib/farce/unshared/pool.rb +1 -1
  90. data/lib/farce/unshared/publisher.rb +25 -0
  91. data/lib/farce/unshared/scheduler.rb +3 -3
  92. data/lib/farce/unshared/vector.rb +3 -2
  93. data/lib/farce/vector.rb +12 -9
  94. data/lib/farce/version.rb +1 -1
  95. data/lib/farce/walker/definitions.rb +13 -2
  96. data/lib/farce/weak_atom.rb +5 -2
  97. data/lib/farce.rb +3 -1
  98. metadata +19 -2
@@ -13,21 +13,6 @@ module Farce
13
13
  class Pool < Executor
14
14
  include Internal::Inspect
15
15
 
16
- # @return [Float] Maximum queue wait before adding a worker.
17
- attr_reader :grow_after
18
-
19
- # @return [Integer, nil] Soft task limit for each worker.
20
- attr_reader :max_inflight
21
-
22
- # @return [Integer] Maximum worker count.
23
- attr_reader :max_size
24
-
25
- # @return [Integer] Minimum worker count.
26
- attr_reader :min_size
27
-
28
- # @return [Float, nil] Idle time before an extra worker retires.
29
- attr_reader :shrink_after
30
-
31
16
  # Create and start an elastic pool using the configured fiber scheduler.
32
17
  # An explicit constructor block overrides the configured default.
33
18
  #
@@ -49,6 +34,7 @@ module Farce
49
34
  initialize_configuration(min_size:, max_size:, max_inflight:, grow_after:, shrink_after:, backend:)
50
35
  @constructor = prepare_constructor(constructor)
51
36
  @queue = new_queue(capacity: capacity && Integer(capacity))
37
+ @queue.interrupt(settings, settings.generation)
52
38
  @state = Internal::Atom.new(:running)
53
39
  @error = Internal::Atom.new
54
40
  @worker_count = Internal::Atom.new(0)
@@ -57,25 +43,92 @@ module Farce
57
43
  super()
58
44
  publish_pool
59
45
  begin
60
- @min_size.times { start_worker }
46
+ settings.min_size.times { start_worker }
61
47
  rescue Exception # rubocop:disable Lint/RescueException
62
48
  close
63
49
  raise
64
50
  end
65
51
  end
66
52
 
53
+ # @return [Float] Maximum queue wait before adding a worker.
54
+ def grow_after = settings.grow_after
55
+
56
+ # @return [Integer, nil] Soft task limit for each worker.
57
+ def max_inflight = settings.max_inflight
58
+
59
+ # @return [Integer] Maximum worker count.
60
+ def max_size = settings.max_size
61
+
62
+ # @return [Integer] Minimum worker count.
63
+ def min_size = settings.min_size
64
+
65
+ # @return [Float, nil] Idle time before an extra worker retires.
66
+ def shrink_after = settings.shrink_after
67
+
68
+ # @api private
69
+ def settings = @settings.value
70
+
71
+ # Pending-task capacity. A smaller capacity preserves already queued tasks.
72
+ # @return [Integer, nil]
73
+ def capacity = @queue.capacity
74
+
75
+ # Resize the pending-task queue. Nil makes the queue unbounded on CRuby.
76
+ # JRuby and TruffleRuby retain their initial bounded or unbounded choice.
77
+ def capacity=(value)
78
+ @queue.capacity = value.nil? ? nil : Integer(value)
79
+ end
80
+
81
+ # Start and retain at least this many workers. Must not exceed max_size.
82
+ def min_size=(value)
83
+ value = Integer(value)
84
+ update_settings(min_size: value)
85
+ value
86
+ end
87
+
88
+ # Limit worker count. Excess workers retire when their running tasks leave them idle.
89
+ # Must be positive and at least min_size.
90
+ def max_size=(value)
91
+ value = Integer(value)
92
+ update_settings(max_size: value)
93
+ value
94
+ end
95
+
96
+ # Set the soft per-worker task limit. Nil disables the limit.
97
+ def max_inflight=(value)
98
+ value &&= Integer(value)
99
+ update_settings(max_inflight: value)
100
+ value
101
+ end
102
+
103
+ # Change the queue wait before adding a worker. Rechecks existing queued work.
104
+ def grow_after=(value)
105
+ value = duration(value, :grow_after)
106
+ update_settings(grow_after: value)
107
+ value
108
+ end
109
+
110
+ # Change the idle timeout. Nil disables idle retirement. Restarts idle waits.
111
+ def shrink_after=(value)
112
+ value = duration(value, :shrink_after, nil_allowed: true)
113
+ update_settings(shrink_after: value)
114
+ value
115
+ end
116
+
67
117
  # Enqueue a task for any worker in the pool.
68
118
  #
69
119
  # Ractor pools reject local transfer. Thread pools preserve the block and
70
120
  # arguments. With `auto_local: false`, they accept only local or smart modes.
121
+ # A `timeout` limits waiting for queue space. Expiry returns `nil` without
122
+ # enqueuing the task. Accepted tasks have no execution timeout.
71
123
  #
72
- # @overload schedule(*args, mode: :smart, auto_local: true)
124
+ # @overload schedule(*args, mode: :smart, auto_local: true, timeout: nil)
73
125
  # @!macro modes
74
126
  # @param args [Array<Object>] Positional arguments passed to the task block.
75
127
  # @param mode [Symbol] Argument transfer mode.
76
128
  # @param auto_local [Boolean] Whether to use local transfer in a thread pool.
129
+ # @param timeout [Numeric, nil] Maximum scheduling wait in seconds. Nil waits indefinitely, zero polls.
77
130
  # @yield [*args] Task to execute.
78
- # @return [Pool] self.
131
+ # @return [self, nil] self when submitted, or nil when the scheduling timeout expires.
79
132
  # @raise [PoolClosedError] If the pool is closing or closed.
80
133
  def schedule(...) = enqueue_task(false, ...)
81
134
 
@@ -83,9 +136,13 @@ module Farce
83
136
  # Arguments and transfer modes follow {#schedule}.
84
137
  #
85
138
  # @overload try_schedule(*args, mode: :smart, auto_local: true)
86
- # @param (see Farce::Abstract::Executor#schedule)
139
+ # @param args (see Farce::Abstract::Executor#schedule)
140
+ # @param mode (see Farce::Abstract::Executor#schedule)
141
+ # @param auto_local (see Farce::Abstract::Executor#schedule)
87
142
  # @return [self, nil] self when submitted, or nil when the queue is full.
88
- def try_schedule(...) = enqueue_task(true, ...)
143
+ def try_schedule(*, mode: :smart, auto_local: true, &)
144
+ enqueue_task(true, *, mode:, auto_local:, &)
145
+ end
89
146
 
90
147
  # Stop accepting tasks and drain queued work.
91
148
  #
@@ -124,7 +181,9 @@ module Farce
124
181
  def drained? = closing? && @queue.closed?
125
182
 
126
183
  # Return whether all configured workers have been started.
127
- def at_max_size? = size >= @max_size
184
+ # @overload at_max_size?
185
+ # @return [Boolean]
186
+ def at_max_size?(settings = self.settings) = size >= settings.max_size
128
187
 
129
188
  # Return the generation used by workers waiting for admission.
130
189
  # @api private
@@ -140,19 +199,22 @@ module Farce
140
199
 
141
200
  # Record that a task left the shared queue.
142
201
  # @api private
143
- def task_taken
144
- return if @pressure.value.nil? || at_max_size?
202
+ def task_taken(settings = self.settings)
203
+ return if @pressure.value.nil? || at_max_size?(settings)
145
204
  return unless queue_empty?
146
205
  @pressure.value = nil
147
- arm_scaler if @queue.size.positive?
206
+ arm_scaler(settings) if @queue.size.positive?
148
207
  end
149
208
 
150
209
  # Reserve a worker retirement above the minimum size.
151
210
  # @api private
152
- def retire_worker
211
+ def retire_worker(excess: false, expected: nil)
153
212
  retired = false
154
213
  @worker_count.update do |count|
155
- if state == :running && count > @min_size && queue_empty?
214
+ settings = self.settings
215
+ next count if expected && !expected.equal?(settings)
216
+ limit = excess ? settings.max_size : settings.min_size
217
+ if state == :running && count > limit && (excess || queue_empty?)
156
218
  retired = true
157
219
  count - 1
158
220
  else
@@ -162,9 +224,28 @@ module Farce
162
224
  retired
163
225
  end
164
226
 
227
+ # Recheck idle dispatchers after an excess worker finishes its last task.
228
+ # @api private
229
+ def wake_retiring_workers
230
+ return admission_changed if closed?
231
+ updated = @settings.update { |current| current.with(generation: current.generation + 1) }
232
+ @queue.interrupt(updated, updated.generation)
233
+ admission_changed
234
+ end
235
+
236
+ # Finish an already reserved retirement before attempting replacement.
237
+ # @api private
238
+ def worker_retired
239
+ ensure_minimum
240
+ admission_changed
241
+ rescue Exception => e # rubocop:disable Lint/RescueException
242
+ @error.compare_and_set(nil, Envelope.new(e))
243
+ end
244
+
165
245
  # Remove a stopped worker and preserve its failure.
166
246
  # @api private
167
247
  def worker_stopped(error, replace: true)
248
+ settings = self.settings
168
249
  @worker_count.update { |count| count - 1 }
169
250
  @error.compare_and_set(nil, Envelope.new(error)) if error
170
251
  if closing?
@@ -173,11 +254,11 @@ module Farce
173
254
  end
174
255
  elsif state == :running && replace
175
256
  begin
176
- start_worker while size < @min_size
257
+ start_worker(settings) while size < settings.min_size
177
258
  rescue Exception # rubocop:disable Lint/RescueException
178
259
  nil
179
260
  end
180
- arm_scaler if @queue.size.positive?
261
+ arm_scaler(settings) if @queue.size.positive?
181
262
  end
182
263
  admission_changed
183
264
  end
@@ -185,41 +266,44 @@ module Farce
185
266
  # Recheck delayed queue pressure and grow by one worker.
186
267
  # @api private
187
268
  def scale_if_needed(started)
269
+ settings = self.settings
188
270
  return unless state == :running && @pressure.value == started
189
271
  age = @queue.oldest_age
190
272
  return clear_pressure(started) unless age
191
- return reschedule_scaler(started, @grow_after - age) if age < @grow_after
273
+ return reschedule_scaler(started, settings.grow_after - age) if age < settings.grow_after
192
274
 
193
- start_worker unless at_max_size?
194
- return clear_pressure(started) if queue_empty? || at_max_size?
275
+ start_worker(settings) unless at_max_size?(settings)
276
+ return clear_pressure(started) if queue_empty? || at_max_size?(settings)
195
277
 
196
278
  next_started = @queue.generation
197
- schedule_scaler(next_started) if @pressure.compare_and_set(started, next_started)
279
+ schedule_scaler(next_started, settings.grow_after) if @pressure.compare_and_set(started, next_started)
198
280
  rescue Exception => e # rubocop:disable Lint/RescueException
199
281
  @error.compare_and_set(nil, Envelope.new(e))
200
282
  end
201
283
 
202
284
  # @api private
203
285
  def inspect_with(inspector)
286
+ settings = self.settings
204
287
  super do
205
288
  inspector.attribute(:state, state)
206
289
  inspector.attribute(:backend, @backend)
207
290
  inspector.attribute(:size, size)
208
- inspector.attribute(:min_size, @min_size)
209
- inspector.attribute(:max_size, @max_size)
210
- inspector.attribute(:max_inflight, @max_inflight) if @max_inflight
291
+ inspector.attribute(:min_size, settings.min_size)
292
+ inspector.attribute(:max_size, settings.max_size)
293
+ inspector.attribute(:max_inflight, settings.max_inflight) if settings.max_inflight
211
294
  yield if block_given?
212
295
  end
213
296
  end
214
297
 
215
298
  private
216
299
 
217
- def enqueue_task(non_block, *args, mode: :smart, auto_local: true, &block)
300
+ def enqueue_task(non_block, *args, mode: :smart, auto_local: true, timeout: nil, &block)
218
301
  raise PoolClosedError, "cannot schedule task on a closed pool" if closed?
219
302
  raise LocalJumpError, "Cannot schedule without a block" unless block
220
303
 
304
+ deadline = scheduling_deadline(timeout)
221
305
  task = prepare_task(args, block, mode, auto_local)
222
- return unless non_block ? @queue.try_push(task) : @queue.push(task)
306
+ return unless non_block ? @queue.try_push(task) : @queue.push(task, timeout: Clock.remaining(deadline))
223
307
  arm_scaler
224
308
  self
225
309
  rescue ClosedQueueError
@@ -227,6 +311,28 @@ module Farce
227
311
  raise PoolClosedError, "cannot schedule task on a closed pool"
228
312
  end
229
313
 
314
+ def update_settings(**changes)
315
+ raise PoolClosedError, "cannot configure a closed pool" if closed?
316
+ updated = @settings.update do |current|
317
+ value = current.with(**changes, generation: current.generation + 1)
318
+ raise ArgumentError, "min_size must be non-negative" if value.min_size.negative?
319
+ raise ArgumentError, "max_size must be positive" unless value.max_size.positive?
320
+ raise ArgumentError, "min_size must not exceed max_size" if value.min_size > value.max_size
321
+ raise ArgumentError, "max_inflight must be positive" if value.max_inflight && !value.max_inflight.positive?
322
+ value
323
+ end
324
+ @queue.interrupt(updated, updated.generation)
325
+ admission_changed
326
+ ensure_minimum
327
+ @pressure.value = nil
328
+ arm_scaler
329
+ updated
330
+ end
331
+
332
+ def ensure_minimum
333
+ start_worker while state == :running && size < settings.min_size
334
+ end
335
+
230
336
  def initialize_configuration(min_size:, max_size:, max_inflight:, grow_after:, shrink_after:, backend:)
231
337
  @min_size = Integer(min_size)
232
338
  @max_size = Integer(max_size)
@@ -235,6 +341,10 @@ module Farce
235
341
  @shrink_after = duration(shrink_after, :shrink_after, nil_allowed: true)
236
342
  @backend = backend
237
343
  validate_sizes
344
+ @settings = Internal::Atom.new(Internal::PoolSettings.new(
345
+ min_size: @min_size, max_size: @max_size, max_inflight: @max_inflight,
346
+ grow_after: @grow_after, shrink_after: @shrink_after, generation: 0,
347
+ ))
238
348
  end
239
349
 
240
350
  def prepare_constructor(constructor)
@@ -266,11 +376,11 @@ module Farce
266
376
  value
267
377
  end
268
378
 
269
- def start_worker(drain: false, only_if_empty: false)
379
+ def start_worker(settings = self.settings, drain: false, only_if_empty: false)
270
380
  reserved = false
271
381
  @worker_count.update do |count|
272
382
  can_start = state == :running || (drain && closing? && !queue_empty?)
273
- if can_start && count < @max_size && (!only_if_empty || count.zero?)
383
+ if can_start && count < settings.max_size && (!only_if_empty || count.zero?)
274
384
  reserved = true
275
385
  count + 1
276
386
  else
@@ -288,17 +398,17 @@ module Farce
288
398
  &@constructor
289
399
  )
290
400
  launch_scheduler(scheduler)
291
- admission_changed if at_max_size?
401
+ admission_changed if at_max_size?(settings)
292
402
  true
293
403
  rescue Exception => e # rubocop:disable Lint/RescueException
294
404
  worker ? worker.stopped(e, replace: false) : @worker_count.update { |count| count - 1 }
295
405
  raise
296
406
  end
297
407
 
298
- def arm_scaler
299
- return if at_max_size? || queue_empty? || state != :running
300
- start_worker(only_if_empty: true) if size.zero?
301
- return if at_max_size?
408
+ def arm_scaler(settings = self.settings)
409
+ return if at_max_size?(settings) || queue_empty? || state != :running
410
+ start_worker(settings, only_if_empty: true) if size.zero?
411
+ return if at_max_size?(settings)
302
412
 
303
413
  started = @queue.generation
304
414
  age = @queue.oldest_age
@@ -306,13 +416,13 @@ module Farce
306
416
  return unless age
307
417
  return unless @pressure.compare_and_set(nil, started)
308
418
 
309
- remaining = @grow_after - age
419
+ remaining = settings.grow_after - age
310
420
  remaining = 0 if remaining.negative?
311
421
 
312
422
  schedule_scaler(started, remaining)
313
423
  end
314
424
 
315
- def schedule_scaler(started, delay = @grow_after)
425
+ def schedule_scaler(started, delay = settings.grow_after)
316
426
  Internal::PoolSupervisor.schedule(self, started, delay)
317
427
  end
318
428
 
@@ -322,8 +432,9 @@ module Farce
322
432
  end
323
433
 
324
434
  def clear_pressure(started)
435
+ settings = self.settings
325
436
  return unless @pressure.compare_and_set(started, nil)
326
- arm_scaler if @queue.size.positive?
437
+ arm_scaler(settings) if @queue.size.positive?
327
438
  end
328
439
 
329
440
  def queue_empty? = @queue.size.zero? # rubocop:disable Style/ZeroLengthPredicate
@@ -44,7 +44,7 @@ module Farce
44
44
  end
45
45
 
46
46
  return result unless empty
47
- remaining = remaining_timeout(deadline)
47
+ remaining = Clock.remaining(deadline)
48
48
  return block_given? ? yield : nil unless wait_pop(timeout: remaining)
49
49
  end
50
50
  end
@@ -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