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.
@@ -0,0 +1,124 @@
1
+ module Shifty
2
+ # Shared by Worker and Gang: declares a pipeline-level policy default on
3
+ # every node reachable upstream through the supply chain. A worker's own
4
+ # policy declaration (its contract) always wins over the pipeline default.
5
+ module PolicyDeclarable
6
+ def with_policy(policy_name)
7
+ policy_name = Policy.validate!(policy_name)
8
+ each_upstream_node { |node| node.pipeline_policy = policy_name }
9
+ self
10
+ end
11
+
12
+ # Locks the assembled topology: "the pipeline you composed is the
13
+ # pipeline that runs" becomes a guarantee. Rewiring (supply=, Gang
14
+ # append/roster mutation) raises FrozenError afterward. Worker
15
+ # closure/context state stays mutable — only the topology freezes.
16
+ #
17
+ # Call this on the pipeline's TAIL: like with_policy, it walks the
18
+ # supply chain upstream, so freezing a mid-chain node leaves
19
+ # everything downstream of it mutable. And use this, not the bare
20
+ # Object#freeze — freeze! first materializes each node's lazy task
21
+ # and Fiber; a bare freeze skips that and the first shift would
22
+ # raise FrozenError from deep inside the worker.
23
+ def freeze!
24
+ each_upstream_node { |node| node.freeze_topology_node! }
25
+ self
26
+ end
27
+
28
+ private
29
+
30
+ def each_upstream_node
31
+ node = self
32
+ seen = {}.compare_by_identity
33
+ while node.respond_to?(:pipeline_policy=) && !seen[node]
34
+ seen[node] = true
35
+ yield node
36
+ node = node.supply
37
+ end
38
+ end
39
+ end
40
+
41
+ # Handoff policies govern how a value crosses a worker boundary.
42
+ # Each policy responds to #call(value, worker:) and returns the value
43
+ # the worker's task will receive.
44
+ module Policy
45
+ # Deeply freezes the value in place (zero copies) so any mutation,
46
+ # anywhere in the pipeline, raises at the worker that attempted it.
47
+ # IO-like values are rejected proactively: Ractor.make_shareable would
48
+ # otherwise freeze a live handle in place — a process-wide side effect
49
+ # on shared resources like loggers or $stdout.
50
+ Frozen = lambda do |value, worker:|
51
+ if value.is_a?(IO)
52
+ raise UnshareableValue.new(worker: worker, policy: :frozen, value: value)
53
+ end
54
+ begin
55
+ Ractor.make_shareable(value)
56
+ rescue Ractor::Error => e
57
+ raise UnshareableValue.new(worker: worker, policy: :frozen, value: value, cause: e)
58
+ end
59
+ end
60
+
61
+ # Hands the task a private, mutable deep copy. Marshal is the mechanism
62
+ # because Ractor.make_shareable(copy: true) returns a *frozen* copy,
63
+ # which cannot satisfy the :isolated contract of a mutable scratch value.
64
+ Isolated = lambda do |value, worker:|
65
+ Marshal.load(Marshal.dump(value))
66
+ rescue TypeError => e
67
+ raise UnshareableValue.new(worker: worker, policy: :isolated, value: value, cause: e)
68
+ end
69
+
70
+ # The escape hatch: the raw reference passes through untouched.
71
+ Shared = ->(value, worker:) { value }
72
+
73
+ TABLE = {
74
+ frozen: Frozen,
75
+ isolated: Isolated,
76
+ shared: Shared
77
+ }.freeze
78
+
79
+ ALIASES = {hardened: :isolated}.freeze
80
+
81
+ class << self
82
+ def resolve(name)
83
+ TABLE.fetch(canonical(name)) do
84
+ raise ArgumentError, "unknown policy #{name.inspect}"
85
+ end
86
+ end
87
+
88
+ def canonical(name)
89
+ if ALIASES.key?(name)
90
+ replacement = ALIASES[name]
91
+ warn "[shifty] policy :#{name} is deprecated and will be " \
92
+ "removed in 1.0.0; use :#{replacement} instead."
93
+ replacement
94
+ else
95
+ name
96
+ end
97
+ end
98
+
99
+ # Canonicalizes and validates a policy name at declaration time, so
100
+ # a typo fails where it was written rather than at first shift.
101
+ def validate!(name)
102
+ canonical(name).tap do |canonical_name|
103
+ unless TABLE.key?(canonical_name)
104
+ raise ArgumentError, "unknown policy #{name.inspect}"
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ # Wraps a worker's supply so that values the task pulls directly
111
+ # (e.g. filter/batch/trailing workers calling supply.shift mid-task)
112
+ # cross the boundary under the same policy as the primary intake.
113
+ class Supply
114
+ def initialize(supply, worker)
115
+ @supply = supply
116
+ @worker = worker
117
+ end
118
+
119
+ def shift
120
+ @worker.intake(@supply.shift)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,37 @@
1
+ require "shifty/testing"
2
+
3
+ # Opt-in RSpec sugar for testing Shifty workers under handoff policies.
4
+ # `require "shifty/rspec"` from your spec_helper; assumes RSpec is loaded.
5
+
6
+ RSpec::Matchers.define :mutate_input do |input|
7
+ match do |worker|
8
+ Shifty::Testing.mutates_input?(worker, input)
9
+ end
10
+
11
+ failure_message do |worker|
12
+ "expected the worker's task to mutate its input #{input.inspect}, but it did not"
13
+ end
14
+
15
+ failure_message_when_negated do |worker|
16
+ "expected the worker's task not to mutate its input, but it mutated " \
17
+ "#{input.inspect}. It is only correct under policy :isolated (mutation " \
18
+ "stays local) or :shared (mutation is intentional); it will raise under :frozen."
19
+ end
20
+ end
21
+
22
+ # Runs the worker through the framework against deeply frozen input —
23
+ # the strictest policy — proving the task is non-destructive and therefore
24
+ # correct under every policy (§9.4, "test at the ceiling").
25
+ #
26
+ # Expects `worker` and `safe_input` to be defined with `let`.
27
+ RSpec.shared_examples "a policy-safe worker" do
28
+ it "processes a deeply frozen input without raising" do
29
+ expect {
30
+ Shifty::Testing.run(worker, inputs: [safe_input], policy: :frozen)
31
+ }.not_to raise_error
32
+ end
33
+
34
+ it "does not mutate its input" do
35
+ expect(worker).not_to mutate_input(safe_input)
36
+ end
37
+ end
@@ -0,0 +1,82 @@
1
+ require "shifty"
2
+
3
+ module Shifty
4
+ # Test harness: runs a worker through the framework so unit tests
5
+ # exercise the same handoff policy the production pipeline will.
6
+ # Deliberately not loaded by `require "shifty"` — opt in with
7
+ # `require "shifty/testing"`.
8
+ module Testing
9
+ class << self
10
+ # Feeds the inputs to the worker via a source and collects its
11
+ # outputs until the end-of-stream sentinel (nil). The worker's
12
+ # declared/effective policy governs each handoff, exactly as in
13
+ # production; pass policy: to override it for policy-matrix tests.
14
+ def run(worker, inputs:, policy: nil, max_shifts: 10_000)
15
+ harness(worker, policy) do |subject|
16
+ subject.supply = source_for(inputs)
17
+ outputs = []
18
+ shifts = 0
19
+ until (value = subject.shift).nil?
20
+ outputs << value
21
+ if (shifts += 1) > max_shifts
22
+ raise Error, "Shifty::Testing.run exceeded #{max_shifts} shifts " \
23
+ "without seeing the nil end-of-stream sentinel. The worker's " \
24
+ "task probably converts nil into a non-nil value; let nil " \
25
+ "pass through (e.g. `value && ...`), or raise max_shifts:."
26
+ end
27
+ end
28
+ outputs
29
+ end
30
+ end
31
+
32
+ # The mutation detector (§6.4): hands the task a private mutable
33
+ # deep copy and reports whether the task changed it — surfacing
34
+ # mutation even when the current policy permits or hides it.
35
+ def mutates_input?(worker, input)
36
+ copy = begin
37
+ Marshal.load(Marshal.dump(input))
38
+ rescue TypeError => e
39
+ raise Error, "Shifty::Testing.mutates_input? needs a deep-copyable " \
40
+ "(Marshal-dumpable) input, but got an instance of #{input.class} " \
41
+ "(#{e.message})."
42
+ end
43
+ baseline = Marshal.dump(copy)
44
+ harness(worker, :shared) do |subject|
45
+ subject.supply = source_for([copy])
46
+ subject.shift
47
+ end
48
+ Marshal.dump(copy) != baseline
49
+ end
50
+
51
+ private
52
+
53
+ # Temporarily rewires the caller's actual worker (a dup would defeat
54
+ # task closures that reference their own worker, e.g. side_worker's
55
+ # policy check) and restores its policy, supply, and Fiber afterward,
56
+ # so the harness never leaves a mark on the worker under test.
57
+ def harness(worker, policy)
58
+ saved = {
59
+ policy: worker.instance_variable_get(:@policy),
60
+ supply: worker.instance_variable_get(:@supply),
61
+ fiber: worker.instance_variable_get(:@my_little_machine)
62
+ }
63
+ worker.instance_variable_set(:@policy, Policy.validate!(policy)) if policy
64
+ worker.instance_variable_set(:@my_little_machine, nil)
65
+ worker.instance_variable_set(:@supply, nil)
66
+ yield worker
67
+ ensure
68
+ worker.instance_variable_set(:@policy, saved[:policy])
69
+ worker.instance_variable_set(:@supply, saved[:supply])
70
+ worker.instance_variable_set(:@my_little_machine, saved[:fiber])
71
+ end
72
+
73
+ def source_for(inputs)
74
+ series = inputs.dup
75
+ Worker.new(tags: [:source]) do
76
+ series.each { |input| Fiber.yield input }
77
+ loop { Fiber.yield nil }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -1,3 +1,3 @@
1
1
  module Shifty
