pipeline_machines 0.1.4
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 +7 -0
- data/CHANGELOG.md +34 -0
- data/Cargo.toml +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +250 -0
- data/ext/pipeline_machines_native/core/CHANGELOG.md +29 -0
- data/ext/pipeline_machines_native/core/Cargo.toml +16 -0
- data/ext/pipeline_machines_native/core/README.md +38 -0
- data/ext/pipeline_machines_native/core/src/lib.rs +261 -0
- data/ext/pipeline_machines_native/extconf.rb +2 -0
- data/ext/pipeline_machines_native/ffi/Cargo.toml +20 -0
- data/ext/pipeline_machines_native/ffi/extconf.rb +14 -0
- data/ext/pipeline_machines_native/ffi/src/init.rs +27 -0
- data/ext/pipeline_machines_native/ffi/src/lib.rs +203 -0
- data/lib/pipeline_machines/version.rb +4 -0
- data/lib/pipeline_machines.rb +3 -0
- data/sig/pipeline_machines.rbs +20 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a949ac7222dd3fda0495facd3720e00cb13f4b78870048127310c844f1ebc18d
|
|
4
|
+
data.tar.gz: 4c8988104a0977410f93fe8396246eb0401a820d289a88cc7bda0a3a6a3ad76c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a59cda3f44680fbc3e915cc41d73f52d27b335722ce3697e91983e6d2ce75473bfc5a7bd5419fe80bb8a430738d0b0563a0c820fd71aafd4e074b6d468952ea1
|
|
7
|
+
data.tar.gz: 8ce58b5a751886b228fde6d99e5305e1b2ca830ce49ff534db56fde249f451ddd8607cd5944b22b08218c647d694245662cf6ace7637a5958a98871229d7af5b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.4](https://github.com/seuros/pipeline_machines/compare/pipeline_machines/v0.1.3...pipeline_machines/v0.1.4) (2026-11-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* extract has_queued lane check ([8c9510f](https://github.com/seuros/pipeline_machines/commit/8c9510fbd5987c2d2f9d4c653ad7aff983e30955))
|
|
9
|
+
|
|
10
|
+
## [0.1.3](https://github.com/seuros/pipeline_machines/compare/pipeline_machines/v0.1.2...pipeline_machines/v0.1.3) (2026-11-06)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* crates.io keywords and categories for the core ([f308575](https://github.com/seuros/pipeline_machines/commit/f30857529c4ac61c145128710bb916770699f760))
|
|
16
|
+
|
|
17
|
+
## [0.1.2](https://github.com/seuros/pipeline_machines/compare/pipeline_machines/v0.1.1...pipeline_machines/v0.1.2) (2026-11-06)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* keep test sources out of the packaged gem and crate ([cd46e0d](https://github.com/seuros/pipeline_machines/commit/cd46e0d8adb4868270b1278fb7cd24c001c89c72))
|
|
23
|
+
|
|
24
|
+
## [0.1.1](https://github.com/seuros/pipeline_machines/compare/pipeline_machines-v0.1.0...pipeline_machines/v0.1.1) (2026-11-06)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* initial extraction ([5e3f8da](https://github.com/seuros/pipeline_machines/commit/5e3f8dadd4cb7159e5fcbdf64a0c8d2de110c680))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Bug Fixes
|
|
33
|
+
|
|
34
|
+
* name the gemspec block spec so the release workflow detects the extension ([4a86b80](https://github.com/seuros/pipeline_machines/commit/4a86b80992c296d0157d5488daea23c1295665c4))
|
data/Cargo.toml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[workspace]
|
|
2
|
+
members = ["ext/pipeline_machines_native/core", "ext/pipeline_machines_native/ffi"]
|
|
3
|
+
default-members = ["ext/pipeline_machines_native/core"]
|
|
4
|
+
resolver = "3"
|
|
5
|
+
[workspace.package]
|
|
6
|
+
edition = "2024"
|
|
7
|
+
rust-version = "1.99"
|
|
8
|
+
license = "MIT"
|
|
9
|
+
[workspace.dependencies]
|
|
10
|
+
pipeline-machines = { path = "ext/pipeline_machines_native/core" }
|
|
11
|
+
magnus = { version = "0.9.3", default-features = false }
|
|
12
|
+
rb-sys = { version = "0.9.130", default-features = false, features = ["stable-api-compiled-fallback"] }
|
|
13
|
+
priority-machines = ">=0.2"
|
|
14
|
+
flow-machines = ">=0.2"
|
|
15
|
+
[workspace.lints.rust]
|
|
16
|
+
unsafe_code = "deny"
|
|
17
|
+
unused_must_use = "warn"
|
|
18
|
+
missing_docs = "warn"
|
|
19
|
+
[workspace.lints.clippy]
|
|
20
|
+
all = { level = "warn", priority = -1 }
|
|
21
|
+
pedantic = { level = "warn", priority = -1 }
|
|
22
|
+
nursery = { level = "warn", priority = -1 }
|
|
23
|
+
unwrap_used = "deny"
|
|
24
|
+
expect_used = "deny"
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abdelkader Boudih
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# PipelineMachines
|
|
2
|
+
|
|
3
|
+
> A job you cancelled is still on the ship until someone signs for it. 🛤️
|
|
4
|
+
|
|
5
|
+
A stage coordinator for Ruby. You submit work into one of four weighted lanes, pull it out in fair order, report what happened, and collect the receipt. Fan a job out across several lanes and join on all of them. The stage keeps count of every slot, including the ones holding work you told to stop but that hasn't stopped yet.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem 'pipeline_machines'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Four lanes: transcode, thumbnail, captions, notify. Thumbnails get double share.
|
|
15
|
+
stage = PipelineMachines::Stage.new([1, 2, 1, 1])
|
|
16
|
+
|
|
17
|
+
# One upload, three lanes at once.
|
|
18
|
+
jobs = stage.fan_out([0, 1, 2], upload.id)
|
|
19
|
+
|
|
20
|
+
# The worker loop. dispatch hands back [job, payload, lane].
|
|
21
|
+
while (job, payload, lane = stage.dispatch)
|
|
22
|
+
run_lane(lane, payload)
|
|
23
|
+
stage.complete(job)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
stage.join(jobs) # => :ready
|
|
27
|
+
jobs.each { |job| stage.reap(job) } # collect receipts, free the slots
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## A Message to the Resistance
|
|
31
|
+
|
|
32
|
+
So you built the upload pipeline. A job comes in, you push three tasks onto a queue, three workers pick them up, the video gets transcoded, thumbnailed, and captioned. Then product asked for cancel.
|
|
33
|
+
|
|
34
|
+
You added a `cancelled` flag. Workers check it before starting. Great. Except the transcode worker checked it four seconds ago and is now thirty seconds into an ffmpeg call that doesn't care about your flag. The user sees "cancelled", uploads a replacement, and now two transcodes of the same video are fighting over one CPU. Meanwhile your concurrency limit says there's a free slot, because the cancelled job was removed from the queue the moment the flag flipped.
|
|
35
|
+
|
|
36
|
+
The queue was honest about what was *waiting*. It lied about what was *running*. A slot isn't free when you decide a job should stop. It's free when the job has actually stopped and you've collected the outcome.
|
|
37
|
+
|
|
38
|
+
This gem keeps that count. Queued work can be cancelled outright. Running work stays on the books until the worker confirms it quit, or finishes anyway. Nothing frees a slot except reaping the receipt.
|
|
39
|
+
|
|
40
|
+
## Chapter 1: Stardate 2025.186, the AMS Welsh
|
|
41
|
+
|
|
42
|
+
> **AMS Welsh**, NCC-4212, *Welsh*-class stage carrier.
|
|
43
|
+
> Freight is not gone merely because it left the waiting deck.
|
|
44
|
+
|
|
45
|
+
The carrier doesn't run any of the work. It holds the manifest: which berths are charged, which descriptors are on the waiting deck, which are on the work deck, and which outcomes are still unsigned. A berth stays charged until the receipt is collected. Not when the job is dispatched, not when it's cancelled. Collected.
|
|
46
|
+
|
|
47
|
+
Somewhere on the bridge, a junior officer proposes:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
def cancel(job)
|
|
51
|
+
queue.delete(job)
|
|
52
|
+
job.update(status: :cancelled) # the worker will notice. probably.
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
"That," says the deck officer, "is how a berth gets charged twice."
|
|
57
|
+
|
|
58
|
+
## The Flight Plan
|
|
59
|
+
|
|
60
|
+
Every job is in exactly one of these states. Only reaping frees its slot.
|
|
61
|
+
|
|
62
|
+
```mermaid
|
|
63
|
+
stateDiagram-v2
|
|
64
|
+
[*] --> queued: submit / fan_out
|
|
65
|
+
queued --> running: dispatch
|
|
66
|
+
queued --> cancelled: cancel
|
|
67
|
+
running --> complete: complete
|
|
68
|
+
running --> cancelling: cancel (worker not yet stopped)
|
|
69
|
+
cancelling --> cancelled: confirm_cancel
|
|
70
|
+
cancelling --> complete: complete (finished anyway)
|
|
71
|
+
complete --> [*]: reap
|
|
72
|
+
cancelled --> [*]: reap
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
And the upload that got cancelled mid-transcode:
|
|
76
|
+
|
|
77
|
+
```mermaid
|
|
78
|
+
sequenceDiagram
|
|
79
|
+
participant You
|
|
80
|
+
participant Stage
|
|
81
|
+
participant W as Worker
|
|
82
|
+
|
|
83
|
+
You->>Stage: submit(lane 0, upload 42)
|
|
84
|
+
W->>Stage: dispatch
|
|
85
|
+
Stage-->>W: job, 42, lane 0
|
|
86
|
+
Note over W: ffmpeg starts, 30 seconds to go
|
|
87
|
+
|
|
88
|
+
You->>Stage: cancel(job)
|
|
89
|
+
Stage-->>You: true
|
|
90
|
+
You->>Stage: join([job])
|
|
91
|
+
Stage-->>You: pending
|
|
92
|
+
Note over Stage: slot still charged
|
|
93
|
+
|
|
94
|
+
Note over W: ffmpeg killed
|
|
95
|
+
W->>Stage: confirm_cancel(job)
|
|
96
|
+
Stage-->>W: true
|
|
97
|
+
You->>Stage: reap(job)
|
|
98
|
+
Stage-->>You: [42, cancelled]
|
|
99
|
+
Note over Stage: slot free
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## How the Deck Works
|
|
103
|
+
|
|
104
|
+
### Submit and dispatch
|
|
105
|
+
|
|
106
|
+
Lanes are numbered 0 to 3. Each lane has a weight you choose at construction. Dispatch walks the lanes in weighted round robin, first in first out within a lane.
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
stage = PipelineMachines::Stage.new([1, 2, 1, 1])
|
|
110
|
+
stage.lanes # => 4
|
|
111
|
+
stage.capacity # => 64
|
|
112
|
+
|
|
113
|
+
stage.submit(0, 100)
|
|
114
|
+
stage.submit(0, 101)
|
|
115
|
+
stage.submit(1, 200)
|
|
116
|
+
stage.submit(1, 201)
|
|
117
|
+
stage.submit(2, 300)
|
|
118
|
+
|
|
119
|
+
job, payload, lane = stage.dispatch
|
|
120
|
+
[payload, lane] # => [100, 0]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Lane 1 has weight 2, so it gets two turns per round. With the queue above, dispatch order is lane 0, lane 1, lane 1, lane 2, then lane 0 again. Payloads are Integers: ids, pointers into your own tables, whatever names the work. The stage never sees the work itself.
|
|
124
|
+
|
|
125
|
+
A lane that doesn't exist, or a full stage, returns `nil` from `submit`. It never raises for that.
|
|
126
|
+
|
|
127
|
+
### Fan out and join
|
|
128
|
+
|
|
129
|
+
One payload, several lanes, admitted atomically. Either every lane gets a job or none does.
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
stage = PipelineMachines::Stage.new([1, 1, 1, 1])
|
|
133
|
+
jobs = stage.fan_out([3, 1], 9)
|
|
134
|
+
jobs.size # => 2
|
|
135
|
+
stage.join(jobs) # => :pending
|
|
136
|
+
|
|
137
|
+
2.times do
|
|
138
|
+
job, = stage.dispatch
|
|
139
|
+
stage.complete(job)
|
|
140
|
+
end
|
|
141
|
+
stage.join(jobs) # => :ready
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`join` answers for the whole group. `:ready` when every job completed, `:cancelled` when any was cancelled, `:pending` while any is still queued, running, or waiting on a cancel confirmation, and `:unknown` if any handle has been reaped or belongs to another stage. Lanes in a fan-out must be distinct, and asking for a lane that doesn't exist returns `nil` without admitting anything.
|
|
145
|
+
|
|
146
|
+
### Cancel, and what cancel actually means
|
|
147
|
+
|
|
148
|
+
Cancel a queued job and it's done. Terminal. Reap it whenever you like.
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
stage = PipelineMachines::Stage.new([1, 1, 1, 1])
|
|
152
|
+
job = stage.submit(2, 77)
|
|
153
|
+
|
|
154
|
+
stage.cancel(job) # => true
|
|
155
|
+
stage.join([job]) # => :cancelled
|
|
156
|
+
stage.reap(job) # => [77, :cancelled]
|
|
157
|
+
stage.dispatch # => nil, nothing left
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Cancel a *running* job and you've only asked. The slot stays charged until the worker says it stopped.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
stage = PipelineMachines::Stage.new([1, 1, 1, 1])
|
|
164
|
+
stage.submit(0, 5)
|
|
165
|
+
job, = stage.dispatch
|
|
166
|
+
|
|
167
|
+
stage.cancel(job) # => true, request noted
|
|
168
|
+
stage.reap(job) # => nil, not terminal yet
|
|
169
|
+
stage.join([job]) # => :pending
|
|
170
|
+
|
|
171
|
+
stage.confirm_cancel(job) # => true, worker has stopped
|
|
172
|
+
stage.reap(job) # => [5, :cancelled]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
If the worker finishes before it sees the request, `complete` wins and the cancel is moot:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
stage = PipelineMachines::Stage.new([1, 1, 1, 1])
|
|
179
|
+
stage.submit(0, 1)
|
|
180
|
+
job, = stage.dispatch
|
|
181
|
+
|
|
182
|
+
stage.cancel(job) # => true
|
|
183
|
+
stage.complete(job) # => true, it finished anyway
|
|
184
|
+
stage.confirm_cancel(job) # => false, nothing to confirm
|
|
185
|
+
stage.reap(job) # => [1, :complete]
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Reap, or the slot never comes back
|
|
189
|
+
|
|
190
|
+
The stage holds 64 jobs. Not 64 queued, not 64 running. 64 *unreaped*. A completed job you never reap is a slot you never get back.
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
stage = PipelineMachines::Stage.new([1, 1, 1, 1])
|
|
194
|
+
jobs = 64.times.map { |i| stage.submit(0, i) }
|
|
195
|
+
stage.submit(0, 65) # => nil, full
|
|
196
|
+
|
|
197
|
+
job, = stage.dispatch
|
|
198
|
+
stage.complete(job)
|
|
199
|
+
stage.submit(0, 65) # => nil, still full. complete isn't reap.
|
|
200
|
+
|
|
201
|
+
stage.reap(job) # => [0, :complete]
|
|
202
|
+
stage.submit(0, 65) # now admitted
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`reap` only returns for terminal jobs: `[payload, :complete]` or `[payload, :cancelled]`. Anything else is `nil`. After reaping, the handle is dead. Every call with it returns `false` or `nil`, and `join` reports `:unknown`.
|
|
206
|
+
|
|
207
|
+
### Drain
|
|
208
|
+
|
|
209
|
+
Shutting down a worker pool means two things: stop taking work, and know when the last job is gone.
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
stage = PipelineMachines::Stage.new([1, 1, 1, 1])
|
|
213
|
+
job = stage.submit(0, 5)
|
|
214
|
+
|
|
215
|
+
stage.begin_drain
|
|
216
|
+
stage.submit(0, 6) # => nil, closed
|
|
217
|
+
stage.drained? # => false, one job still charged
|
|
218
|
+
|
|
219
|
+
work, = stage.dispatch # draining still dispatches what's queued
|
|
220
|
+
stage.complete(work)
|
|
221
|
+
stage.drained? # => false, completed but not reaped
|
|
222
|
+
stage.reap(job)
|
|
223
|
+
stage.drained? # => true
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## The Rules of the Carrier
|
|
227
|
+
|
|
228
|
+
- **Four lanes, sixty-four slots.** Weights are an Array of exactly four Integers in `1..2**32-1`. Anything else raises `TypeError` or `ArgumentError`.
|
|
229
|
+
- **Lanes and payloads are Integers** in `0..2**64-1`. Floats and strings raise `TypeError`. Negative or too large raises `RangeError`.
|
|
230
|
+
- **Bad lanes and full stages return `nil`.** Only malformed input raises.
|
|
231
|
+
- **Handles belong to one stage.** A job from another stage, or one already reaped, mutates nothing: `cancel`, `complete`, and `confirm_cancel` return `false`, `reap` returns `nil`, `join` says `:unknown`.
|
|
232
|
+
- **`join` takes up to 64 handles.** An empty Array is `:ready`.
|
|
233
|
+
- **Frozen means frozen.** Every mutation raises `FrozenError`. `join` and `drained?` still answer.
|
|
234
|
+
|
|
235
|
+
## What You Think You're Doing vs Reality
|
|
236
|
+
|
|
237
|
+
### You Think: "I cancelled it, so the slot is free."
|
|
238
|
+
### Reality: You cancelled it, so the slot is spoken for by a job nobody is watching.
|
|
239
|
+
|
|
240
|
+
Removing work from a queue is instant. Stopping work that has already started is a negotiation with a process that may be deep inside a syscall. Between those two moments the resources are still in use, and any capacity count that says otherwise will admit one job too many, every time, right when the system is under the most pressure.
|
|
241
|
+
|
|
242
|
+
## What's Yours
|
|
243
|
+
|
|
244
|
+
The stage counts. It doesn't run, schedule, persist, or time anything out. You run the worker loop and call `dispatch` when you have a free worker. You decide what a payload means. You tell the stage `complete` or `confirm_cancel` when the work really stopped, and you `reap` every job, every time, or the slots drain away. Across a restart, the manifest is gone, so write down what you dispatched before you dispatch it.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT License. See [LICENSE.txt](LICENSE.txt) for details.
|
|
249
|
+
|
|
250
|
+
**Remember: The transcode you cancelled at 14:02 is still burning a core at 14:03. The stage knows. Your queue didn't.**
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.3](https://github.com/seuros/pipeline_machines/compare/pipeline-machines-v0.2.2...pipeline-machines-v0.2.3) (2026-11-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* extract has_queued lane check ([8c9510f](https://github.com/seuros/pipeline_machines/commit/8c9510fbd5987c2d2f9d4c653ad7aff983e30955))
|
|
9
|
+
|
|
10
|
+
## [0.2.2](https://github.com/seuros/pipeline_machines/compare/pipeline-machines-v0.2.1...pipeline-machines-v0.2.2) (2026-11-06)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* crates.io keywords and categories for the core ([f308575](https://github.com/seuros/pipeline_machines/commit/f30857529c4ac61c145128710bb916770699f760))
|
|
16
|
+
|
|
17
|
+
## [0.2.1](https://github.com/seuros/pipeline_machines/compare/pipeline-machines-v0.2.0...pipeline-machines-v0.2.1) (2026-11-06)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* keep test sources out of the packaged gem and crate ([cd46e0d](https://github.com/seuros/pipeline_machines/commit/cd46e0d8adb4868270b1278fb7cd24c001c89c72))
|
|
23
|
+
|
|
24
|
+
## [0.2.0](https://github.com/seuros/pipeline_machines/compare/pipeline-machines-v0.1.0...pipeline-machines-v0.2.0) (2026-11-06)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* initial extraction ([5e3f8da](https://github.com/seuros/pipeline_machines/commit/5e3f8dadd4cb7159e5fcbdf64a0c8d2de110c680))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "pipeline-machines"
|
|
3
|
+
version = "0.2.3"
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
rust-version.workspace = true
|
|
6
|
+
license.workspace = true
|
|
7
|
+
description = "Bounded stage dispatch, fanout/join, cancellation, and draining decisions"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
exclude = ["src/**/tests.rs", "tests/"]
|
|
10
|
+
keywords = ["pipeline", "fanout", "join", "cancellation", "no-std"]
|
|
11
|
+
categories = ["no-std", "algorithms", "concurrency"]
|
|
12
|
+
[dependencies]
|
|
13
|
+
priority-machines.workspace = true
|
|
14
|
+
flow-machines.workspace = true
|
|
15
|
+
[lints]
|
|
16
|
+
workspace = true
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# pipeline-machines
|
|
2
|
+
|
|
3
|
+
> **AMS Welsh**, NCC-4212, *Welsh*-class stage carrier.
|
|
4
|
+
|
|
5
|
+
Engine-room flight manual: account for bounded cargo stages and unresolved flights.
|
|
6
|
+
|
|
7
|
+
Allocation-free `no_std` coordinator for fixed lanes and shared bounded job slots.
|
|
8
|
+
Copyable payloads should be command IDs or small descriptors; adapters own actual
|
|
9
|
+
resources and execute work. WRR selects ready lanes, FIFO within each lane.
|
|
10
|
+
External eligibility masks can incorporate arbitration or deadline policy.
|
|
11
|
+
|
|
12
|
+
Flow credits bound **all unreaped jobs**, including running and terminal jobs.
|
|
13
|
+
Fanout admission is atomic across distinct lanes. Fan-in reports readiness over
|
|
14
|
+
unreaped handles, not an output aggregation or distributed transaction.
|
|
15
|
+
|
|
16
|
+
Queued cancellation is immediate. Running cancellation becomes `Cancelling` and
|
|
17
|
+
keeps its capacity until the executor confirms cancellation or reports completion.
|
|
18
|
+
This never assumes an unresolved external effect has disappeared. Reap only
|
|
19
|
+
terminal jobs to return credit; generation checks reject stale handles.
|
|
20
|
+
Handle namespaces are scoped to one stage lifetime.
|
|
21
|
+
|
|
22
|
+
Draining refuses submissions while allowing dispatch and completion. No async
|
|
23
|
+
runtime, multi-stage graph orchestration, automatic propagation, result payloads,
|
|
24
|
+
durability, or Ruby binding is implemented.
|
|
25
|
+
|
|
26
|
+
```rust
|
|
27
|
+
use core::num::NonZeroU32;
|
|
28
|
+
use pipeline_machines::{Stage, State};
|
|
29
|
+
# fn example() -> Option<()> {
|
|
30
|
+
let mut stage = Stage::<u64, 1, 4>::new([NonZeroU32::MIN])?;
|
|
31
|
+
let id = stage.submit(0, 42)?;
|
|
32
|
+
assert_eq!(stage.dispatch(&[true])?.id, id);
|
|
33
|
+
assert!(stage.complete(id));
|
|
34
|
+
assert_eq!(stage.reap(id), Some((42, State::Complete)));
|
|
35
|
+
# Some(())
|
|
36
|
+
# }
|
|
37
|
+
# assert_eq!(example(), Some(()));
|
|
38
|
+
```
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#![no_std]
|
|
2
|
+
#![doc = include_str!("../README.md")]
|
|
3
|
+
use core::num::NonZeroU32;
|
|
4
|
+
use flow_machines::CreditWindow;
|
|
5
|
+
use priority_machines::WeightedRoundRobin;
|
|
6
|
+
|
|
7
|
+
/// Stage-local generation-checked job identity.
|
|
8
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
9
|
+
pub struct JobId {
|
|
10
|
+
slot: usize,
|
|
11
|
+
generation: u64,
|
|
12
|
+
}
|
|
13
|
+
/// Job lifecycle; cancellation is not inferred from a timeout.
|
|
14
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
15
|
+
#[non_exhaustive]
|
|
16
|
+
pub enum State {
|
|
17
|
+
/// Awaiting dispatch.
|
|
18
|
+
Queued,
|
|
19
|
+
/// Executor owns an unresolved action.
|
|
20
|
+
Running,
|
|
21
|
+
/// Executor was asked to cancel, but has not confirmed.
|
|
22
|
+
Cancelling,
|
|
23
|
+
/// Executor reported completion.
|
|
24
|
+
Complete,
|
|
25
|
+
/// Queued work was cancelled, or the executor confirmed cancellation.
|
|
26
|
+
Cancelled,
|
|
27
|
+
}
|
|
28
|
+
/// Fan-in readiness for handles not yet reaped.
|
|
29
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
30
|
+
#[non_exhaustive]
|
|
31
|
+
pub enum Join {
|
|
32
|
+
/// All jobs completed.
|
|
33
|
+
Ready,
|
|
34
|
+
/// Some jobs remain unresolved.
|
|
35
|
+
Pending,
|
|
36
|
+
/// At least one job was cancelled.
|
|
37
|
+
Cancelled,
|
|
38
|
+
/// A handle is stale/unknown/already reaped.
|
|
39
|
+
Unknown,
|
|
40
|
+
}
|
|
41
|
+
/// Descriptor returned for external execution.
|
|
42
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
43
|
+
pub struct Dispatch<T> {
|
|
44
|
+
/// Handle to resolve after execution.
|
|
45
|
+
pub id: JobId,
|
|
46
|
+
/// Copyable command descriptor.
|
|
47
|
+
pub payload: T,
|
|
48
|
+
/// Originating lane.
|
|
49
|
+
pub lane: usize,
|
|
50
|
+
}
|
|
51
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
52
|
+
struct Job<T> {
|
|
53
|
+
generation: u64,
|
|
54
|
+
lane: usize,
|
|
55
|
+
payload: T,
|
|
56
|
+
state: State,
|
|
57
|
+
}
|
|
58
|
+
/// Bounded stage with caller-driven execution.
|
|
59
|
+
#[derive(Debug)]
|
|
60
|
+
pub struct Stage<T, const N: usize, const C: usize> {
|
|
61
|
+
jobs: [Option<Job<T>>; C],
|
|
62
|
+
sequence: u64,
|
|
63
|
+
credits: CreditWindow,
|
|
64
|
+
scheduler: WeightedRoundRobin<N>,
|
|
65
|
+
draining: bool,
|
|
66
|
+
}
|
|
67
|
+
impl<T: Copy, const N: usize, const C: usize> Stage<T, N, C> {
|
|
68
|
+
/// Construct positive lane/capacity bounds; capacity must fit in `u32`.
|
|
69
|
+
#[must_use]
|
|
70
|
+
pub fn new(weights: [NonZeroU32; N]) -> Option<Self> {
|
|
71
|
+
if N == 0 {
|
|
72
|
+
return None;
|
|
73
|
+
}
|
|
74
|
+
let Ok(capacity) = u32::try_from(C) else {
|
|
75
|
+
return None;
|
|
76
|
+
};
|
|
77
|
+
Some(Self {
|
|
78
|
+
jobs: [None; C],
|
|
79
|
+
sequence: 0,
|
|
80
|
+
credits: CreditWindow::new(NonZeroU32::new(capacity)?),
|
|
81
|
+
scheduler: WeightedRoundRobin::new(weights),
|
|
82
|
+
draining: false,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
/// Submit one descriptor, failing without changing state if admission is impossible.
|
|
86
|
+
pub fn submit(&mut self, lane: usize, payload: T) -> Option<JobId> {
|
|
87
|
+
if self.draining || lane >= N {
|
|
88
|
+
return None;
|
|
89
|
+
}
|
|
90
|
+
let slot = self.jobs.iter().position(Option::is_none)?;
|
|
91
|
+
let generation = self.sequence.checked_add(1)?;
|
|
92
|
+
if !self.credits.try_acquire(NonZeroU32::MIN) {
|
|
93
|
+
return None;
|
|
94
|
+
}
|
|
95
|
+
self.sequence = generation;
|
|
96
|
+
self.jobs[slot] = Some(Job {
|
|
97
|
+
generation,
|
|
98
|
+
lane,
|
|
99
|
+
payload,
|
|
100
|
+
state: State::Queued,
|
|
101
|
+
});
|
|
102
|
+
Some(JobId { slot, generation })
|
|
103
|
+
}
|
|
104
|
+
/// Atomically submit the same descriptor to distinct lanes.
|
|
105
|
+
///
|
|
106
|
+
/// # Panics
|
|
107
|
+
/// Panics only if internal prevalidated admission invariants are inconsistent.
|
|
108
|
+
pub fn fan_out(&mut self, lanes: &[usize], payload: T) -> Option<[Option<JobId>; C]> {
|
|
109
|
+
if self.draining {
|
|
110
|
+
return None;
|
|
111
|
+
}
|
|
112
|
+
let Ok(count) = u32::try_from(lanes.len()) else {
|
|
113
|
+
return None;
|
|
114
|
+
};
|
|
115
|
+
if count > self.credits.available() || self.sequence.checked_add(u64::from(count)).is_none()
|
|
116
|
+
{
|
|
117
|
+
return None;
|
|
118
|
+
}
|
|
119
|
+
for (index, lane) in lanes.iter().enumerate() {
|
|
120
|
+
if *lane >= N || lanes[..index].contains(lane) {
|
|
121
|
+
return None;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let mut ids = [None; C];
|
|
125
|
+
for (slot, &lane) in lanes.iter().enumerate() {
|
|
126
|
+
ids[slot] = self.submit(lane, payload);
|
|
127
|
+
assert!(ids[slot].is_some());
|
|
128
|
+
}
|
|
129
|
+
Some(ids)
|
|
130
|
+
}
|
|
131
|
+
/// Dispatch FIFO within a WRR-selected eligible lane.
|
|
132
|
+
///
|
|
133
|
+
/// # Panics
|
|
134
|
+
/// Panics only if internal ready-lane invariants are inconsistent.
|
|
135
|
+
pub fn dispatch(&mut self, eligible: &[bool; N]) -> Option<Dispatch<T>> {
|
|
136
|
+
let ready = core::array::from_fn(|lane| eligible[lane] && self.has_queued(lane));
|
|
137
|
+
let lane = self.scheduler.select(&ready)?;
|
|
138
|
+
let slot = self
|
|
139
|
+
.jobs
|
|
140
|
+
.iter()
|
|
141
|
+
.enumerate()
|
|
142
|
+
.filter_map(|(slot, job)| {
|
|
143
|
+
job.filter(|job| job.lane == lane && job.state == State::Queued)
|
|
144
|
+
.map(|job| (slot, job.generation))
|
|
145
|
+
})
|
|
146
|
+
.min_by_key(|&(_, generation)| generation)
|
|
147
|
+
.map(|(slot, _)| slot)?;
|
|
148
|
+
let job = self.jobs[slot].as_mut()?;
|
|
149
|
+
job.state = State::Running;
|
|
150
|
+
let dispatch = Dispatch {
|
|
151
|
+
id: JobId {
|
|
152
|
+
slot,
|
|
153
|
+
generation: job.generation,
|
|
154
|
+
},
|
|
155
|
+
payload: job.payload,
|
|
156
|
+
lane,
|
|
157
|
+
};
|
|
158
|
+
self.reset_drained_lane(lane);
|
|
159
|
+
Some(dispatch)
|
|
160
|
+
}
|
|
161
|
+
/// Request cancellation; running jobs retain their credit and unresolved state.
|
|
162
|
+
#[must_use]
|
|
163
|
+
pub fn cancel(&mut self, id: JobId) -> bool {
|
|
164
|
+
let Some(job) = self.job_mut(id) else {
|
|
165
|
+
return false;
|
|
166
|
+
};
|
|
167
|
+
job.state = match job.state {
|
|
168
|
+
State::Queued => State::Cancelled,
|
|
169
|
+
State::Running | State::Cancelling => State::Cancelling,
|
|
170
|
+
_ => return false,
|
|
171
|
+
};
|
|
172
|
+
let lane = job.lane;
|
|
173
|
+
self.reset_drained_lane(lane);
|
|
174
|
+
true
|
|
175
|
+
}
|
|
176
|
+
/// Record successful execution, including a completion racing cancellation.
|
|
177
|
+
#[must_use]
|
|
178
|
+
pub fn complete(&mut self, id: JobId) -> bool {
|
|
179
|
+
let Some(job) = self.job_mut(id) else {
|
|
180
|
+
return false;
|
|
181
|
+
};
|
|
182
|
+
if job.state != State::Running && job.state != State::Cancelling {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
job.state = State::Complete;
|
|
186
|
+
true
|
|
187
|
+
}
|
|
188
|
+
/// Confirm that a requested cancellation actually stopped the action.
|
|
189
|
+
#[must_use]
|
|
190
|
+
pub fn confirm_cancel(&mut self, id: JobId) -> bool {
|
|
191
|
+
let Some(job) = self.job_mut(id) else {
|
|
192
|
+
return false;
|
|
193
|
+
};
|
|
194
|
+
if job.state != State::Cancelling {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
job.state = State::Cancelled;
|
|
198
|
+
true
|
|
199
|
+
}
|
|
200
|
+
/// Remove a terminal descriptor and return its bounded slot credit.
|
|
201
|
+
///
|
|
202
|
+
/// # Panics
|
|
203
|
+
/// Panics only if internal credit accounting is inconsistent.
|
|
204
|
+
pub fn reap(&mut self, id: JobId) -> Option<(T, State)> {
|
|
205
|
+
let job = *self.job_mut(id)?;
|
|
206
|
+
if job.state != State::Complete && job.state != State::Cancelled {
|
|
207
|
+
return None;
|
|
208
|
+
}
|
|
209
|
+
self.jobs[id.slot] = None;
|
|
210
|
+
assert!(self.credits.release(NonZeroU32::MIN));
|
|
211
|
+
Some((job.payload, job.state))
|
|
212
|
+
}
|
|
213
|
+
/// Query readiness before reaping any constituent handle.
|
|
214
|
+
#[must_use]
|
|
215
|
+
pub fn join(&self, ids: &[JobId]) -> Join {
|
|
216
|
+
let mut result = Join::Ready;
|
|
217
|
+
for id in ids {
|
|
218
|
+
let Some(Some(job)) = self.jobs.get(id.slot) else {
|
|
219
|
+
return Join::Unknown;
|
|
220
|
+
};
|
|
221
|
+
if job.generation != id.generation {
|
|
222
|
+
return Join::Unknown;
|
|
223
|
+
}
|
|
224
|
+
if job.state == State::Cancelled {
|
|
225
|
+
result = Join::Cancelled;
|
|
226
|
+
} else if job.state != State::Complete && result != Join::Cancelled {
|
|
227
|
+
result = Join::Pending;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
result
|
|
231
|
+
}
|
|
232
|
+
/// Refuse future submissions; existing queued/running work may finish.
|
|
233
|
+
pub const fn begin_drain(&mut self) {
|
|
234
|
+
self.draining = true;
|
|
235
|
+
}
|
|
236
|
+
/// Draining is complete only after all descriptors are terminal and reaped.
|
|
237
|
+
#[must_use]
|
|
238
|
+
pub const fn is_drained(&self) -> bool {
|
|
239
|
+
self.draining && self.credits.in_flight() == 0
|
|
240
|
+
}
|
|
241
|
+
fn job_mut(&mut self, id: JobId) -> Option<&mut Job<T>> {
|
|
242
|
+
self.jobs
|
|
243
|
+
.get_mut(id.slot)?
|
|
244
|
+
.as_mut()
|
|
245
|
+
.filter(|job| job.generation == id.generation)
|
|
246
|
+
}
|
|
247
|
+
/// Whether `lane` still holds a job waiting for dispatch.
|
|
248
|
+
fn has_queued(&self, lane: usize) -> bool {
|
|
249
|
+
self.jobs
|
|
250
|
+
.iter()
|
|
251
|
+
.flatten()
|
|
252
|
+
.any(|job| job.lane == lane && job.state == State::Queued)
|
|
253
|
+
}
|
|
254
|
+
fn reset_drained_lane(&mut self, lane: usize) {
|
|
255
|
+
if !self.has_queued(lane) {
|
|
256
|
+
assert!(self.scheduler.deactivate(lane));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
#[cfg(test)]
|
|
261
|
+
mod tests;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "pipeline_machines_native"
|
|
3
|
+
version = "0.1.4"
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
rust-version.workspace = true
|
|
6
|
+
license.workspace = true
|
|
7
|
+
publish = false
|
|
8
|
+
|
|
9
|
+
[lib]
|
|
10
|
+
crate-type = ["cdylib"]
|
|
11
|
+
test = false
|
|
12
|
+
doctest = false
|
|
13
|
+
|
|
14
|
+
[dependencies]
|
|
15
|
+
pipeline-machines.workspace = true
|
|
16
|
+
magnus.workspace = true
|
|
17
|
+
rb-sys.workspace = true
|
|
18
|
+
|
|
19
|
+
[lints]
|
|
20
|
+
workspace = true
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'mkmf'
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'rb_sys/mkmf'
|
|
5
|
+
|
|
6
|
+
create_rust_makefile('pipeline_machines_native/pipeline_machines_native') do |e|
|
|
7
|
+
e.ext_dir = Pathname(__dir__).relative_path_from(Pathname(Dir.pwd)).to_s
|
|
8
|
+
e.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :release).to_sym
|
|
9
|
+
end
|
|
10
|
+
makefile = File.join(Dir.pwd, 'Makefile')
|
|
11
|
+
File.write(makefile, File.read(makefile).gsub(
|
|
12
|
+
/^RB_SYS_CARGO_MANIFEST_DIR \?=.*$/,
|
|
13
|
+
"RB_SYS_CARGO_MANIFEST_DIR ?= #{File.expand_path(__dir__)}"
|
|
14
|
+
))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//! Private native entry point.
|
|
2
|
+
use crate::Wrapper;
|
|
3
|
+
use magnus::{Error, Ruby, function, method, prelude::*};
|
|
4
|
+
|
|
5
|
+
#[magnus::init]
|
|
6
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
7
|
+
let module = ruby.define_module("PipelineMachines")?;
|
|
8
|
+
module
|
|
9
|
+
.define_class("Job", ruby.class_object())?
|
|
10
|
+
.undef_default_alloc_func();
|
|
11
|
+
let class = module.define_class("Stage", ruby.class_object())?;
|
|
12
|
+
class.undef_default_alloc_func();
|
|
13
|
+
class.define_singleton_method("new", function!(Wrapper::new, 1))?;
|
|
14
|
+
class.define_method("submit", method!(Wrapper::submit, 2))?;
|
|
15
|
+
class.define_method("fan_out", method!(Wrapper::fan_out, 2))?;
|
|
16
|
+
class.define_method("dispatch", method!(Wrapper::dispatch, 0))?;
|
|
17
|
+
class.define_method("cancel", method!(Wrapper::cancel, 1))?;
|
|
18
|
+
class.define_method("complete", method!(Wrapper::complete, 1))?;
|
|
19
|
+
class.define_method("confirm_cancel", method!(Wrapper::confirm_cancel, 1))?;
|
|
20
|
+
class.define_method("reap", method!(Wrapper::reap, 1))?;
|
|
21
|
+
class.define_method("join", method!(Wrapper::join, 1))?;
|
|
22
|
+
class.define_method("begin_drain", method!(Wrapper::begin_drain, 0))?;
|
|
23
|
+
class.define_method("drained?", method!(Wrapper::is_drained, 0))?;
|
|
24
|
+
class.define_method("capacity", function!(Wrapper::capacity, 0))?;
|
|
25
|
+
class.define_method("lanes", function!(Wrapper::lanes, 0))?;
|
|
26
|
+
Ok(())
|
|
27
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
//! Native boundary: all lifecycle decisions are delegated to the stage core.
|
|
2
|
+
mod init;
|
|
3
|
+
|
|
4
|
+
use magnus::{
|
|
5
|
+
Error, Integer, RArray, Ruby, Symbol, TryConvert, Value, prelude::*, typed_data::Obj,
|
|
6
|
+
};
|
|
7
|
+
use pipeline_machines::{JobId, Join, Stage, State};
|
|
8
|
+
use std::{cell::RefCell, num::NonZeroU32, sync::Arc};
|
|
9
|
+
|
|
10
|
+
const LANES: usize = 4;
|
|
11
|
+
const CAPACITY: usize = 64;
|
|
12
|
+
|
|
13
|
+
#[derive(Clone, Copy)]
|
|
14
|
+
struct Number(u64);
|
|
15
|
+
|
|
16
|
+
impl TryConvert for Number {
|
|
17
|
+
fn try_convert(value: Value) -> Result<Self, Error> {
|
|
18
|
+
if Integer::from_value(value).is_none() {
|
|
19
|
+
return Err(Error::new(
|
|
20
|
+
Ruby::get_with(value).exception_type_error(),
|
|
21
|
+
"expected an unsigned Integer",
|
|
22
|
+
));
|
|
23
|
+
}
|
|
24
|
+
u64::try_convert(value).map(Self)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#[magnus::wrap(class = "PipelineMachines::Job", free_immediately, size)]
|
|
29
|
+
struct Handle {
|
|
30
|
+
owner: Arc<()>,
|
|
31
|
+
id: JobId,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[magnus::wrap(class = "PipelineMachines::Stage", free_immediately, size)]
|
|
35
|
+
struct Wrapper {
|
|
36
|
+
owner: Arc<()>,
|
|
37
|
+
core: RefCell<Stage<u64, LANES, CAPACITY>>,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
impl Wrapper {
|
|
41
|
+
fn new(ruby: &Ruby, weights: RArray) -> Result<Self, Error> {
|
|
42
|
+
let values = weights.to_value_array::<LANES>()?;
|
|
43
|
+
let mut converted = [NonZeroU32::MIN; LANES];
|
|
44
|
+
for (slot, value) in converted.iter_mut().zip(values) {
|
|
45
|
+
let number = Number::try_convert(value)?.0;
|
|
46
|
+
*slot = u32::try_from(number)
|
|
47
|
+
.ok()
|
|
48
|
+
.and_then(NonZeroU32::new)
|
|
49
|
+
.ok_or_else(|| {
|
|
50
|
+
Error::new(ruby.exception_arg_error(), "weights must be in 1..2**32-1")
|
|
51
|
+
})?;
|
|
52
|
+
}
|
|
53
|
+
let core = Stage::new(converted)
|
|
54
|
+
.ok_or_else(|| Error::new(ruby.exception_arg_error(), "invalid stage bounds"))?;
|
|
55
|
+
Ok(Self {
|
|
56
|
+
owner: Arc::new(()),
|
|
57
|
+
core: RefCell::new(core),
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fn handle(&self, id: JobId) -> Handle {
|
|
62
|
+
Handle {
|
|
63
|
+
owner: Arc::clone(&self.owner),
|
|
64
|
+
id,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fn submit(this: Obj<Self>, lane: Number, payload: Number) -> Result<Option<Handle>, Error> {
|
|
69
|
+
this.check_frozen()?;
|
|
70
|
+
let lane = usize::try_from(lane.0).map_err(|_| {
|
|
71
|
+
Error::new(
|
|
72
|
+
Ruby::get_with(this).exception_range_error(),
|
|
73
|
+
"lane out of range",
|
|
74
|
+
)
|
|
75
|
+
})?;
|
|
76
|
+
let id = this.core.borrow_mut().submit(lane, payload.0);
|
|
77
|
+
Ok(id.map(|id| this.handle(id)))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn fan_out(
|
|
81
|
+
ruby: &Ruby,
|
|
82
|
+
this: Obj<Self>,
|
|
83
|
+
lanes: RArray,
|
|
84
|
+
payload: Number,
|
|
85
|
+
) -> Result<Option<RArray>, Error> {
|
|
86
|
+
this.check_frozen()?;
|
|
87
|
+
if lanes.len() > LANES {
|
|
88
|
+
return Err(Error::new(
|
|
89
|
+
ruby.exception_arg_error(),
|
|
90
|
+
"at most four distinct lanes",
|
|
91
|
+
));
|
|
92
|
+
}
|
|
93
|
+
let mut converted = Vec::with_capacity(lanes.len());
|
|
94
|
+
for index in 0..lanes.len() {
|
|
95
|
+
let number = Number::try_convert(lanes.entry(index.cast_signed())?)?.0;
|
|
96
|
+
converted.push(
|
|
97
|
+
usize::try_from(number)
|
|
98
|
+
.map_err(|_| Error::new(ruby.exception_range_error(), "lane out of range"))?,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
let ids = this.core.borrow_mut().fan_out(&converted, payload.0);
|
|
102
|
+
let Some(ids) = ids else {
|
|
103
|
+
return Ok(None);
|
|
104
|
+
};
|
|
105
|
+
let result = ruby.ary_new();
|
|
106
|
+
for id in ids.into_iter().flatten() {
|
|
107
|
+
result.push(this.handle(id))?;
|
|
108
|
+
}
|
|
109
|
+
Ok(Some(result))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
fn dispatch(ruby: &Ruby, this: Obj<Self>) -> Result<Option<RArray>, Error> {
|
|
113
|
+
this.check_frozen()?;
|
|
114
|
+
let work = this.core.borrow_mut().dispatch(&[true; LANES]);
|
|
115
|
+
let Some(work) = work else {
|
|
116
|
+
return Ok(None);
|
|
117
|
+
};
|
|
118
|
+
let result = ruby.ary_new();
|
|
119
|
+
result.push(this.handle(work.id))?;
|
|
120
|
+
result.push(work.payload)?;
|
|
121
|
+
result.push(work.lane)?;
|
|
122
|
+
Ok(Some(result))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn cancel(this: Obj<Self>, handle: Obj<Handle>) -> Result<bool, Error> {
|
|
126
|
+
this.check_frozen()?;
|
|
127
|
+
Ok(Arc::ptr_eq(&this.owner, &handle.owner) && this.core.borrow_mut().cancel(handle.id))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fn complete(this: Obj<Self>, handle: Obj<Handle>) -> Result<bool, Error> {
|
|
131
|
+
this.check_frozen()?;
|
|
132
|
+
Ok(Arc::ptr_eq(&this.owner, &handle.owner) && this.core.borrow_mut().complete(handle.id))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fn confirm_cancel(this: Obj<Self>, handle: Obj<Handle>) -> Result<bool, Error> {
|
|
136
|
+
this.check_frozen()?;
|
|
137
|
+
Ok(Arc::ptr_eq(&this.owner, &handle.owner)
|
|
138
|
+
&& this.core.borrow_mut().confirm_cancel(handle.id))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fn reap(
|
|
142
|
+
ruby: &Ruby,
|
|
143
|
+
this: Obj<Self>,
|
|
144
|
+
handle: Obj<Handle>,
|
|
145
|
+
) -> Result<Option<(u64, Symbol)>, Error> {
|
|
146
|
+
this.check_frozen()?;
|
|
147
|
+
if !Arc::ptr_eq(&this.owner, &handle.owner) {
|
|
148
|
+
return Ok(None);
|
|
149
|
+
}
|
|
150
|
+
let result = this.core.borrow_mut().reap(handle.id);
|
|
151
|
+
Ok(result.map(|(payload, state)| (payload, ruby.to_symbol(state_name(state)))))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fn join(ruby: &Ruby, this: Obj<Self>, handles: RArray) -> Result<Symbol, Error> {
|
|
155
|
+
if handles.len() > CAPACITY {
|
|
156
|
+
return Err(Error::new(
|
|
157
|
+
ruby.exception_arg_error(),
|
|
158
|
+
"at most 64 join handles",
|
|
159
|
+
));
|
|
160
|
+
}
|
|
161
|
+
let mut ids = Vec::with_capacity(handles.len());
|
|
162
|
+
for index in 0..handles.len() {
|
|
163
|
+
let handle: Obj<Handle> = handles.entry(index.cast_signed())?;
|
|
164
|
+
if !Arc::ptr_eq(&this.owner, &handle.owner) {
|
|
165
|
+
return Ok(ruby.to_symbol("unknown"));
|
|
166
|
+
}
|
|
167
|
+
ids.push(handle.id);
|
|
168
|
+
}
|
|
169
|
+
let result = this.core.borrow().join(&ids);
|
|
170
|
+
Ok(ruby.to_symbol(match result {
|
|
171
|
+
Join::Ready => "ready",
|
|
172
|
+
Join::Pending => "pending",
|
|
173
|
+
Join::Cancelled => "cancelled",
|
|
174
|
+
_ => "unknown",
|
|
175
|
+
}))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fn begin_drain(this: Obj<Self>) -> Result<(), Error> {
|
|
179
|
+
this.check_frozen()?;
|
|
180
|
+
this.core.borrow_mut().begin_drain();
|
|
181
|
+
Ok(())
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
fn is_drained(&self) -> bool {
|
|
185
|
+
self.core.borrow().is_drained()
|
|
186
|
+
}
|
|
187
|
+
const fn capacity() -> usize {
|
|
188
|
+
CAPACITY
|
|
189
|
+
}
|
|
190
|
+
const fn lanes() -> usize {
|
|
191
|
+
LANES
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const fn state_name(state: State) -> &'static str {
|
|
196
|
+
match state {
|
|
197
|
+
State::Queued => "queued",
|
|
198
|
+
State::Running => "running",
|
|
199
|
+
State::Cancelling => "cancelling",
|
|
200
|
+
State::Complete => "complete",
|
|
201
|
+
_ => "cancelled",
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module PipelineMachines
|
|
2
|
+
VERSION: String
|
|
3
|
+
class Job
|
|
4
|
+
end
|
|
5
|
+
class Stage
|
|
6
|
+
def self.new: (Array[Integer] weights) -> Stage
|
|
7
|
+
def capacity: () -> Integer
|
|
8
|
+
def lanes: () -> Integer
|
|
9
|
+
def submit: (Integer lane, Integer payload) -> Job?
|
|
10
|
+
def fan_out: (Array[Integer] lanes, Integer payload) -> Array[Job]?
|
|
11
|
+
def dispatch: () -> [Job, Integer, Integer]?
|
|
12
|
+
def cancel: (Job) -> bool
|
|
13
|
+
def complete: (Job) -> bool
|
|
14
|
+
def confirm_cancel: (Job) -> bool
|
|
15
|
+
def reap: (Job) -> [Integer, Symbol]?
|
|
16
|
+
def join: (Array[Job]) -> Symbol
|
|
17
|
+
def begin_drain: () -> nil
|
|
18
|
+
def drained?: () -> bool
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pipeline_machines
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.4
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Abdelkader Boudih
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rb_sys
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.9'
|
|
19
|
+
- - ">="
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: 0.9.130
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - "~>"
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '0.9'
|
|
29
|
+
- - ">="
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: 0.9.130
|
|
32
|
+
description: Local unstable stage policy model, not a durable pipeline runtime.
|
|
33
|
+
email:
|
|
34
|
+
- oss@seuros.com
|
|
35
|
+
executables: []
|
|
36
|
+
extensions:
|
|
37
|
+
- ext/pipeline_machines_native/extconf.rb
|
|
38
|
+
extra_rdoc_files: []
|
|
39
|
+
files:
|
|
40
|
+
- CHANGELOG.md
|
|
41
|
+
- Cargo.toml
|
|
42
|
+
- LICENSE.txt
|
|
43
|
+
- README.md
|
|
44
|
+
- ext/pipeline_machines_native/core/CHANGELOG.md
|
|
45
|
+
- ext/pipeline_machines_native/core/Cargo.toml
|
|
46
|
+
- ext/pipeline_machines_native/core/README.md
|
|
47
|
+
- ext/pipeline_machines_native/core/src/lib.rs
|
|
48
|
+
- ext/pipeline_machines_native/extconf.rb
|
|
49
|
+
- ext/pipeline_machines_native/ffi/Cargo.toml
|
|
50
|
+
- ext/pipeline_machines_native/ffi/extconf.rb
|
|
51
|
+
- ext/pipeline_machines_native/ffi/src/init.rs
|
|
52
|
+
- ext/pipeline_machines_native/ffi/src/lib.rs
|
|
53
|
+
- lib/pipeline_machines.rb
|
|
54
|
+
- lib/pipeline_machines/version.rb
|
|
55
|
+
- sig/pipeline_machines.rbs
|
|
56
|
+
homepage: https://github.com/seuros/pipeline_machines
|
|
57
|
+
licenses:
|
|
58
|
+
- MIT
|
|
59
|
+
metadata:
|
|
60
|
+
source_code_uri: https://github.com/seuros/pipeline_machines
|
|
61
|
+
rubygems_mfa_required: 'true'
|
|
62
|
+
cargo_crate_name: pipeline_machines_native
|
|
63
|
+
cargo_manifest_path: ext/pipeline_machines_native/ffi/Cargo.toml
|
|
64
|
+
rdoc_options: []
|
|
65
|
+
require_paths:
|
|
66
|
+
- lib
|
|
67
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: 4.0.0
|
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '0'
|
|
77
|
+
requirements: []
|
|
78
|
+
rubygems_version: 4.0.20
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: Initial native bounded stage lifecycle and fanout decisions
|
|
81
|
+
test_files: []
|