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 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,2 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'ffi/extconf'
@@ -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,4 @@
1
+ # frozen_string_literal: true
2
+ module PipelineMachines
3
+ VERSION = '0.1.4'
4
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'pipeline_machines/version'
3
+ require 'pipeline_machines_native/pipeline_machines_native'
@@ -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: []