2
- VERSION = "0.5.0"
2
+ VERSION = "0.6.0"
3
3
  end
data/lib/shifty/worker.rb CHANGED
@@ -2,22 +2,60 @@ require "ostruct"
2
2
  require "shifty/taggable"
3
3
 
4
4
  module Shifty
5
+ # Shifty::Worker uses Ruby Fibers for cooperative multitasking.
6
+ # This results in a single-threaded execution model where workers
7
+ # explicitly yield control to one another. This model is chosen for its
8
+ # simplicity and suitability for creating chainable data processing
9
+ # pipelines, where each worker performs a specific task and passes
10
+ # its output to the next worker in the chain.
5
11
  class Worker
6
- attr_reader :supply, :tags
12
+ attr_reader :supply, :tags, :name
13
+ attr_accessor :pipeline_policy
7
14
 
8
15
  include Shifty::Taggable
16
+ include Shifty::PolicyDeclarable
9
17
 
10
18
  def initialize(p = {}, &block)
11
19
  @supply = p[:supply]
12
20
  @task = block || p[:task]
13
21
  @context = p[:context] || OpenStruct.new
22
+ @policy = Policy.validate!(p[:policy]) if p[:policy]
23
+ @name = p[:name]
14
24
  self.criteria = p[:criteria]
