farce 0.2.0-arm-linux-gnu → 0.3.0-arm-linux-gnu
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 +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +39 -1
- data/docs/modes.md +8 -5
- data/docs/scopes.md +3 -1
- data/docs/variants.md +18 -0
- data/lib/farce/abstract/executor.rb +130 -0
- data/lib/farce/abstract/pool.rb +334 -0
- data/lib/farce/abstract/scheduler.rb +539 -93
- data/lib/farce/engine/jruby/signal.rb +7 -4
- data/lib/farce/engine/shared/main_scheduler.rb +2 -2
- data/lib/farce/engine/shared/parallel_scheduler.rb +1 -1
- data/lib/farce/engine/shared/proxy_owner.rb +1 -1
- data/lib/farce/engine/shared/unshared_atom.rb +9 -8
- data/lib/farce/engine/shared.rb +9 -0
- data/lib/farce/internal/lease_initialization.rb +1 -1
- data/lib/farce/internal/local_executor_methods.rb +33 -0
- data/lib/farce/internal/local_scheduled_task.rb +27 -0
- data/lib/farce/internal/scheduled_task.rb +1 -1
- data/lib/farce/internal/thread_pool_supervisor.rb +45 -0
- data/lib/farce/internal/unshared_pool_worker.rb +14 -0
- data/lib/farce/internal.rb +14 -0
- data/lib/farce/local/pool.rb +111 -0
- data/lib/farce/local/scheduler.rb +219 -0
- data/lib/farce/pool.rb +2 -290
- data/lib/farce/proxy.rb +2 -2
- data/lib/farce/scheduler.rb +1 -502
- data/lib/farce/task.rb +285 -0
- data/lib/farce/{thread_scheduler.rb → thread_executor.rb} +7 -7
- data/lib/farce/unshared/pool.rb +44 -0
- data/lib/farce/unshared/scheduler.rb +89 -0
- data/lib/farce/version.rb +1 -1
- data/lib/farce.rb +9 -17
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8295409e81c0cbabd7868166bfbf29d456d99babab6dee561feb8d3676a865d
|
|
4
|
+
data.tar.gz: 4fdf1d5d80980fca658668abf24ca9395524e65d30e4e4ce20745463c157117f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 86ebdae86e939f495543480a53b7df19aed571cadd1207a605122737424d14f6033584e0d96869ceafb9bf9383d34020b8ff388f4569b523f8d5cfa8f73b665f
|
|
7
|
+
data.tar.gz: 65d5fbdf1843f628c60f5e07e49b551ae4f1354c34afa72e02b95b18615a0d86d1a97c6bf4203c9933ce27ff34c433b07be01baa5721e907edcef22d8a241178
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,53 @@ Farce follows [Semantic Versioning 2.0](http://semver.org/). Any public method o
|
|
|
4
4
|
|
|
5
5
|
## Development Releases
|
|
6
6
|
|
|
7
|
+
### Farce 0.3.0 (2026-10-13)
|
|
8
|
+
|
|
9
|
+
#### New Features
|
|
10
|
+
|
|
11
|
+
##### Breaking Changes
|
|
12
|
+
|
|
13
|
+
* `Abstract::Scheduler` is no longer a superclass of `Pool`. Use `Abstract::Executor` instead.
|
|
14
|
+
|
|
15
|
+
##### Executor Improvements
|
|
16
|
+
|
|
17
|
+
`Scheduler` and `Pool` have the following improvements:
|
|
18
|
+
|
|
19
|
+
* They now support `try_schedule`, which discards tasks and returns `nil` when their internal queues are at capacity.
|
|
20
|
+
* They now have `Unshared` and `Local` variants, running in a Ractor-local context (Pool will manage a Thread pool instead of a Ractor pool).
|
|
21
|
+
|
|
22
|
+
Creates a thread pool with a maximum size of 2 and a queue capacity of 10:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# A thread pool local to the current Ractor
|
|
26
|
+
thread_pool = Farce::Unshared::Pool.new(max_size: 2, capacity: 10)
|
|
27
|
+
|
|
28
|
+
# this should only run approximately 10 tasks on CRuby
|
|
29
|
+
500.times { thread_pool.try_schedule { puts "Task executed" } }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
##### Tasks
|
|
33
|
+
|
|
34
|
+
A new class, `Task`, has been introduced to represent asynchronous tasks that can be scheduled and executed within Farce's executor framework,
|
|
35
|
+
executed manually, or handed off to any other executor supporting procs or callable objects.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
task = Farce::Task.schedule do
|
|
39
|
+
puts "Task started"
|
|
40
|
+
sleep 1
|
|
41
|
+
:success
|
|
42
|
+
ensure
|
|
43
|
+
puts "Task finished"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
task.alive? # => true
|
|
47
|
+
task.value # => :success (blocks until the task completes)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
#### Bug Fixes
|
|
51
|
+
|
|
52
|
+
* Calling `wait_*` methods on atoms on JRuby no longer blocks a fiber scheduler.
|
|
53
|
+
|
|
7
54
|
### Farce 0.2.0 (2026-10-11)
|
|
8
55
|
|
|
9
56
|
#### New Features
|
data/README.md
CHANGED
|
@@ -117,6 +117,7 @@ puts output
|
|
|
117
117
|
- [`Farce.schedule`](#farceschedule)
|
|
118
118
|
- [Schedulers](#schedulers)
|
|
119
119
|
- [Ractor Pools](#ractor-pools)
|
|
120
|
+
- [Tasks](#tasks)
|
|
120
121
|
- [Third-Party Fiber Schedulers](#third-party-fiber-schedulers)
|
|
121
122
|
- [Integrations](#integrations)
|
|
122
123
|
- [Active Support](#active-support)
|
|
@@ -1222,6 +1223,43 @@ pool = Farce::Pool.new(min_size: 4, max_size: 10, grow_after: 0.1)
|
|
|
1222
1223
|
pool.schedule { puts "Hello from the pool!" }
|
|
1223
1224
|
```
|
|
1224
1225
|
|
|
1226
|
+
#### Tasks
|
|
1227
|
+
|
|
1228
|
+
If you want to retrieve the result of an asynchronous operation, or monitor if it is still running, you can use `Farce::Task`:
|
|
1229
|
+
|
|
1230
|
+
```ruby
|
|
1231
|
+
task = Farce::Task.schedule(1, 2) do |a, b|
|
|
1232
|
+
sleep 1 # simulate intense computation
|
|
1233
|
+
a + b
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
task.alive? # => true
|
|
1237
|
+
task.value # => 3 (after approximately 1 second)
|
|
1238
|
+
task.join # would block, but doesn't, as the task has already completed
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
Tasks can also be created without immediately scheduling them, allowing you to control when and how they are executed:
|
|
1242
|
+
|
|
1243
|
+
```ruby
|
|
1244
|
+
task = Farce::Task.new { :success }
|
|
1245
|
+
task.alive? # => true
|
|
1246
|
+
task.value(0.1) # => nil (after 100 milliseconds)
|
|
1247
|
+
|
|
1248
|
+
Farce.schedule(&task)
|
|
1249
|
+
task.value # => :success
|
|
1250
|
+
```
|
|
1251
|
+
|
|
1252
|
+
This allows you to use third-party executors:
|
|
1253
|
+
|
|
1254
|
+
```ruby
|
|
1255
|
+
require "concurrent"
|
|
1256
|
+
executor = Concurrent.global_fast_executor
|
|
1257
|
+
task = Farce::Task.new { :success }
|
|
1258
|
+
executor.post(&task)
|
|
1259
|
+
|
|
1260
|
+
task.value # => :success
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1225
1263
|
#### Third-Party Fiber Schedulers
|
|
1226
1264
|
|
|
1227
1265
|
Both `Farce::Scheduler` and `Farce::Pool` run a Fiber scheduler under the hood to execute tasks. This is a Farce-internal scheduler by default, but you can replace it with your own if you want:
|
|
@@ -1420,7 +1458,7 @@ Other classes Farce provides include:
|
|
|
1420
1458
|
* `Exchanger`: A synchronization point for two-way data swapping between Threads, Ractors, and/or Fibers. Drop-in replacement for concurrent-ruby's exchanger.
|
|
1421
1459
|
* `Lazy`: Lazily initialized value.
|
|
1422
1460
|
* `LazyRef`: A [reference](#references) for a lazily initialized value.
|
|
1423
|
-
* `
|
|
1461
|
+
* `ThreadExecutor`: A [task executor](#scheduling-code) creating a new thread for each unit of work. Used on TruffleRuby.
|
|
1424
1462
|
* `Walker`: A tool for walking a Ruby object tree.
|
|
1425
1463
|
* `WeakValue`: A value object version of [`WeakRef`](#weakref). Allows handling references more explicitly, without automatic method delegation.
|
|
1426
1464
|
|
data/docs/modes.md
CHANGED
|
@@ -391,6 +391,7 @@ The same choices appear in several Farce APIs. Containers normally default to `:
|
|
|
391
391
|
| `Farce::WeakAtom` | `new`, `store`, `update`, and other replacement operations | Weakly held values. Supports only `:raise`, `:make_shareable`, and `:dedup`. Defaults to `:raise`. |
|
|
392
392
|
| `Farce::Atom` | `new`, `store`, `swap`, `update`, and other replacement operations | The stored value. |
|
|
393
393
|
| `Farce::Lazy`, `Farce::LazyRef` | `new` | The result computed once by the factory. |
|
|
394
|
+
| `Farce::Task` | `new`, `schedule`, `try_schedule`, `in_parallel`, `on_main`, `add_argument` | Arguments and the successful result. `add_argument` overrides the mode for one argument. Scheduling can select local mode automatically. |
|
|
394
395
|
| `Farce::Molecule` | `define`, `new` | Newly created field atoms. |
|
|
395
396
|
| `Farce::Vector` | `new`, `push`, `store`, `update`, and other replacement operations | Element values. |
|
|
396
397
|
| `Farce::WeakSet` | `new`, `add`, `add?` | Weakly held elements. Supports only `:raise`, `:make_shareable`, and `:dedup`. Defaults to `:raise`. |
|
|
@@ -400,9 +401,11 @@ The same choices appear in several Farce APIs. Containers normally default to `:
|
|
|
400
401
|
| `Farce::TreeMap` | `new` | Values only. Keys follow the tree map's own rules. |
|
|
401
402
|
| `Farce::LRUMap` | `new` | Values only. Individual value hits and writes update eviction order. |
|
|
402
403
|
| `Farce::LFUMap` | `new` | Values only. Individual value hits and writes update eviction frequency. |
|
|
403
|
-
| `Farce::Scheduler` | `schedule` | Task arguments, with automatic local transfer enabled by default. |
|
|
404
|
-
| `Farce::
|
|
405
|
-
| `Farce::
|
|
404
|
+
| `Farce::Scheduler` | `schedule`, `try_schedule` | Task arguments, with automatic local transfer enabled by default. |
|
|
405
|
+
| `Farce::Unshared::Scheduler`, `Farce::Local::Scheduler` | `schedule`, `try_schedule`, `execute` | Always local. With `auto_local: false`, only `:local` and smart modes are accepted. |
|
|
406
|
+
| `Farce::ThreadExecutor` | `schedule`, `try_schedule`, `execute` | Accepts scheduler options but always keeps arguments local. |
|
|
407
|
+
| `Farce::Pool` | `schedule`, `try_schedule` | Task arguments. `:local` is rejected. |
|
|
408
|
+
| `Farce::Unshared::Pool`, `Farce::Local::Pool` | `schedule`, `try_schedule`, `execute` | Always local. With `auto_local: false`, only `:local` and smart modes are accepted. |
|
|
406
409
|
|
|
407
410
|
`Farce::Lazy` runs its shareable factory once and applies the mode to the result. With
|
|
408
411
|
`:copy`, each Ractor receives its own cached copy. Use `Farce::Strict::Lazy` when the
|
|
@@ -541,7 +544,7 @@ For containers that retain values, `:copy` gives each Ractor its own copy of a s
|
|
|
541
544
|
|
|
542
545
|
### Pass task data as arguments
|
|
543
546
|
|
|
544
|
-
|
|
547
|
+
Schedulers and pools accept `mode:` for task arguments in `schedule` and `try_schedule`. Pass mutable data as arguments instead of capturing it from the surrounding scope. A non-local task's block must be convertible to a shareable proc.
|
|
545
548
|
|
|
546
549
|
```ruby
|
|
547
550
|
pool = Farce::Pool.new(max_size: 2)
|
|
@@ -557,7 +560,7 @@ pool.close
|
|
|
557
560
|
results.close
|
|
558
561
|
```
|
|
559
562
|
|
|
560
|
-
A scheduler defaults to `auto_local: true`, preserving the block and its arguments when scheduling from its owning Ractor. Set `auto_local: false` to enforce the requested mode there.
|
|
563
|
+
A scheduler defaults to `auto_local: true`, preserving the block and its arguments when scheduling from its owning Ractor. Set `auto_local: false` to enforce the requested mode there. `Farce::Pool` may choose another Ractor for any task, so it rejects `:local` and does not apply automatic local transfer. If you want a Pool with `local` support, use `Farce::Unshared::Pool` or `Farce::Local::Pool` instead.
|
|
561
564
|
|
|
562
565
|
## Under the hood
|
|
563
566
|
|
data/docs/scopes.md
CHANGED
|
@@ -238,7 +238,7 @@ Pass the needed data as task arguments when crossing an execution boundary. Use
|
|
|
238
238
|
|
|
239
239
|
## Classes that support scopes
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
The classes in this table accept the same five scopes. The scope determines which backing container or resource an operation uses. Each class retains its own behavior, such as queue ordering or weak references.
|
|
242
242
|
|
|
243
243
|
| Class | What each scope gets |
|
|
244
244
|
| --- | --- |
|
|
@@ -266,6 +266,8 @@ These classes accept the same five scopes. The scope determines which backing co
|
|
|
266
266
|
| `Farce::Local::Lease` | An independently initialized resource to borrow. |
|
|
267
267
|
| `Farce::Local::LeaseMap` | An independently initialized set of named resources. |
|
|
268
268
|
| `Farce::Local::LeasePool` | A pool with its own resources and capacity. |
|
|
269
|
+
| `Farce::Local::Scheduler` | A task scheduler with its own worker and lifecycle. |
|
|
270
|
+
| `Farce::Local::Pool` | An elastic pool of thread workers with independent scaling and lifecycle. |
|
|
269
271
|
|
|
270
272
|
### Count within each scope
|
|
271
273
|
|
data/docs/variants.md
CHANGED
|
@@ -174,6 +174,15 @@ end
|
|
|
174
174
|
<td align="center" style="text-align: center">➖</td> <!-- Unsafe -->
|
|
175
175
|
<td align="center" style="text-align: center">✅</td> <!-- Transaction -->
|
|
176
176
|
</tr>
|
|
177
|
+
<tr>
|
|
178
|
+
<td><tt>Pool</tt></td>
|
|
179
|
+
<td align="center" style="text-align: center">✅</td> <!-- Farce -->
|
|
180
|
+
<td align="center" style="text-align: center">➖</td> <!-- Strict -->
|
|
181
|
+
<td align="center" style="text-align: center">✅</td> <!-- Local -->
|
|
182
|
+
<td align="center" style="text-align: center">✅</td> <!-- Unshared -->
|
|
183
|
+
<td align="center" style="text-align: center">🔗</td> <!-- Unsafe -->
|
|
184
|
+
<td align="center" style="text-align: center">➖</td> <!-- Transaction -->
|
|
185
|
+
</tr>
|
|
177
186
|
<tr>
|
|
178
187
|
<td><tt>Port</tt></td>
|
|
179
188
|
<td align="center" style="text-align: center">✅</td> <!-- Farce -->
|
|
@@ -201,6 +210,15 @@ end
|
|
|
201
210
|
<td align="center" style="text-align: center">🔗</td> <!-- Unsafe -->
|
|
202
211
|
<td align="center" style="text-align: center">➖</td> <!-- Transaction -->
|
|
203
212
|
</tr>
|
|
213
|
+
<tr>
|
|
214
|
+
<td><tt>Scheduler</tt></td>
|
|
215
|
+
<td align="center" style="text-align: center">✅</td> <!-- Farce -->
|
|
216
|
+
<td align="center" style="text-align: center">➖</td> <!-- Strict -->
|
|
217
|
+
<td align="center" style="text-align: center">✅</td> <!-- Local -->
|
|
218
|
+
<td align="center" style="text-align: center">✅</td> <!-- Unshared -->
|
|
219
|
+
<td align="center" style="text-align: center">🔗</td> <!-- Unsafe -->
|
|
220
|
+
<td align="center" style="text-align: center">➖</td> <!-- Transaction -->
|
|
221
|
+
</tr>
|
|
204
222
|
<tr>
|
|
205
223
|
<td><tt>Set</tt></td>
|
|
206
224
|
<td align="center" style="text-align: center">✅</td> <!-- Farce -->
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# shareable_constant_value: literal
|
|
3
|
+
# warn_indent: true
|
|
4
|
+
|
|
5
|
+
module Farce
|
|
6
|
+
module Abstract
|
|
7
|
+
# A task executor that can submit work or wait for it to finish.
|
|
8
|
+
#
|
|
9
|
+
# {#schedule} submits a task. {#execute} waits for the task to finish and may
|
|
10
|
+
# run it directly when execution is local. Concrete executors decide where
|
|
11
|
+
# tasks run and how their arguments are transferred.
|
|
12
|
+
#
|
|
13
|
+
# @abstract Use a scheduler or pool class.
|
|
14
|
+
#
|
|
15
|
+
# @!method schedule(*args, mode: :smart, auto_local: true)
|
|
16
|
+
# @abstract This method must be implemented by concrete executor subclasses.
|
|
17
|
+
#
|
|
18
|
+
# Schedules a block for execution.
|
|
19
|
+
# What actually happens depends on the concrete executor implementation.
|
|
20
|
+
# Some executors may ignore the `auto_local` option. Some might treat every mode as `:local`.
|
|
21
|
+
#
|
|
22
|
+
# The executor may choose to block until the task has been executed, but most will add it to a queue of pending
|
|
23
|
+
# tasks, only blocking when that queue's capacity has been reached.
|
|
24
|
+
#
|
|
25
|
+
# @!macro modes
|
|
26
|
+
#
|
|
27
|
+
# @param args [Array]
|
|
28
|
+
# Arguments to pass to the scheduled block.
|
|
29
|
+
# May be handled according to `mode`.
|
|
30
|
+
#
|
|
31
|
+
# @param mode [Symbol]
|
|
32
|
+
# The mode in which to schedule the task. Defaults to `:smart`.
|
|
33
|
+
# May be ignored by some executors.
|
|
34
|
+
#
|
|
35
|
+
# @param auto_local [Boolean]
|
|
36
|
+
# Whether to automatically use a local mode when possible. Defaults to `true`.
|
|
37
|
+
# May be ignored by some executors.
|
|
38
|
+
#
|
|
39
|
+
# @yield [*args] The block to be executed by the scheduler.
|
|
40
|
+
# @yieldparam args [Array] The arguments passed to the block.
|
|
41
|
+
# @yieldreceiver [BasicObject, nil]
|
|
42
|
+
# Local mode will not touch the block's binding (self stays the same), but other modes may change it to `nil`.
|
|
43
|
+
#
|
|
44
|
+
# @return [self]
|
|
45
|
+
#
|
|
46
|
+
# @!method close
|
|
47
|
+
# @abstract This method must be implemented by concrete executor subclasses.
|
|
48
|
+
# Closes the executor, releasing any resources it holds.
|
|
49
|
+
# Once closed, the executor cannot be used to schedule new tasks.
|
|
50
|
+
# Will drain any pending tasks before fully closing. If tasks are running indefinitely, it may block.
|
|
51
|
+
# @return [self]
|
|
52
|
+
#
|
|
53
|
+
# @!method closed?
|
|
54
|
+
# @abstract This method must be implemented by concrete executor subclasses.
|
|
55
|
+
# @return [Boolean] Whether the executor is closed.
|
|
56
|
+
#
|
|
57
|
+
# @!method state
|
|
58
|
+
# @abstract This method must be implemented by concrete executor subclasses.
|
|
59
|
+
# @return [Symbol]
|
|
60
|
+
# The current state of the executor.
|
|
61
|
+
#
|
|
62
|
+
# Common states include:
|
|
63
|
+
# * `:initialized`, `:launching` and `:setup` for the setup phase.
|
|
64
|
+
# * `:running` for the execution phase.
|
|
65
|
+
# * `:closing`, `:closed`, and `:error` for the termination phase.
|
|
66
|
+
#
|
|
67
|
+
# Not all executors will implement every state.
|
|
68
|
+
#
|
|
69
|
+
# @!method ractor_shareable?
|
|
70
|
+
# @abstract Executors should include {Shareable} or {Unshareable}, which implement this method.
|
|
71
|
+
# @return [Boolean] Whether the executor is safe to use across Ractors.
|
|
72
|
+
class Executor
|
|
73
|
+
include Internal::Noncopyable
|
|
74
|
+
|
|
75
|
+
# An error that occurred during the executor's operation, if any.
|
|
76
|
+
# This is primarily for scheduling errors, not execution errors.
|
|
77
|
+
# You can check this if the {#state} is `:error`.
|
|
78
|
+
def error = nil
|
|
79
|
+
|
|
80
|
+
# @param wait [Boolean] Whether to wait for the owner to be non-nil before checking if the executor is local.
|
|
81
|
+
# @return [Boolean] Whether the executor is local for the current Ractor.
|
|
82
|
+
def local?(wait: true) = false # rubocop:disable Lint/UnusedMethodArgument
|
|
83
|
+
|
|
84
|
+
# Attempts to submit a task. Queue-based executors return `nil` when full
|
|
85
|
+
# instead of waiting for space. Other executors behave like {#schedule}.
|
|
86
|
+
#
|
|
87
|
+
# @param (see #schedule)
|
|
88
|
+
# @return [self, nil] self when submitted, or nil when the queue is full.
|
|
89
|
+
def try_schedule(...) = schedule(...)
|
|
90
|
+
|
|
91
|
+
# Closes the executor in every scope.
|
|
92
|
+
# For executors without scopes, this behaves like {#close}.
|
|
93
|
+
#
|
|
94
|
+
# @return [self]
|
|
95
|
+
def close_all = close
|
|
96
|
+
|
|
97
|
+
# Like {#schedule}, with two differences:
|
|
98
|
+
# 1. If {#local?} returns true, and it would run in local mode (either due to `auto_local` or explicitly set
|
|
99
|
+
# `mode: :local`), it executes the block directly without enqueuing it.
|
|
100
|
+
# 2. It blocks until the block has been executed, either immediately in local mode or after being scheduled.
|
|
101
|
+
#
|
|
102
|
+
# @param (see #schedule)
|
|
103
|
+
# @return [self]
|
|
104
|
+
def execute(*, mode: :smart, auto_local: true, &callback)
|
|
105
|
+
raise LocalJumpError, "Cannot yield without a block" unless block_given?
|
|
106
|
+
raise SchedulerClosedError, "cannot execute task on a closed scheduler" if closed?
|
|
107
|
+
|
|
108
|
+
if (auto_local || mode == :local) && local?
|
|
109
|
+
raise SchedulerClosedError, "cannot execute task on a closed scheduler" if closed?
|
|
110
|
+
yield(*)
|
|
111
|
+
return self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
ran = Farce::Flag.new(false)
|
|
115
|
+
signal = Signal.new
|
|
116
|
+
callback = Ractor.shareable_proc(&callback) unless Ractor.shareable?(callback)
|
|
117
|
+
|
|
118
|
+
schedule(callback, ran, signal, *, mode:, auto_local:) do |callback, ran, signal, *args|
|
|
119
|
+
callback.call(*args)
|
|
120
|
+
ensure
|
|
121
|
+
ran.set
|
|
122
|
+
signal.broadcast
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
signal.wait_until { ran.value }
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# shareable_constant_value: literal
|
|
3
|
+
# warn_indent: true
|
|
4
|
+
|
|
5
|
+
module Farce
|
|
6
|
+
module Abstract
|
|
7
|
+
# A task scheduler with a dynamically sized pool of workers.
|
|
8
|
+
#
|
|
9
|
+
# Pools accept work through {#schedule}. {#close} rejects new tasks and drains
|
|
10
|
+
# queued work. Concrete pools use either Ractor or Thread workers.
|
|
11
|
+
#
|
|
12
|
+
# @abstract Use {Farce::Pool}, {Unshared::Pool}, or {Local::Pool}.
|
|
13
|
+
class Pool < Executor
|
|
14
|
+
include Internal::Inspect
|
|
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
|
+
# Create and start an elastic pool using the configured fiber scheduler.
|
|
32
|
+
# An explicit constructor block overrides the configured default.
|
|
33
|
+
#
|
|
34
|
+
# `max_inflight` is a soft limit. A worker may exceed it when its scheduler
|
|
35
|
+
# has no runnable fibers or when the pool has reached `max_size`.
|
|
36
|
+
#
|
|
37
|
+
# @param min_size [Integer] Workers started immediately and retained while open.
|
|
38
|
+
# @param max_size [Integer] Maximum workers allowed.
|
|
39
|
+
# @param max_inflight [Integer, nil] Soft task limit for each worker.
|
|
40
|
+
# @param grow_after [Numeric] Queue wait before adding another worker.
|
|
41
|
+
# @param shrink_after [Numeric, nil] Idle time before an extra worker retires.
|
|
42
|
+
# @param capacity [Integer, nil] Pending-task capacity.
|
|
43
|
+
# @param backend [Symbol] IO backend for the built-in scheduler.
|
|
44
|
+
# @yieldreturn [Object] Fiber scheduler constructed in each worker.
|
|
45
|
+
def initialize(min_size: 0, max_size: 4, max_inflight: 64,
|
|
46
|
+
grow_after: 0.005, shrink_after: 30, capacity: 1024,
|
|
47
|
+
backend: Internal::FROZEN_CONFIG.io_backend, &constructor)
|
|
48
|
+
constructor ||= Internal::FROZEN_CONFIG.fiber_scheduler_constructor
|
|
49
|
+
initialize_configuration(min_size:, max_size:, max_inflight:, grow_after:, shrink_after:, backend:)
|
|
50
|
+
@constructor = prepare_constructor(constructor)
|
|
51
|
+
@queue = new_queue(capacity: capacity && Integer(capacity))
|
|
52
|
+
@state = Internal::Atom.new(:running)
|
|
53
|
+
@error = Internal::Atom.new
|
|
54
|
+
@worker_count = Internal::Atom.new(0)
|
|
55
|
+
@pressure = Internal::Atom.new
|
|
56
|
+
@admission = Internal::Signal.new
|
|
57
|
+
super()
|
|
58
|
+
publish_pool
|
|
59
|
+
begin
|
|
60
|
+
@min_size.times { start_worker }
|
|
61
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
62
|
+
close
|
|
63
|
+
raise
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Enqueue a task for any worker in the pool.
|
|
68
|
+
#
|
|
69
|
+
# Ractor pools reject local transfer. Thread pools preserve the block and
|
|
70
|
+
# arguments. With `auto_local: false`, they accept only local or smart modes.
|
|
71
|
+
#
|
|
72
|
+
# @overload schedule(*args, mode: :smart, auto_local: true)
|
|
73
|
+
# @!macro modes
|
|
74
|
+
# @param args [Array<Object>] Positional arguments passed to the task block.
|
|
75
|
+
# @param mode [Symbol] Argument transfer mode.
|
|
76
|
+
# @param auto_local [Boolean] Whether to use local transfer in a thread pool.
|
|
77
|
+
# @yield [*args] Task to execute.
|
|
78
|
+
# @return [Pool] self.
|
|
79
|
+
# @raise [PoolClosedError] If the pool is closing or closed.
|
|
80
|
+
def schedule(...) = enqueue_task(false, ...)
|
|
81
|
+
|
|
82
|
+
# Enqueues a task without waiting for queue space.
|
|
83
|
+
# Arguments and transfer modes follow {#schedule}.
|
|
84
|
+
#
|
|
85
|
+
# @overload try_schedule(*args, mode: :smart, auto_local: true)
|
|
86
|
+
# @param (see Farce::Abstract::Executor#schedule)
|
|
87
|
+
# @return [self, nil] self when submitted, or nil when the queue is full.
|
|
88
|
+
def try_schedule(...) = enqueue_task(true, ...)
|
|
89
|
+
|
|
90
|
+
# Stop accepting tasks and drain queued work.
|
|
91
|
+
#
|
|
92
|
+
# @return [Pool] self.
|
|
93
|
+
def close
|
|
94
|
+
return self if closed?
|
|
95
|
+
@state.compare_and_set(:running, :closing)
|
|
96
|
+
@queue.seal
|
|
97
|
+
admission_changed
|
|
98
|
+
if size.zero?
|
|
99
|
+
@queue.closed? ? finish_close : start_worker(drain: true)
|
|
100
|
+
end
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Return the first worker error, if any.
|
|
105
|
+
def error
|
|
106
|
+
value = @error.value
|
|
107
|
+
value.is_a?(Envelope) ? value.value : value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Return the current number of running and starting workers.
|
|
111
|
+
def size = @worker_count.value
|
|
112
|
+
|
|
113
|
+
# Return the current pool state.
|
|
114
|
+
def state = @state.value
|
|
115
|
+
|
|
116
|
+
# Return whether the pool rejects new tasks.
|
|
117
|
+
def closed? = state != :running
|
|
118
|
+
|
|
119
|
+
# Return whether the pool is draining queued work.
|
|
120
|
+
def closing? = state == :closing
|
|
121
|
+
|
|
122
|
+
# Return whether closing workers have drained all admitted submissions.
|
|
123
|
+
# @api private
|
|
124
|
+
def drained? = closing? && @queue.closed?
|
|
125
|
+
|
|
126
|
+
# Return whether all configured workers have been started.
|
|
127
|
+
def at_max_size? = size >= @max_size
|
|
128
|
+
|
|
129
|
+
# Return the generation used by workers waiting for admission.
|
|
130
|
+
# @api private
|
|
131
|
+
def admission_generation = @admission.generation
|
|
132
|
+
|
|
133
|
+
# Wait for worker capacity or a pool state change.
|
|
134
|
+
# @api private
|
|
135
|
+
def wait_for_admission(generation) = @admission.wait(generation)
|
|
136
|
+
|
|
137
|
+
# Wake workers waiting at their soft admission limit.
|
|
138
|
+
# @api private
|
|
139
|
+
def admission_changed = @admission.broadcast
|
|
140
|
+
|
|
141
|
+
# Record that a task left the shared queue.
|
|
142
|
+
# @api private
|
|
143
|
+
def task_taken
|
|
144
|
+
return if @pressure.value.nil? || at_max_size?
|
|
145
|
+
return unless queue_empty?
|
|
146
|
+
@pressure.value = nil
|
|
147
|
+
arm_scaler if @queue.size.positive?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Reserve a worker retirement above the minimum size.
|
|
151
|
+
# @api private
|
|
152
|
+
def retire_worker
|
|
153
|
+
retired = false
|
|
154
|
+
@worker_count.update do |count|
|
|
155
|
+
if state == :running && count > @min_size && queue_empty?
|
|
156
|
+
retired = true
|
|
157
|
+
count - 1
|
|
158
|
+
else
|
|
159
|
+
count
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
retired
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Remove a stopped worker and preserve its failure.
|
|
166
|
+
# @api private
|
|
167
|
+
def worker_stopped(error, replace: true)
|
|
168
|
+
@worker_count.update { |count| count - 1 }
|
|
169
|
+
@error.compare_and_set(nil, Envelope.new(error)) if error
|
|
170
|
+
if closing?
|
|
171
|
+
if size.zero?
|
|
172
|
+
error && !drained? ? start_worker(drain: true) : finish_close
|
|
173
|
+
end
|
|
174
|
+
elsif state == :running && replace
|
|
175
|
+
begin
|
|
176
|
+
start_worker while size < @min_size
|
|
177
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
arm_scaler if @queue.size.positive?
|
|
181
|
+
end
|
|
182
|
+
admission_changed
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Recheck delayed queue pressure and grow by one worker.
|
|
186
|
+
# @api private
|
|
187
|
+
def scale_if_needed(started)
|
|
188
|
+
return unless state == :running && @pressure.value == started
|
|
189
|
+
age = @queue.oldest_age
|
|
190
|
+
return clear_pressure(started) unless age
|
|
191
|
+
return reschedule_scaler(started, @grow_after - age) if age < @grow_after
|
|
192
|
+
|
|
193
|
+
start_worker unless at_max_size?
|
|
194
|
+
return clear_pressure(started) if queue_empty? || at_max_size?
|
|
195
|
+
|
|
196
|
+
next_started = @queue.generation
|
|
197
|
+
schedule_scaler(next_started) if @pressure.compare_and_set(started, next_started)
|
|
198
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
199
|
+
@error.compare_and_set(nil, Envelope.new(e))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @api private
|
|
203
|
+
def inspect_with(inspector)
|
|
204
|
+
super do
|
|
205
|
+
inspector.attribute(:state, state)
|
|
206
|
+
inspector.attribute(:backend, @backend)
|
|
207
|
+
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
|
|
211
|
+
yield if block_given?
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def enqueue_task(non_block, *args, mode: :smart, auto_local: true, &block)
|
|
218
|
+
raise PoolClosedError, "cannot schedule task on a closed pool" if closed?
|
|
219
|
+
raise LocalJumpError, "Cannot schedule without a block" unless block
|
|
220
|
+
|
|
221
|
+
task = prepare_task(args, block, mode, auto_local)
|
|
222
|
+
return unless non_block ? @queue.try_push(task) : @queue.push(task)
|
|
223
|
+
arm_scaler
|
|
224
|
+
self
|
|
225
|
+
rescue ClosedQueueError
|
|
226
|
+
raise unless closed?
|
|
227
|
+
raise PoolClosedError, "cannot schedule task on a closed pool"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def initialize_configuration(min_size:, max_size:, max_inflight:, grow_after:, shrink_after:, backend:)
|
|
231
|
+
@min_size = Integer(min_size)
|
|
232
|
+
@max_size = Integer(max_size)
|
|
233
|
+
@max_inflight = max_inflight && Integer(max_inflight)
|
|
234
|
+
@grow_after = duration(grow_after, :grow_after)
|
|
235
|
+
@shrink_after = duration(shrink_after, :shrink_after, nil_allowed: true)
|
|
236
|
+
@backend = backend
|
|
237
|
+
validate_sizes
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def prepare_constructor(constructor)
|
|
241
|
+
return constructor if !constructor || Ractor.shareable?(constructor)
|
|
242
|
+
Ractor.shareable_proc(&constructor)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def prepare_task(args, block, mode, _auto_local)
|
|
246
|
+
raise ArgumentError, "local transfer is not supported by a pool" if mode == :local
|
|
247
|
+
Internal::ScheduledTask.new(args, block, mode)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def new_queue(capacity:) = Internal::Queue.new(capacity:, track_age: true)
|
|
251
|
+
def new_worker = Internal::PoolWorker.new(self)
|
|
252
|
+
def new_scheduler(**, &) = Farce::Scheduler.new(**, &)
|
|
253
|
+
def launch_scheduler(scheduler) = scheduler.launch_ractor
|
|
254
|
+
|
|
255
|
+
def validate_sizes
|
|
256
|
+
raise ArgumentError, "min_size must be non-negative" if @min_size.negative?
|
|
257
|
+
raise ArgumentError, "max_size must be positive" unless @max_size.positive?
|
|
258
|
+
raise ArgumentError, "min_size must not exceed max_size" if @min_size > @max_size
|
|
259
|
+
raise ArgumentError, "max_inflight must be positive" if @max_inflight && !@max_inflight.positive?
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def duration(value, name, nil_allowed: false)
|
|
263
|
+
return if value.nil? && nil_allowed
|
|
264
|
+
value = Float(value)
|
|
265
|
+
raise ArgumentError, "#{name} must be finite and non-negative" if !value.finite? || value.negative?
|
|
266
|
+
value
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def start_worker(drain: false, only_if_empty: false)
|
|
270
|
+
reserved = false
|
|
271
|
+
@worker_count.update do |count|
|
|
272
|
+
can_start = state == :running || (drain && closing? && !queue_empty?)
|
|
273
|
+
if can_start && count < @max_size && (!only_if_empty || count.zero?)
|
|
274
|
+
reserved = true
|
|
275
|
+
count + 1
|
|
276
|
+
else
|
|
277
|
+
count
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
return false unless reserved
|
|
281
|
+
|
|
282
|
+
worker = new_worker
|
|
283
|
+
scheduler = new_scheduler(
|
|
284
|
+
backend: @backend,
|
|
285
|
+
queue: @queue,
|
|
286
|
+
pool_worker: worker,
|
|
287
|
+
queue_owner: false,
|
|
288
|
+
&@constructor
|
|
289
|
+
)
|
|
290
|
+
launch_scheduler(scheduler)
|
|
291
|
+
admission_changed if at_max_size?
|
|
292
|
+
true
|
|
293
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
294
|
+
worker ? worker.stopped(e, replace: false) : @worker_count.update { |count| count - 1 }
|
|
295
|
+
raise
|
|
296
|
+
end
|
|
297
|
+
|
|
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?
|
|
302
|
+
|
|
303
|
+
started = @queue.generation
|
|
304
|
+
age = @queue.oldest_age
|
|
305
|
+
|
|
306
|
+
return unless age
|
|
307
|
+
return unless @pressure.compare_and_set(nil, started)
|
|
308
|
+
|
|
309
|
+
remaining = @grow_after - age
|
|
310
|
+
remaining = 0 if remaining.negative?
|
|
311
|
+
|
|
312
|
+
schedule_scaler(started, remaining)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def schedule_scaler(started, delay = @grow_after)
|
|
316
|
+
Internal::PoolSupervisor.schedule(self, started, delay)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def reschedule_scaler(started, delay)
|
|
320
|
+
next_started = @queue.generation
|
|
321
|
+
schedule_scaler(next_started, delay) if @pressure.compare_and_set(started, next_started)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def clear_pressure(started)
|
|
325
|
+
return unless @pressure.compare_and_set(started, nil)
|
|
326
|
+
arm_scaler if @queue.size.positive?
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def queue_empty? = @queue.size.zero? # rubocop:disable Style/ZeroLengthPredicate
|
|
330
|
+
|
|
331
|
+
def finish_close = @state.compare_and_set(:closing, :closed)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|