shifty 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +79 -0
- data/README.md +79 -35
- data/benchmark/RESULTS.md +69 -0
- data/benchmark/handoff_policies.rb +82 -0
- data/docs/planning/artifacts/.discovery-notes.md +40 -0
- data/docs/planning/artifacts/.round1-specialist-outputs.md +196 -0
- data/docs/planning/artifacts/implementation-decision-log.md +231 -0
- data/docs/planning/artifacts/implementation-iteration-history.md +73 -0
- data/docs/planning/feature-implementation-plan.md +191 -0
- data/docs/planning/handoff-immutability-policies.md +590 -0
- data/docs/shifty/worker.md +8 -19
- data/docs/use_cases.md +59 -0
- data/docs/wiki/Coding-Idioms-Under-Frozen.md +74 -0
- data/docs/wiki/Handoff-Policies.md +167 -0
- data/docs/wiki/Home.md +59 -0
- data/docs/wiki/Migration-Guide-0.6.md +96 -0
- data/docs/wiki/Performance.md +59 -0
- data/docs/wiki/Testing-Workers.md +127 -0
- data/docs/wiki/Worker-Types.md +171 -0
- data/docs/wiki/_Sidebar.md +13 -0
- data/lib/shifty/configuration.rb +27 -0
- data/lib/shifty/dsl.rb +35 -14
- data/lib/shifty/errors.rb +121 -0
- data/lib/shifty/gang.rb +25 -2
- data/lib/shifty/policy.rb +124 -0
- data/lib/shifty/rspec.rb +37 -0
- data/lib/shifty/testing.rb +82 -0
- data/lib/shifty/version.rb +1 -1
- data/lib/shifty/worker.rb +108 -5
- data/lib/shifty.rb +3 -0
- data/shifty.gemspec +6 -1
- metadata +56 -8
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
**Status:** Implemented in 0.6.0 (see [feature-implementation-plan.md](feature-implementation-plan.md))
|
|
2
|
+
**Target:** 0.6.0 (pre-1.0 minor carrying the breaking change)
|
|
3
|
+
**Author:** Joel Helbling
|
|
4
|
+
**Last updated:** 2026-07-10
|
|
5
|
+
|
|
6
|
+
> **Post-implementation corrections.** Empirical spikes during Phase 1
|
|
7
|
+
> falsified three mechanism claims below; the shipped code follows the
|
|
8
|
+
> corrected behavior (design intent unchanged):
|
|
9
|
+
>
|
|
10
|
+
> 1. **§3.2/§10.1:** `:isolated` uses a **Marshal round-trip**, not
|
|
11
|
+
> `Ractor.make_shareable(copy: true)` — the latter returns a *frozen*
|
|
12
|
+
> copy and cannot provide the mutable scratch copy the contract
|
|
13
|
+
> promises. Consequently `:hardened` → `:isolated` preserves the exact
|
|
14
|
+
> mechanism as well as the semantics.
|
|
15
|
+
> 2. **§3.3/§10.1:** `make_shareable` does **not** reject IO handles or
|
|
16
|
+
> singleton-methoded objects — it silently freezes a live IO *in
|
|
17
|
+
> place*, process-wide (and `copy: true` leaks a file descriptor). The
|
|
18
|
+
> implementation therefore detects top-level IO proactively and raises
|
|
19
|
+
> `UnshareableValue` before calling `make_shareable`. Only Proc and
|
|
20
|
+
> `Enumerator::Lazy` (and StringIO) are rejected naturally.
|
|
21
|
+
> 3. **§5.3/§11.4:** `PolicyConflict` was dropped entirely — with worker
|
|
22
|
+
> declarations authoritative and pipeline policy default-only, no
|
|
23
|
+
> violable composition rule exists. Reopen alongside a "strict Gang".
|
|
24
|
+
>
|
|
25
|
+
> Also: §5.4's `Worker#task=` never existed; `#freeze!`'s motivation is
|
|
26
|
+
> `supply=`/Roster rewiring. §8.2's amortization claim was validated by
|
|
27
|
+
> benchmarks (`benchmark/RESULTS.md`): steady-state `:frozen` ≈ 71ns per
|
|
28
|
+
> handoff, independent of value size.
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# Handoff Immutability Policies
|
|
32
|
+
|
|
33
|
+
## 1. Motivation
|
|
34
|
+
|
|
35
|
+
Shifty's core strength is that individual workers are simple to reason about: each
|
|
36
|
+
worker is an isolated unit of work with private state held in closure scope, connected
|
|
37
|
+
to its neighbors only by the values it receives and hands off. But that strength has a
|
|
38
|
+
shadow. The *connascence buried in the values passed between workers* remains
|
|
39
|
+
unmanaged. Every handoff is a shared Ruby object reference, which means:
|
|
40
|
+
|
|
41
|
+
- A worker four steps into a pipeline can mutate a value (`<<`, `map!`, `merge!`,
|
|
42
|
+
attribute assignment) and silently corrupt what workers five through nine observe.
|
|
43
|
+
- Because the escalator model moves each value through *every* worker before the next
|
|
44
|
+
value starts, the symptom of such a mutation typically surfaces far downstream from
|
|
45
|
+
its cause. Failure locality is poor; debugging is archaeology.
|
|
46
|
+
- Side workers — whose entire contract is "observe, don't modify" — cannot actually be
|
|
47
|
+
held to that contract. The current `:hardened` mode acknowledges this by handing side
|
|
48
|
+
workers a `Marshal.load(Marshal.dump(value))` deep copy, at significant cost.
|
|
49
|
+
|
|
50
|
+
Immutability addresses Shifty exactly where it is weakest. Workers stay easy to reason
|
|
51
|
+
about *individually*; immutable handoffs make the *data space between them* equally
|
|
52
|
+
tractable. Mutation bugs stop being possible-but-invisible and become either impossible
|
|
53
|
+
(isolated copies) or immediately loud (`FrozenError` at the offending worker).
|
|
54
|
+
|
|
55
|
+
Strong typing (e.g. Sorbet) would extend these guarantees further, but is a separate
|
|
56
|
+
concern and a separate developer choice. It is explicitly out of scope here (§12).
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 2. Summary of the Change
|
|
61
|
+
|
|
62
|
+
1. Introduce a **handoff policy** governing how each value crosses a worker boundary.
|
|
63
|
+
2. Three policies: **`:frozen`** (new default), **`:isolated`**, and **`:shared`**.
|
|
64
|
+
3. Policy is declarable at the **worker** level and at the **pipeline/Gang** level;
|
|
65
|
+
worker declarations take precedence (§5).
|
|
66
|
+
4. The existing `:hardened` option is subsumed by `:isolated` and deprecated (§10).
|
|
67
|
+
5. Raw `FrozenError`s arising from policy violations are caught by the framework and
|
|
68
|
+
re-raised as `Shifty::PolicyViolation` with pipeline-aware diagnostics (§6).
|
|
69
|
+
6. A test harness and RSpec helpers ensure unit tests exercise workers under the same
|
|
70
|
+
policy the pipeline will (§9).
|
|
71
|
+
|
|
72
|
+
This is a breaking change in default behavior and warrants a major version bump.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 3. The Three Policies
|
|
77
|
+
|
|
78
|
+
### 3.1 `:frozen` — the new default
|
|
79
|
+
|
|
80
|
+
The value is **deeply frozen** at handoff via `Ractor.make_shareable(value)` and passed
|
|
81
|
+
**by reference**. Zero copies. Any attempt to mutate the value (or anything reachable
|
|
82
|
+
from it) raises immediately, at the worker that misbehaved.
|
|
83
|
+
|
|
84
|
+
- **Guarantee:** no worker can mutate a value observed by any other worker.
|
|
85
|
+
- **Cost:** freeze traversal only; no copying, no extra garbage. Amortized cost is
|
|
86
|
+
proportional to *newly created* objects, not graph size (§8.2).
|
|
87
|
+
- **Developer contract:** tasks must be non-destructive. "Apparent mutation" is
|
|
88
|
+
expressed as copy-on-write: `value.with(...)`, `arr + [x]`, `str + "suffix"`,
|
|
89
|
+
`hash.merge(...)` (§7).
|
|
90
|
+
|
|
91
|
+
### 3.2 `:isolated` — compatibility / scratch-copy mode
|
|
92
|
+
|
|
93
|
+
The worker's task receives a **deep, mutable copy** of the value, produced by
|
|
94
|
+
`Ractor.make_shareable(value, copy: true)` where available (falling back to
|
|
95
|
+
`Marshal.load(Marshal.dump(value))`). The task may mutate its copy freely — `<<` and
|
|
96
|
+
friends work exactly as in classic Shifty code — but nothing leaks back to the
|
|
97
|
+
upstream reference (the supplying worker's closure state, a source's recirculating
|
|
98
|
+
value, etc.). Whatever the task returns/hands off is what flows downstream, mutations
|
|
99
|
+
included.
|
|
100
|
+
|
|
101
|
+
- **Guarantee:** upstream references are protected; task-local mutation is invisible
|
|
102
|
+
outside the worker.
|
|
103
|
+
- **Cost:** full deep copy of the value graph **per worker, per value**, plus the
|
|
104
|
+
corresponding GC pressure. This is why it is not the default (§8.1).
|
|
105
|
+
- **Use cases:** mutation-heavy legacy task code not worth rewriting; side workers
|
|
106
|
+
(this is precisely the old `:hardened` semantics, generalized); untrusted or
|
|
107
|
+
third-party task procs.
|
|
108
|
+
- **Note for side workers:** the return value of a side worker is discarded, so under
|
|
109
|
+
`:isolated` a side worker's mutations simply evaporate — the behavior the
|
|
110
|
+
documentation always wished for.
|
|
111
|
+
|
|
112
|
+
### 3.3 `:shared` — the escape hatch
|
|
113
|
+
|
|
114
|
+
The raw object reference is passed through untouched. This is today's default
|
|
115
|
+
behavior, renamed to say what it actually is.
|
|
116
|
+
|
|
117
|
+
There are two legitimate reasons to reach for `:shared`, and the name is chosen to
|
|
118
|
+
cover both:
|
|
119
|
+
|
|
120
|
+
1. **Intentional in-place mutation** of a value that downstream workers are meant to
|
|
121
|
+
observe.
|
|
122
|
+
2. **Uncopyable / unfreezable values**: IO handles, sockets, lazy enumerators, procs,
|
|
123
|
+
objects with singleton methods, or anything else that `make_shareable` (or Marshal)
|
|
124
|
+
rejects. Sometimes the developer is not asking permission to mutate; she is asking
|
|
125
|
+
for pass-by-reference.
|
|
126
|
+
|
|
127
|
+
- **Guarantee:** none. The framework provides no protection on this boundary.
|
|
128
|
+
- **Cost:** zero.
|
|
129
|
+
|
|
130
|
+
### 3.4 Policy comparison
|
|
131
|
+
|
|
132
|
+
| | `:frozen` (default) | `:isolated` | `:shared` |
|
|
133
|
+
|---|---|---|---|
|
|
134
|
+
| What the task receives | frozen shared reference | private mutable deep copy | raw shared reference |
|
|
135
|
+
| Upstream protected from mutation | yes (mutation raises) | yes (mutation is local) | no |
|
|
136
|
+
| Mutation in task code | raises `PolicyViolation` | works, stays local | works, leaks |
|
|
137
|
+
| Copying per handoff | none | full graph | none |
|
|
138
|
+
| Freeze traversal per handoff | delta only (amortized) | none | none |
|
|
139
|
+
| GC pressure | minimal | high | none |
|
|
140
|
+
| Handles unshareable values (IO, procs, …) | no — raises at handoff | no — raises at handoff | yes |
|
|
141
|
+
| Failure mode of incorrect task code | loud, local, immediate | silent no-op upstream | silent corruption downstream |
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 4. Why `:frozen` Is the Right Default
|
|
146
|
+
|
|
147
|
+
### 4.1 The candidates
|
|
148
|
+
|
|
149
|
+
- **`:shared` (status quo):** preserves silent-corruption bugs; rejected.
|
|
150
|
+
- **`:isolated`:** attractive because existing task code keeps working verbatim — but
|
|
151
|
+
it deep-copies the entire value graph at *every* boundary. A nine-worker pipeline
|
|
152
|
+
copies every value nine times and generates nine graphs of garbage. For token-sized
|
|
153
|
+
values this is noise; for parsed documents or large collections it is a serious,
|
|
154
|
+
hard-to-work-around tax. Defaults should not carry O(graph) costs per hop.
|
|
155
|
+
(This is also, historically, why `:hardened` was never made the default: the
|
|
156
|
+
Marshal round-trip was too expensive. `make_shareable(copy: true)` is cheaper, but
|
|
157
|
+
it is the same complexity class.)
|
|
158
|
+
- **`:frozen`:** zero-copy, and its freeze cost amortizes to the size of the *change*
|
|
159
|
+
rather than the size of the value (§8.2). It is the only policy that is
|
|
160
|
+
simultaneously safe and cheap at scale.
|
|
161
|
+
|
|
162
|
+
### 4.2 The trade-off being accepted
|
|
163
|
+
|
|
164
|
+
`:frozen` will briefly surprise developers whose task code mutates its input. That
|
|
165
|
+
surprise is deliberate, and it is a *well-behaved* surprise:
|
|
166
|
+
|
|
167
|
+
- It arrives **immediately**, at the exact worker that misbehaved — not downstream.
|
|
168
|
+
- It arrives as a rich `PolicyViolation` naming the receiver and offering two
|
|
169
|
+
documented exits (rewrite non-destructively, or declare `:isolated`/`:shared` on
|
|
170
|
+
that worker) (§6).
|
|
171
|
+
- An early, loud, explained error is strictly preferable to cumbersome performance
|
|
172
|
+
problems (an `:isolated` default) or silent data corruption (a `:shared` default).
|
|
173
|
+
|
|
174
|
+
### 4.3 The synergy that makes it pleasant
|
|
175
|
+
|
|
176
|
+
`:frozen` as default and copy-on-write as idiom are not two separate decisions.
|
|
177
|
+
Structural sharing (`Data#with` copying member *references*, `arr + [x]` sharing
|
|
178
|
+
elements) is only safe because shared substructure is frozen; and copy-on-write is
|
|
179
|
+
what makes frozen values ergonomic. Each half makes the other work (§7, §8).
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 5. API Design
|
|
184
|
+
|
|
185
|
+
### 5.1 Declaring policy
|
|
186
|
+
|
|
187
|
+
Policy may be declared at three levels, from narrowest to widest:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
# 1. Worker level — part of the worker's contract
|
|
191
|
+
worker = Shifty::Worker.new(policy: :isolated) { |v| v << transform(v) }
|
|
192
|
+
|
|
193
|
+
# DSL flavors (names illustrative):
|
|
194
|
+
side_worker(policy: :shared) { |v| logger.info(v) }
|
|
195
|
+
|
|
196
|
+
# 2. Pipeline / composition level — default for undeclared workers
|
|
197
|
+
pipeline = (source | parser | enricher | sink).with_policy(:frozen)
|
|
198
|
+
|
|
199
|
+
# 3. Global default — :frozen out of the box
|
|
200
|
+
Shifty.configure { |c| c.default_policy = :frozen }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### 5.2 Precedence: worker beats pipeline beats global
|
|
204
|
+
|
|
205
|
+
A worker-level declaration is part of that worker's **contract** and always wins.
|
|
206
|
+
Pipeline-level policy is a *default applied to undeclared workers*, not an override.
|
|
207
|
+
This rule is what makes testing reliable (§9): any harness that runs a worker through
|
|
208
|
+
the framework automatically exercises the policy that production will.
|
|
209
|
+
|
|
210
|
+
### 5.3 Build-time validation
|
|
211
|
+
|
|
212
|
+
Because policy is part of the worker contract, mismatches can be detected at
|
|
213
|
+
composition time — at the `|`, not mid-stream:
|
|
214
|
+
|
|
215
|
+
- A worker declaring `:shared` (e.g. "I pass an IO handle downstream") composed into a
|
|
216
|
+
pipeline whose policy configuration forbids it can raise
|
|
217
|
+
`Shifty::PolicyConflict` when the pipeline is assembled.
|
|
218
|
+
- Exact strictness semantics (whether pipelines may *forbid* worker-level loosening)
|
|
219
|
+
is an open question (§11.4); the initial implementation treats worker declarations
|
|
220
|
+
as authoritative and pipeline policy as default-only.
|
|
221
|
+
|
|
222
|
+
### 5.4 Freezing the topology (companion change)
|
|
223
|
+
|
|
224
|
+
The same intentionality argument applies to the pipeline itself: `Worker#task=` and
|
|
225
|
+
`Worker#supply=` allow a fully assembled pipeline to be rewired mid-stream. This
|
|
226
|
+
change adds an optional `#freeze!` on the assembled chain so that "the pipeline you
|
|
227
|
+
composed is the pipeline that runs" can be a guarantee rather than a convention.
|
|
228
|
+
(Independent of handoff policy; included in the same major version.)
|
|
229
|
+
|
|
230
|
+
### 5.5 Optional worker naming for diagnostics
|
|
231
|
+
|
|
232
|
+
Workers gain an optional `name:` (in addition to existing tags) used purely in error
|
|
233
|
+
messages and diagnostics, so a `PolicyViolation` can say *which* of nine anonymous
|
|
234
|
+
workers raised.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## 6. Errors and Diagnostics
|
|
239
|
+
|
|
240
|
+
### 6.1 The problem
|
|
241
|
+
|
|
242
|
+
A developer changes a policy without changing task code (or vice versa), and the code
|
|
243
|
+
becomes *incorrect*. Raw `FrozenError`s from deep inside a task, surfacing in a test
|
|
244
|
+
run, do not point anywhere useful. The framework owns every task call site, so it can
|
|
245
|
+
do much better.
|
|
246
|
+
|
|
247
|
+
### 6.2 `Shifty::PolicyViolation`
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
def perform_task(value)
|
|
251
|
+
task.call(value)
|
|
252
|
+
rescue FrozenError => e
|
|
253
|
+
raise PolicyViolation.new(
|
|
254
|
+
worker: self, # includes name/tags for locating it
|
|
255
|
+
policy: effective_policy,
|
|
256
|
+
receiver: e.receiver, # the object the task tried to mutate
|
|
257
|
+
value: value,
|
|
258
|
+
cause: e # never mask the original
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Key details:
|
|
264
|
+
|
|
265
|
+
- **`FrozenError#receiver`** identifies *which object* the task tried to mutate. This
|
|
266
|
+
distinguishes "you mutated the handed-off value (or something reachable from it)"
|
|
267
|
+
from an unrelated frozen-string-literal error in the task's own code. Heuristic:
|
|
268
|
+
report whether `receiver.equal?(value)` or receiver appears reachable from `value`;
|
|
269
|
+
when unclear, report both objects and let the developer judge.
|
|
270
|
+
- **Message content** (illustrative):
|
|
271
|
+
|
|
272
|
+
> Worker `enricher` (tags: `[:etl]`) received its value under the `:frozen` handoff
|
|
273
|
+
> policy, and its task attempted to mutate an instance of `Array`.
|
|
274
|
+
>
|
|
275
|
+
> Either make the task non-destructive — e.g. `map` instead of `map!`,
|
|
276
|
+
> `value.with(...)`, `arr + [x]` — or declare a different policy on this worker:
|
|
277
|
+
> `policy: :isolated` (task works on a private scratch copy) or `policy: :shared`
|
|
278
|
+
> (raw reference; no protection).
|
|
279
|
+
|
|
280
|
+
- Unshareable-value failures at the handoff itself (IO, procs, singleton-methoded
|
|
281
|
+
objects under `:frozen` or `:isolated`) raise a parallel
|
|
282
|
+
`Shifty::UnshareableValue` error: *"this value cannot be frozen/copied; declare
|
|
283
|
+
`:shared` on this worker, or restructure the value."*
|
|
284
|
+
|
|
285
|
+
### 6.3 The silent-loosening asymmetry
|
|
286
|
+
|
|
287
|
+
Error wrapping only catches motion in the *strict* direction. Moving **looser** —
|
|
288
|
+
`:frozen`/`:isolated` → `:shared` — fails silently: a task that was harmlessly
|
|
289
|
+
mutating its private copy (or that would have raised) now mutates the live shared
|
|
290
|
+
value. No exception will ever fire. This asymmetry must be documented prominently,
|
|
291
|
+
and it is the primary motivation for the mutation detector:
|
|
292
|
+
|
|
293
|
+
### 6.4 Mutation detector (opt-in diagnostic mode)
|
|
294
|
+
|
|
295
|
+
A development/test-only mode in which the framework hands each task a mutable deep
|
|
296
|
+
copy, then compares the copy before and after invocation (via `Marshal.dump`
|
|
297
|
+
comparison or recursive digest) and reports:
|
|
298
|
+
|
|
299
|
+
> Worker `enricher`'s task **mutates its input**. It is only correct under
|
|
300
|
+
> `:isolated` (mutation stays local) or `:shared` (mutation is intentional and
|
|
301
|
+
> observed downstream). It will raise under `:frozen`.
|
|
302
|
+
|
|
303
|
+
This surfaces mutation even when the current policy *permits* it — exactly the
|
|
304
|
+
information a developer needs **before** flipping a policy, not after. It also powers
|
|
305
|
+
the `not_to mutate_input` test matcher (§9.3). Never enabled in production paths
|
|
306
|
+
(it costs a deep copy per handoff plus comparison).
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## 7. Coding Idioms Under `:frozen`
|
|
311
|
+
|
|
312
|
+
Documentation (README + a dedicated guide) should establish these patterns:
|
|
313
|
+
|
|
314
|
+
### 7.1 Copy-on-write instead of mutation
|
|
315
|
+
|
|
316
|
+
| Mutating (raises under `:frozen`) | Non-destructive equivalent |
|
|
317
|
+
|---|---|
|
|
318
|
+
| `str << "x"`, `str.upcase!` | `str + "x"`, `str.upcase` |
|
|
319
|
+
| `arr << x`, `arr.map!` | `arr + [x]`, `arr.map` |
|
|
320
|
+
| `hash[k] = v`, `hash.merge!` | `hash.merge(k => v)` |
|
|
321
|
+
| `obj.attr = v` | `obj.with(attr: v)` (see below) |
|
|
322
|
+
|
|
323
|
+
### 7.2 `Data.define` as the recommended value envelope
|
|
324
|
+
|
|
325
|
+
Ruby 3.2+ `Data` classes are immutable by construction and ship copy-on-update:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
Token = Data.define(:payload, :meta)
|
|
329
|
+
|
|
330
|
+
# in a worker task:
|
|
331
|
+
->(v) { v.with(payload: enrich(v.payload)) }
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`#with` allocates one new outer object and copies member *references*; unchanged
|
|
335
|
+
members are structurally shared. This is safe precisely because everything is frozen.
|
|
336
|
+
If Shifty later grows an official work-item envelope (provenance, batch metadata),
|
|
337
|
+
`Data` is the substrate.
|
|
338
|
+
|
|
339
|
+
### 7.3 Mutable within, immutable between
|
|
340
|
+
|
|
341
|
+
Closure state remains fully mutable — that is Shifty's design and its selling point
|
|
342
|
+
for stateful workers, and nothing about it changes. Workers that *build* values
|
|
343
|
+
(sources; batch/trailing workers accumulating in closure scope) may mutate freely
|
|
344
|
+
during construction; the freeze happens at handoff. The framework thereby enforces
|
|
345
|
+
the functional-core boundary exactly where Shifty always drew it conceptually:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
source_worker do
|
|
349
|
+
buffer = [] # closure state: mutable, private, fine
|
|
350
|
+
while (line = io.gets)
|
|
351
|
+
buffer << line # construction: mutate freely
|
|
352
|
+
handoff buffer.join if buffer.size == 10 # handoff: frozen from here on
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
One consequence worth a doc callout: a builder that hands off an object and *keeps a
|
|
358
|
+
reference to it* (e.g. hands off `buffer` itself, then keeps appending) will raise on
|
|
359
|
+
the next append under `:frozen` — which is correct, and exactly the aliasing bug the
|
|
360
|
+
policy exists to catch. Hand off a snapshot (`buffer.dup`, `buffer.join`, `#with`)
|
|
361
|
+
when the builder intends to keep building.
|
|
362
|
+
|
|
363
|
+
### 7.4 Heavy accumulation: persistent data structures (optional)
|
|
364
|
+
|
|
365
|
+
For genuinely large collections under frequent update, `arr + [x]`'s O(n) pointer
|
|
366
|
+
copy can pinch. The **immutable-ruby** gem's persistent `Vector`/`Hash` offer
|
|
367
|
+
O(log n) structurally-shared updates. This is a documented optimization for users,
|
|
368
|
+
**not** a Shifty dependency.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## 8. Performance Analysis
|
|
373
|
+
|
|
374
|
+
### 8.1 Complexity per handoff
|
|
375
|
+
|
|
376
|
+
| Policy | CPU per handoff | Allocation per handoff |
|
|
377
|
+
|---|---|---|
|
|
378
|
+
| `:shared` | O(1) | none |
|
|
379
|
+
| `:frozen` | O(new objects since last freeze) — see 8.2 | none |
|
|
380
|
+
| `:isolated` | O(entire value graph) | entire value graph (GC pressure) |
|
|
381
|
+
|
|
382
|
+
Ordering: `:shared` ≤ `:frozen` (with copy-on-write idioms, delta-proportional)
|
|
383
|
+
≪ `:isolated` (whole-graph-proportional, per boundary). In practice the GC pressure
|
|
384
|
+
of `:isolated` — a full graph of garbage per worker per value — is often a bigger
|
|
385
|
+
real-world cost than the copy CPU itself.
|
|
386
|
+
|
|
387
|
+
### 8.2 Why `:frozen` amortizes
|
|
388
|
+
|
|
389
|
+
MRI marks objects shareable once `Ractor.make_shareable` has blessed them, and the
|
|
390
|
+
traversal short-circuits on already-shareable subgraphs. In a `:frozen` pipeline the
|
|
391
|
+
*first* handoff pays a full traversal of the value; every subsequent handoff
|
|
392
|
+
traverses only objects created since the previous one. Combined with copy-on-write
|
|
393
|
+
task code (which by definition creates only delta-sized new structure), per-boundary
|
|
394
|
+
freeze cost is proportional to the change, not the value.
|
|
395
|
+
|
|
396
|
+
### 8.3 `Ractor.shareable?` fast path (open design point)
|
|
397
|
+
|
|
398
|
+
If an incoming value is already shareable, it is deeply frozen and *nobody* can
|
|
399
|
+
mutate it — isolation is trivially satisfied without copying. `:isolated` could
|
|
400
|
+
exploit this and skip the copy. Wrinkle: the task then receives a frozen object where
|
|
401
|
+
it expected a mutable scratch copy. Options: (a) accept "you get either a mutable
|
|
402
|
+
copy or an already-immutable original" as the `:isolated` contract; (b) don't
|
|
403
|
+
optimize; (c) add an `:isolated_frozen` variant. Decision deferred pending benchmarks
|
|
404
|
+
(§13); initial implementation takes (b) for contract simplicity.
|
|
405
|
+
|
|
406
|
+
### 8.4 Benchmarks to produce before release
|
|
407
|
+
|
|
408
|
+
- `:shared` vs `:frozen` vs `:isolated` vs legacy `:hardened` (Marshal) across value
|
|
409
|
+
shapes: small token, mid-size hash/array, large parsed document, deep nesting.
|
|
410
|
+
- First-handoff vs steady-state cost under `:frozen` (verify the amortization claim).
|
|
411
|
+
- GC statistics (allocations, minor/major GC counts) per policy.
|
|
412
|
+
- `Data#with` vs `make_shareable(copy: true)` for representative update patterns.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## 9. Testing Story
|
|
417
|
+
|
|
418
|
+
### 9.1 The parity problem
|
|
419
|
+
|
|
420
|
+
A pipeline runs `:frozen`; a unit test exercises the worker's raw proc
|
|
421
|
+
(`worker.task.call(input)`) with an ordinary mutable object. The test passes; the
|
|
422
|
+
pipeline raises. Three reinforcing moves close this gap:
|
|
423
|
+
|
|
424
|
+
### 9.2 Worker-level policy declarations win (§5.2)
|
|
425
|
+
|
|
426
|
+
Since the worker carries its policy, any test that runs the worker *through the
|
|
427
|
+
framework* automatically exercises production semantics. With `:frozen` as the
|
|
428
|
+
global default, the common case requires no test configuration at all.
|
|
429
|
+
|
|
430
|
+
### 9.3 Make the framework path the convenient path
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
# Test harness — uses the worker's declared/effective policy by default
|
|
434
|
+
outputs = Shifty::Testing.run(worker, inputs: [a, b, c])
|
|
435
|
+
|
|
436
|
+
# Explicit override for policy-matrix testing
|
|
437
|
+
outputs = Shifty::Testing.run(worker, inputs: [a], policy: :frozen)
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
RSpec sugar:
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
it_behaves_like "a policy-safe worker" # runs task against deeply frozen input
|
|
444
|
+
expect(worker).not_to mutate_input # built on the mutation detector (§6.4)
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### 9.4 Test at the ceiling (monotonicity)
|
|
448
|
+
|
|
449
|
+
Policy safety is (almost) a one-way ratchet: a task correct on deeply frozen input is
|
|
450
|
+
correct under every policy; the reverse does not hold. Therefore the harness default
|
|
451
|
+
— and the documented guidance — is to test under `:frozen` unless the worker
|
|
452
|
+
explicitly declares mutation as part of its design. Consequences:
|
|
453
|
+
|
|
454
|
+
- Loosening a pipeline's policy can never break a ceiling-tested worker.
|
|
455
|
+
- Tightening is the only breaking direction, and tightening fails loudly with
|
|
456
|
+
`PolicyViolation` diagnostics.
|
|
457
|
+
|
|
458
|
+
**Caveat (the "almost"):** `:isolated` hands the task a *copy*, so a worker
|
|
459
|
+
depending on object **identity** (rare; conceivable with identity-keyed caches)
|
|
460
|
+
behaves differently there. This is the one spot where strict-passing does not imply
|
|
461
|
+
lax-passing; document it.
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## 10. Migration Guide (Breaking Changes)
|
|
466
|
+
|
|
467
|
+
### 10.1 What breaks
|
|
468
|
+
|
|
469
|
+
1. **Default behavior changes** from raw shared references (`:shared`) to deeply
|
|
470
|
+
frozen references (`:frozen`). Any task that mutates its input raises
|
|
471
|
+
`PolicyViolation` on first contact.
|
|
472
|
+
2. **Unshareable values** (IO handles, sockets, procs, lazy enumerators, objects
|
|
473
|
+
with singleton methods) can no longer cross a default-policy boundary; they raise
|
|
474
|
+
`UnshareableValue` at handoff. These pipelines must declare `:shared` on the
|
|
475
|
+
affected workers.
|
|
476
|
+
3. **`:hardened` is deprecated**, mapped to `:isolated` with a deprecation warning
|
|
477
|
+
for one major version, then removed. Semantics are preserved (deep private copy);
|
|
478
|
+
the mechanism changes from Marshal round-trip to
|
|
479
|
+
`make_shareable(copy: true)` where available. Note: the two mechanisms reject
|
|
480
|
+
slightly different sets of objects (their failure sets overlap but are not
|
|
481
|
+
identical) — this is covered by specs (§13) and called out in the CHANGELOG.
|
|
482
|
+
|
|
483
|
+
### 10.2 Migration paths, in order of preference
|
|
484
|
+
|
|
485
|
+
1. **Rewrite tasks non-destructively** (§7). Usually a small diff (`map!`→`map`,
|
|
486
|
+
`<<`→`+`, `merge!`→`merge`); the `PolicyViolation` message names the object and
|
|
487
|
+
the worker.
|
|
488
|
+
2. **Declare `:isolated`** on mutation-heavy workers not worth rewriting. Correctness
|
|
489
|
+
preserved, cost localized to those workers.
|
|
490
|
+
3. **Declare `:shared`** where pass-by-reference is genuinely required (uncopyable
|
|
491
|
+
values, intentional shared mutation). This restores today's exact semantics per
|
|
492
|
+
worker.
|
|
493
|
+
4. **Blanket opt-out** for large legacy codebases:
|
|
494
|
+
`Shifty.configure { |c| c.default_policy = :shared }` reproduces current behavior
|
|
495
|
+
globally, letting teams migrate worker-by-worker.
|
|
496
|
+
|
|
497
|
+
### 10.3 Migration tooling
|
|
498
|
+
|
|
499
|
+
Run the mutation detector (§6.4) across an existing suite *before* upgrading: it
|
|
500
|
+
inventories which workers mutate input under the old default, i.e. exactly which
|
|
501
|
+
workers need path 1, 2, or 3.
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## 11. Edge Cases and Open Questions
|
|
506
|
+
|
|
507
|
+
1. **Unshareable values under `:frozen`/`:isolated`** — raise `UnshareableValue`
|
|
508
|
+
with guidance (declare `:shared`, or restructure). Should common cases (e.g.
|
|
509
|
+
`Enumerator::Lazy`) get tailored messages? *(Nice-to-have.)*
|
|
510
|
+
2. **Object identity under `:isolated`** — copies break identity-based logic (§9.4).
|
|
511
|
+
Documented; no mitigation planned.
|
|
512
|
+
3. **`shareable?` fast path for `:isolated`** — deferred; see §8.3.
|
|
513
|
+
4. **May a pipeline *forbid* worker-level `:shared`?** — e.g. a "strict Gang" for
|
|
514
|
+
untrusted workers. Initial answer: no; worker contract wins (§5.2). Revisit if a
|
|
515
|
+
concrete need appears.
|
|
516
|
+
5. **Frozen values and `#with`-less objects** — plain objects lack `#with`. Guidance:
|
|
517
|
+
prefer `Data`; otherwise `clone(freeze: true)` + constructor patterns. Should
|
|
518
|
+
Shifty ship a tiny `Shifty::Value` mixin? *(Probably not; stay unopinionated.)*
|
|
519
|
+
6. **Ractor future** — `:frozen`'s shareable values are exactly what Ractor
|
|
520
|
+
boundaries require. This change is deliberately Ractor-*compatible* but
|
|
521
|
+
Ractor-based workers remain out of scope. Fibers cannot cross Ractors; a
|
|
522
|
+
Ractor-backed worker would be a distinct worker type in a future effort.
|
|
523
|
+
7. **Ruby version floor** — `Ractor.make_shareable` requires Ruby ≥ 3.0;
|
|
524
|
+
`FrozenError#receiver` requires ≥ 2.7; `Data` idioms require ≥ 3.2. Proposal: set
|
|
525
|
+
the gem's floor at 3.2 for this major version (aligns docs, `Data`, and mature
|
|
526
|
+
`make_shareable` behavior). Marshal fallback then exists only for exotic
|
|
527
|
+
platforms lacking Ractor support, if any are still targeted.
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## 12. Out of Scope
|
|
532
|
+
|
|
533
|
+
- **Static/strong typing** (Sorbet, RBS): would extend data-space guarantees further,
|
|
534
|
+
but is an orthogonal, separate developer choice. Nothing in this design should
|
|
535
|
+
preclude it; `Data`-based envelopes are Sorbet-friendly if users go there.
|
|
536
|
+
- **Ractor-based parallel workers**: this change lays the groundwork (shareable
|
|
537
|
+
values by default) but introduces no Ractors.
|
|
538
|
+
- **Persistent data structures as a dependency**: immutable-ruby remains a
|
|
539
|
+
documented user-side optimization only.
|
|
540
|
+
- **Enforcing purity of closure state**: worker-internal state is intentionally
|
|
541
|
+
mutable; that is Shifty's model. Only *handoffs* are governed.
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
## 13. Implementation Plan
|
|
546
|
+
|
|
547
|
+
**Phase 1 — mechanism**
|
|
548
|
+
- Handoff policy plumbing: worker attribute, pipeline default, global config,
|
|
549
|
+
precedence resolution.
|
|
550
|
+
- `:frozen` (make_shareable), `:isolated` (make_shareable copy, Marshal fallback),
|
|
551
|
+
`:shared` implementations at the handoff site.
|
|
552
|
+
- `PolicyViolation` / `UnshareableValue` wrapping with receiver heuristics and
|
|
553
|
+
worker `name:` support.
|
|
554
|
+
- Specs: policy matrix × value shapes, including the Marshal-vs-make_shareable
|
|
555
|
+
failure-set edge cases (procs, IO, singleton methods, lazy enumerators).
|
|
556
|
+
|
|
557
|
+
**Phase 2 — diagnostics & testing**
|
|
558
|
+
- Mutation detector mode.
|
|
559
|
+
- `Shifty::Testing.run` harness; RSpec shared examples and `mutate_input` matcher.
|
|
560
|
+
- Build-time `PolicyConflict` validation at composition.
|
|
561
|
+
|
|
562
|
+
**Phase 3 — performance & polish**
|
|
563
|
+
- Benchmark suite (§8.4); publish results in docs.
|
|
564
|
+
- Decide `shareable?` fast-path question (§8.3) from benchmark data.
|
|
565
|
+
- `#freeze!` for pipeline topology (§5.4).
|
|
566
|
+
|
|
567
|
+
**Phase 4 — release**
|
|
568
|
+
- Migration guide, CHANGELOG, README rewrite of the side-worker/hardened sections.
|
|
569
|
+
- Reconcile the concurrency documentation introduced by PR #26
|
|
570
|
+
(`README.md` "Concurrency Model", `docs/use_cases.md`, and the thread-safety
|
|
571
|
+
comments in `lib/shifty/worker.rb`): the interim edits there forward-reference
|
|
572
|
+
this plan, but on release they must be updated from "planned" to shipped —
|
|
573
|
+
fold copy-on-write idioms (§7) into `use_cases.md`'s examples, and confirm the
|
|
574
|
+
single-threading/immutability boundary and Ractor-compatibility framing (§11.6)
|
|
575
|
+
are stated as current behavior rather than future work.
|
|
576
|
+
- `:hardened` deprecation shim.
|
|
577
|
+
- Major version release.
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
## Appendix A — One-paragraph rationale (for README)
|
|
582
|
+
|
|
583
|
+
> Shifty workers are easy to reason about because each one is isolated; the values
|
|
584
|
+
> flowing *between* them were, until now, the un-governed part of the system. As of
|
|
585
|
+
> vNEXT, values are deeply frozen at every handoff by default. Workers that need a
|
|
586
|
+
> private scratch copy can declare `policy: :isolated`; workers that genuinely need
|
|
587
|
+
> shared mutable references can declare `policy: :shared`. Mutation bugs that used to
|
|
588
|
+
> surface as mysterious downstream corruption now either cannot happen or raise
|
|
589
|
+
> immediately at the worker responsible — with an error message that tells you
|
|
590
|
+
> exactly what to do about it.
|
data/docs/shifty/worker.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
> This page covers the raw `Shifty::Worker` API. The
|
|
2
|
+
> [wiki's Worker-Types page](https://github.com/joelhelbling/shifty/wiki/Worker-Types)
|
|
3
|
+
> covers every DSL worker and their handoff-policy behavior.
|
|
4
|
+
|
|
1
5
|
# Shifty::Worker
|
|
2
6
|
|
|
3
7
|
_The workhorse of the Shifty framework._
|
|
@@ -31,25 +35,10 @@ relay_worker = Shifty::Worker.new(task: capitalizer, supply: source_worker)
|
|
|
31
35
|
relay_worker.shift #=> 'Hulk'
|
|
32
36
|
```
|
|
33
37
|
|
|
34
|
-
A worker
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
relay_worker.task = doofusizer
|
|
39
|
-
|
|
40
|
-
relay_worker.shift #=> 'hoolk'
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
And finally, you can provide a task by directly overriding the
|
|
44
|
-
worker's #task instance method:
|
|
45
|
-
|
|
46
|
-
```ruby
|
|
47
|
-
def relay_worker.task(name)
|
|
48
|
-
name.to_sym
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
relay_worker.shift #=> :hulk
|
|
52
|
-
```
|
|
38
|
+
A worker's task is fixed at construction — there is no `task=`
|
|
39
|
+
accessor. (And as of 0.6.0, `#freeze!` can lock the rest of the
|
|
40
|
+
topology down too, so the pipeline you composed is the pipeline
|
|
41
|
+
that runs.)
|
|
53
42
|
|
|
54
43
|
Even workers without a task have a task; all workers actually come
|
|
55
44
|
with a default task which simply passes on the received value unchanged:
|