15
25
  self.tags = p[:tags]
16
26
  end
17
27
 
28
+ def effective_policy
29
+ @policy || pipeline_policy || Shifty.config.default_policy
30
+ end
31
+
32
+ # Applies this worker's effective policy to a value crossing its
33
+ # boundary. Public because Policy::Supply routes a task's own
34
+ # supply.shift calls back through it.
35
+ def intake(value)
36
+ Policy.resolve(effective_policy).call(value, worker: self)
37
+ end
38
+
18
39
  def shift
19
40
  ensure_ready_to_work!
20
41
  workflow.resume
42
+ rescue PolicyError
43
+ # The raising Fiber is terminated and can never be resumed; discard
44
+ # it so a caller that rescues the violation can keep shifting.
45
+ # Closure and context state survive — only the loop restarts.
46
+ # (A #freeze!-d worker can't rebuild its Fiber, so a violation
47
+ # there ends the pipeline — the topology guarantee wins.)
48
+ @my_little_machine = nil unless frozen?
49
+ raise
50
+ end
51
+
52
+ # Materializes lazy state (default task, the Fiber) so that freezing
53
+ # this instance cannot surprise it later with a FrozenError on ivar
54
+ # assignment mid-shift, then freezes it.
55
+ def freeze_topology_node!
56
+ ensure_ready_to_work!
57
+ workflow
58
+ freeze
21
59
  end
22
60
 
23
61
  def ready_to_work?
@@ -50,11 +88,18 @@ module Shifty
50
88
  end
51
89
 
52
90
  def workflow
91
+ # This is the core of the worker's execution, managed by a Fiber.
92
+ # The Fiber allows the worker to pause its execution (yield) and
93
+ # be resumed later, enabling cooperative multitasking.
94
+ #
95
+ # Handoff policy is applied at intake — the moment this worker pulls
96
+ # a value across its boundary — which is the single seam every value
97
+ # crosses regardless of how many times an upstream task yielded.
53
98
  @my_little_machine ||= Fiber.new {
54
99
  loop do
55
- value = supply&.shift
100
+ value = intake(supply&.shift)
56
101
  if criteria_passes?
57
- Fiber.yield @task.call(value, supply, @context)
102
+ Fiber.yield perform_task(value)
58
103
  else
59
104
  Fiber.yield value
60
105
  end
@@ -62,6 +107,22 @@ module Shifty
62
107
  }
63
108
  end
64
109
 
110
+ def perform_task(value)
111
+ @task.call(value, policy_supply, @context)
112
+ rescue FrozenError => e
113
+ raise PolicyViolation.new(
114
+ worker: self,
115
+ policy: effective_policy,
116
+ receiver: e.receiver,
117
+ value: value,
118
+ cause: e
119
+ )
120
+ end
121
+
122
+ def policy_supply
123
+ supply && Policy::Supply.new(supply, self)
124
+ end
125
+
65
126
  def default_task
66
127
  proc { |value| value }
67
128
  end
@@ -77,7 +138,49 @@ module Shifty
77
138
  def task_method_accepts_a_value?
78
139
  method(:task).arity > 0
79
140
  end
80
- end
81
141
 
82
- class WorkerError < StandardError; end
142
+ # ## Concurrency and Thread Safety
143
+ #
144
+ # ### Concurrency Model
145
+ # Shifty utilizes Ruby Fibers to achieve cooperative multitasking. This means
146
+ # that workers voluntarily yield control, allowing other workers to execute.
147
+ # The entire processing pipeline runs within a single thread, which removes
148
+ # an entire class of *preemptive* concurrency hazards (races on shared
149
+ # objects, the need for mutexes around Shifty's own state).
150
+ #
151
+ # Note that single-threading does NOT make the data flowing between workers
152
+ # safe on its own: because each value passes through every worker before the
153
+ # next value begins, a worker that mutates a handed-off value could silently
154
+ # corrupt what downstream workers observe. That hazard is orthogonal to
155
+ # threading and is governed by handoff immutability policies: values are
156
+ # deeply frozen at intake by default (:frozen), with :isolated and :shared
157
+ # opt-outs per worker/pipeline/global (see lib/shifty/policy.rb and the
158
+ # project wiki's Handoff-Policies page).
159
+ #
160
+ # ### Alternatives (Threads/Ractors)
161
+ # Threads and Ractors are not used for parallelism *yet*. Shifty's current
162
+ # goal is an easy-to-use framework for sequential data pipelines, and a
163
+ # single-threaded Fiber model serves that directly without the overhead of
164
+ # mutexes (Threads) or the sharing restrictions of Ractors. This is a
165
+ # scoping decision, not a rejection: the default :frozen policy's deeply
166
+ # frozen, shareable handoff values are deliberately Ractor-compatible and
167
+ # lay the groundwork for a future Ractor-backed worker type. (Fibers cannot
168
+ # cross Ractor boundaries, so such a worker would be a distinct type, not a
169
+ # retrofit of this one.)
170
+ #
171
+ # ### Thread Safety
172
+ # `Shifty::Worker` instances, and by extension `Shifty::Gang` or
173
+ # `Shifty::Roster` instances that manage these workers, are **not**
174
+ # inherently thread-safe if they are shared and modified across multiple
175
+ # user-created native threads. If users choose to integrate Shifty components
176
+ # into a multi-threaded application (e.g., processing multiple independent
177
+ # Shifty pipelines in parallel using separate Threads), they are responsible
178
+ # for implementing appropriate synchronization mechanisms (like mutexes)
179
+ # to protect shared Shifty objects from concurrent access and modification.
180
+ # (Handoff immutability policies govern only the *values* passed between
181
+ # workers, not the worker/pipeline objects themselves or their closure
182
+ # state — those remain the user's responsibility across threads.)
183
+ # For typical use cases where a Shifty pipeline is built and run, no
184
+ # external threading is usually involved by the user.
185
+ end
83
186
  end
data/lib/shifty.rb CHANGED
@@ -1,4 +1,7 @@
1
1
  require_relative "shifty/version"
2
+ require_relative "shifty/errors"
3
+ require_relative "shifty/configuration"
4
+ require_relative "shifty/policy"
2
5
  require_relative "shifty/worker"
3
6
  require_relative "shifty/gang"
4
7
  require_relative "shifty/roster"
data/shifty.gemspec CHANGED
@@ -20,10 +20,15 @@ Gem::Specification.new do |spec|
20
20
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
21
  spec.require_paths = ["lib"]
22
22
 
23
+ spec.required_ruby_version = ">= 3.2"
24
+
25
+ spec.add_dependency "ostruct"
26
+
23
27
  spec.add_development_dependency "rake"
24
28
  spec.add_development_dependency "rspec", "~> 3.9"
25
29
  spec.add_development_dependency "rspec-given", "~> 3.8"
26
30
  spec.add_development_dependency "pry"
27
31
  spec.add_development_dependency "standard", ">= 1.28.2"
28
- spec.add_development_dependency "codeclimate-test-reporter"
32
+ spec.add_development_dependency "simplecov"
33
+ spec.add_development_dependency "benchmark-ips"
29
34
  end
metadata CHANGED
@@ -1,15 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shifty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joel Helbling
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2023-05-19 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ostruct
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
13
26
  - !ruby/object:Gem::Dependency
14
27
  name: rake
15
28
  requirement: !ruby/object:Gem::Requirement
@@ -81,7 +94,21 @@ dependencies:
81
94
  - !ruby/object:Gem::Version
82
95
  version: 1.28.2
83
96
  - !ruby/object:Gem::Dependency
84
- name: codeclimate-test-reporter
97
+ name: simplecov
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: benchmark-ips
85
112
  requirement: !ruby/object:Gem::Requirement
86
113
  requirements:
87
114
  - - ">="
@@ -106,20 +133,43 @@ files:
106
133
  - ".gitignore"
107
134
  - ".rspec"
108
135
  - ".standard.yml"
136
+ - CHANGELOG.md
109
137
  - Gemfile
110
138
  - Guardfile
111
139
  - LICENSE.txt
112
140
  - README.md
113
141
  - Rakefile
114
142
  - _config.yml
143
+ - benchmark/RESULTS.md
144
+ - benchmark/handoff_policies.rb
115
145
  - bin/console
116
146
  - bin/setup
147
+ - docs/planning/artifacts/.discovery-notes.md
148
+ - docs/planning/artifacts/.round1-specialist-outputs.md
149
+ - docs/planning/artifacts/implementation-decision-log.md
150
+ - docs/planning/artifacts/implementation-iteration-history.md
151
+ - docs/planning/feature-implementation-plan.md
152
+ - docs/planning/handoff-immutability-policies.md
117
153
  - docs/shifty/worker.md
154
+ - docs/use_cases.md
155
+ - docs/wiki/Coding-Idioms-Under-Frozen.md
156
+ - docs/wiki/Handoff-Policies.md
157
+ - docs/wiki/Home.md
158
+ - docs/wiki/Migration-Guide-0.6.md
159
+ - docs/wiki/Performance.md
160
+ - docs/wiki/Testing-Workers.md
161
+ - docs/wiki/Worker-Types.md
162
+ - docs/wiki/_Sidebar.md
118
163
  - lib/shifty.rb
164
+ - lib/shifty/configuration.rb
119
165
  - lib/shifty/dsl.rb
166
+ - lib/shifty/errors.rb
120
167
  - lib/shifty/gang.rb
168
+ - lib/shifty/policy.rb
121
169
  - lib/shifty/roster.rb
170
+ - lib/shifty/rspec.rb
122
171
  - lib/shifty/taggable.rb
172
+ - lib/shifty/testing.rb
123
173
  - lib/shifty/version.rb
124
174
  - lib/shifty/worker.rb
125
175
  - pkg/.gitkeep
@@ -128,7 +178,6 @@ homepage: https://github.com/joelhelbling/shifty
128
178
  licenses:
129
179
  - MIT
130
180
  metadata: {}
131
- post_install_message:
132
181
  rdoc_options: []
133
182
  require_paths:
134
183
  - lib
@@ -136,15 +185,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
136
185
  requirements:
137
186
  - - ">="
138
187
  - !ruby/object:Gem::Version
139
- version: '0'
188
+ version: '3.2'
140
189
  required_rubygems_version: !ruby/object:Gem::Requirement
141
190
  requirements:
142
191
  - - ">="
143
192
  - !ruby/object:Gem::Version
144
193
  version: '0'
145
194
  requirements: []
146
- rubygems_version: 3.3.3
147
- signing_key:
195
+ rubygems_version: 4.0.15
148
196
  specification_version: 4
149
197
  summary: A functional framework aimed at extremely low coupling
150
198
  test_files: []