octorail 1.0.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 +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/lib/octorail/compiler/code.rb +55 -0
- data/lib/octorail/compiler/nested_cycles.rb +34 -0
- data/lib/octorail/compiler/nested_emitter.rb +33 -0
- data/lib/octorail/compiler/recovery_emitter.rb +70 -0
- data/lib/octorail/compiler/scopes.rb +113 -0
- data/lib/octorail/compiler/signature.rb +112 -0
- data/lib/octorail/compiler/step_emitter.rb +35 -0
- data/lib/octorail/compiler/switch_emitter.rb +52 -0
- data/lib/octorail/compiler/tracing.rb +56 -0
- data/lib/octorail/compiler/wrap_emitter.rb +74 -0
- data/lib/octorail/compiler.rb +160 -0
- data/lib/octorail/config.rb +35 -0
- data/lib/octorail/dsl/nested.rb +40 -0
- data/lib/octorail/dsl/outputs.rb +37 -0
- data/lib/octorail/dsl/settings.rb +34 -0
- data/lib/octorail/dsl/steps.rb +111 -0
- data/lib/octorail/dsl/switches.rb +71 -0
- data/lib/octorail/dsl/wraps.rb +50 -0
- data/lib/octorail/dsl.rb +72 -0
- data/lib/octorail/errors.rb +40 -0
- data/lib/octorail/invalidation.rb +49 -0
- data/lib/octorail/nodes/nested.rb +18 -0
- data/lib/octorail/nodes/step.rb +20 -0
- data/lib/octorail/nodes/switch.rb +25 -0
- data/lib/octorail/nodes/wrap.rb +26 -0
- data/lib/octorail/nodes.rb +21 -0
- data/lib/octorail/operation.rb +145 -0
- data/lib/octorail/result.rb +135 -0
- data/lib/octorail/runtime.rb +52 -0
- data/lib/octorail/trace/rendering.rb +110 -0
- data/lib/octorail/trace.rb +182 -0
- data/lib/octorail/version.rb +5 -0
- data/lib/octorail.rb +21 -0
- metadata +85 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
module DSL
|
|
5
|
+
# wrap: run a group of steps inside a wrapper (transaction, lock, timeout, ...).
|
|
6
|
+
module Wraps
|
|
7
|
+
def wrap(wrapper, error: nil, &block)
|
|
8
|
+
check_not_abstract!("wrap #{wrapper.inspect}")
|
|
9
|
+
check_wrap!(wrapper, error, block)
|
|
10
|
+
location = declaration_location
|
|
11
|
+
nodes = collect_nodes(&block)
|
|
12
|
+
raise ArgumentError, "#{self}: wrap #{wrapper.inspect} has no steps" if nodes.empty?
|
|
13
|
+
|
|
14
|
+
current_nodes << Nodes::Wrap.new(wrapper:, nodes:, error:, location:)
|
|
15
|
+
invalidate!
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def check_wrap!(wrapper, error, block)
|
|
22
|
+
check_wrapper!(wrapper)
|
|
23
|
+
raise ArgumentError, "#{self}: wrap #{wrapper.inspect} needs a block with steps" unless block
|
|
24
|
+
return if error.nil? || error.is_a?(Symbol)
|
|
25
|
+
|
|
26
|
+
raise ArgumentError, "#{self}: wrap #{wrapper.inspect}: error: must be a Symbol, got #{error.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def check_wrapper!(wrapper)
|
|
30
|
+
return check_name!(wrapper, "wrap") if wrapper.is_a?(Symbol)
|
|
31
|
+
if wrapper.is_a?(Class) && wrapper < Operation
|
|
32
|
+
raise ArgumentError, "#{self}: wrap #{wrapper}: is an operation; to run it as a step use nested #{wrapper}"
|
|
33
|
+
end
|
|
34
|
+
return if wrapper.respond_to?(:call)
|
|
35
|
+
|
|
36
|
+
raise ArgumentError, "#{self}: wrap: wrapper must be a method name (Symbol) or respond to .call, " \
|
|
37
|
+
"got #{wrapper.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Steps declared inside a wrap block go into a buffer instead of the class's own list.
|
|
41
|
+
def collect_nodes
|
|
42
|
+
(@__octorail_nesting ||= []).push([])
|
|
43
|
+
yield
|
|
44
|
+
@__octorail_nesting.last
|
|
45
|
+
ensure
|
|
46
|
+
@__octorail_nesting.pop
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/octorail/dsl.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dsl/steps"
|
|
4
|
+
require_relative "dsl/outputs"
|
|
5
|
+
require_relative "dsl/wraps"
|
|
6
|
+
require_relative "dsl/nested"
|
|
7
|
+
require_relative "dsl/switches"
|
|
8
|
+
require_relative "dsl/settings"
|
|
9
|
+
|
|
10
|
+
module Octorail
|
|
11
|
+
# Class-level DSL, extended into Operation. Only records declarations; compilation happens later.
|
|
12
|
+
# Each feature lives in its own module under dsl/.
|
|
13
|
+
module DSL
|
|
14
|
+
include Steps
|
|
15
|
+
include Outputs
|
|
16
|
+
include Wraps
|
|
17
|
+
include Nested
|
|
18
|
+
include Switches
|
|
19
|
+
include Settings
|
|
20
|
+
|
|
21
|
+
METHOD_NAME = /\A[a-z_][a-zA-Z0-9_]*[?!]?\z/
|
|
22
|
+
# Called bare in generated code, these would parse as syntax (`not(ctx)`, `defined?(ctx)`, `super`).
|
|
23
|
+
RUBY_KEYWORDS = %i[
|
|
24
|
+
__ENCODING__ __FILE__ __LINE__ alias and begin break case class def defined? do else elsif end ensure
|
|
25
|
+
false for if in module next nil not or redo rescue retry return self super then true undef unless
|
|
26
|
+
until when while yield
|
|
27
|
+
].freeze
|
|
28
|
+
# Frames from the gem itself are skipped when looking for where a node was declared.
|
|
29
|
+
GEM_DIR = File.expand_path("..", __dir__)
|
|
30
|
+
LOCATION_FRAMES = 50
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# An abstract operation holds shared methods and macros only; the flow is declared in the
|
|
35
|
+
# operations that inherit from it, so each shows its whole flow.
|
|
36
|
+
def check_not_abstract!(what)
|
|
37
|
+
return unless abstract?
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, "#{self}: is abstract (shared methods and macros only), so it can't declare #{what}; " \
|
|
40
|
+
"declare it in the operations that inherit from it"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# "path:line" of the DSL call outside the gem. When a macro declared it (a method called from
|
|
44
|
+
# the class body), also the class body line: "macros.rb:6 (via app/operations/x.rb:12)".
|
|
45
|
+
def declaration_location
|
|
46
|
+
frames = caller_locations(1, LOCATION_FRAMES).reject { |frame| path_of(frame).start_with?(GEM_DIR) }
|
|
47
|
+
return if frames.empty?
|
|
48
|
+
|
|
49
|
+
site = frames.first
|
|
50
|
+
body = frames.find { |frame| frame.label.to_s.start_with?("<class:", "<module:") }
|
|
51
|
+
return format_location(site) if body.nil? || body.equal?(site)
|
|
52
|
+
|
|
53
|
+
"#{format_location(site)} (via #{format_location(body)})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_location(frame)
|
|
57
|
+
"#{path_of(frame).delete_prefix("#{Dir.pwd}/")}:#{frame.lineno}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# absolute_path is nil for eval'd code; path then holds the file name given to eval.
|
|
61
|
+
def path_of(frame) = (frame.absolute_path || frame.path).to_s
|
|
62
|
+
|
|
63
|
+
def check_name!(name, kind)
|
|
64
|
+
unless name.is_a?(Symbol) && METHOD_NAME.match?(name)
|
|
65
|
+
raise ArgumentError, "#{self}: #{kind} name must be a Symbol method name, got #{name.inspect}"
|
|
66
|
+
end
|
|
67
|
+
return unless RUBY_KEYWORDS.include?(name)
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, "#{self}: #{kind} #{name.inspect} is a Ruby keyword, pick another name"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
# Every Octorail error is a mistake in code, not a business failure (those are failure results).
|
|
5
|
+
# Like NotImplementedError, they derive from ScriptError so that a bare `rescue` / `rescue
|
|
6
|
+
# StandardError` (a timeout wrapper, a controller, `retry_on StandardError`) can't swallow or
|
|
7
|
+
# retry them: they reach the top-level handler (Rails, Sidekiq, Sentry rescue Exception).
|
|
8
|
+
class Error < ScriptError; end
|
|
9
|
+
|
|
10
|
+
# Suffix for error messages about a declared node: where its step/wrap/nested line is.
|
|
11
|
+
def self.declared_at(node)
|
|
12
|
+
node&.location ? "\n declared at #{node.location}" : ""
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Key lists in error messages: ":a, :b", or "none".
|
|
16
|
+
def self.key_list(keys)
|
|
17
|
+
keys.empty? ? "none" : keys.map(&:inspect).join(", ")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Raised while compiling an operation: unsupported step signature, unknown step method, etc.
|
|
21
|
+
class CompileError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised at runtime when a step requires a keyword that ctx doesn't have.
|
|
24
|
+
class MissingKeyError < Error; end
|
|
25
|
+
|
|
26
|
+
# Raised when ctx holds a key that collides with a Result field.
|
|
27
|
+
class ReservedKeyError < Error; end
|
|
28
|
+
|
|
29
|
+
# Raised when an operation with declared outputs succeeds without setting one of them.
|
|
30
|
+
class MissingOutputError < Error; end
|
|
31
|
+
|
|
32
|
+
# Raised by `result[key]` when the operation declares outputs and `key` isn't one of them.
|
|
33
|
+
class UnknownOutputError < Error; end
|
|
34
|
+
|
|
35
|
+
# Raised when a switch decider returns a value that has no on(...) branch.
|
|
36
|
+
class UnknownBranchError < Error; end
|
|
37
|
+
|
|
38
|
+
# Raised when a step writes an instance variable: the operation instance is shared and frozen.
|
|
39
|
+
class FrozenOperationError < Error; end
|
|
40
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
# Drops compiled code when what it was compiled from changes; extended into Operation.
|
|
5
|
+
# These hooks run at code load time only.
|
|
6
|
+
module Invalidation
|
|
7
|
+
# Drops the compiled code of this operation and its descendants; their next call recompiles.
|
|
8
|
+
def invalidate!
|
|
9
|
+
COMPILE_LOCK.synchronize do
|
|
10
|
+
# The next `call` sees no instance and compiles again.
|
|
11
|
+
@__octorail_instance = nil
|
|
12
|
+
subclasses.each(&:invalidate!)
|
|
13
|
+
end
|
|
14
|
+
self
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def include(*)
|
|
18
|
+
super.tap { invalidate! }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def prepend(*)
|
|
22
|
+
super.tap { invalidate! }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def method_added(name)
|
|
28
|
+
super
|
|
29
|
+
step_method_changed(name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def method_removed(name)
|
|
33
|
+
super
|
|
34
|
+
step_method_changed(name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def method_undefined(name)
|
|
38
|
+
super
|
|
39
|
+
step_method_changed(name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A step method was (re)defined or removed: recompile every compiled operation that calls it.
|
|
43
|
+
def step_method_changed(name)
|
|
44
|
+
[self, *descendants].each do |operation|
|
|
45
|
+
operation.invalidate! if operation.compiled? && operation.step_method_names.include?(name)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
module Nodes
|
|
5
|
+
# Another operation run as a step on the same ctx (`nested Billing::Charge`).
|
|
6
|
+
Nested = Data.define(:operation, :error, :fail_fast, :location)
|
|
7
|
+
|
|
8
|
+
class Nested
|
|
9
|
+
def initialize(operation:, error: nil, fail_fast: false, location: nil)
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def type = :nested
|
|
14
|
+
|
|
15
|
+
def children = []
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
module Nodes
|
|
5
|
+
# A declared step. `type` is :step, :pass or :failure; `name` is the instance method to call;
|
|
6
|
+
# `recover` (failure only): a truthy result returns to the success track;
|
|
7
|
+
# `location` is where it was declared ("path:line"), for error messages.
|
|
8
|
+
Step = Data.define(:type, :name, :error, :fail_fast, :recover, :location)
|
|
9
|
+
|
|
10
|
+
class Step
|
|
11
|
+
def initialize(type:, name:, error: nil, fail_fast: false, recover: false, location: nil) # rubocop:disable Metrics/ParameterLists
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def children = []
|
|
16
|
+
|
|
17
|
+
def failure? = type == :failure
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
module Nodes
|
|
5
|
+
# A fork: `decider` is the instance method whose result (a Symbol) picks the branch to run.
|
|
6
|
+
Switch = Data.define(:decider, :branches, :location)
|
|
7
|
+
|
|
8
|
+
class Switch
|
|
9
|
+
def initialize(decider:, branches:, location: nil)
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def name = decider
|
|
14
|
+
|
|
15
|
+
def type = :switch
|
|
16
|
+
|
|
17
|
+
def children = branches.flat_map(&:nodes)
|
|
18
|
+
|
|
19
|
+
def keys = branches.flat_map(&:keys)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# One `on(:a, :b) { ... }` of a switch.
|
|
23
|
+
Branch = Data.define(:keys, :nodes)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
module Nodes
|
|
5
|
+
# A group of nodes run inside `wrapper`: an operation method name (Symbol) or an object
|
|
6
|
+
# responding to `.call(ctx) { }`. The wrapper yields to run the nodes (the block returns
|
|
7
|
+
# true/false) and its own return value decides whether the wrap succeeded.
|
|
8
|
+
Wrap = Data.define(:wrapper, :nodes, :error, :location)
|
|
9
|
+
|
|
10
|
+
class Wrap
|
|
11
|
+
def initialize(wrapper:, nodes:, error: nil, location: nil)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Used as failed_step when the wrapper itself fails without a step failing inside.
|
|
16
|
+
def name = wrapper.is_a?(Symbol) ? wrapper : nil
|
|
17
|
+
|
|
18
|
+
def type = :wrap
|
|
19
|
+
|
|
20
|
+
def children = nodes
|
|
21
|
+
|
|
22
|
+
# Steps and nested operations inside, at any depth, in declaration order.
|
|
23
|
+
def steps = Nodes.leaves(nodes)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "nodes/step"
|
|
4
|
+
require_relative "nodes/wrap"
|
|
5
|
+
require_relative "nodes/nested"
|
|
6
|
+
require_relative "nodes/switch"
|
|
7
|
+
|
|
8
|
+
module Octorail
|
|
9
|
+
# What the DSL records and the compiler reads. Wrap and Switch hold other nodes (`children`).
|
|
10
|
+
module Nodes
|
|
11
|
+
# Every node in `nodes` and inside them, depth-first, in declaration order.
|
|
12
|
+
def self.walk(nodes)
|
|
13
|
+
nodes.flat_map { |node| [node, *walk(node.children)] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Nodes that run something themselves (a step method or a nested operation), not containers.
|
|
17
|
+
def self.leaves(nodes)
|
|
18
|
+
walk(nodes).select { |node| node.is_a?(Step) || node.is_a?(Nested) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Octorail
|
|
6
|
+
# Guards compilation and invalidation; reentrant because compile_all! nests compile!.
|
|
7
|
+
COMPILE_LOCK = Monitor.new
|
|
8
|
+
|
|
9
|
+
class Operation
|
|
10
|
+
include Runtime
|
|
11
|
+
extend DSL
|
|
12
|
+
# Last: its `include` / `method_added` hooks expect the rest of the class to be defined.
|
|
13
|
+
extend Invalidation
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# `call` and `nested_call` are defined once, here, and never redefined: each class reads its
|
|
17
|
+
# own compiled instance (nil until compiled or after invalidation). Redefining them per class
|
|
18
|
+
# would replace a test's stub of them and break it when the stub is restored.
|
|
19
|
+
def call(**input)
|
|
20
|
+
(@__octorail_instance || __octorail_compiled_instance).__octorail_entry(input)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# One call with a :profile trace, whatever the trace setting (console, tests).
|
|
24
|
+
def call_traced(**input)
|
|
25
|
+
(@__octorail_instance || __octorail_compiled_instance).__octorail_trace_call(input, :profile)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Runs on the given ctx Hash itself (no copy): how `nested` calls an operation, and what
|
|
29
|
+
# a test stubs (or calls with a hand-built ctx) to fake or check a nested operation.
|
|
30
|
+
# `trace`: the outer trace's node, passed by a traced outer operation (docs/tracing.md);
|
|
31
|
+
# without it the plain variant runs, whatever this operation's own trace setting.
|
|
32
|
+
# `**nil`: `nested_call(id: 1)` (meant as `call`) is an ArgumentError, not a ctx Hash.
|
|
33
|
+
def nested_call(ctx, trace = nil, **nil)
|
|
34
|
+
raise ArgumentError, "#{self}.nested_call takes the ctx Hash, got #{ctx.inspect}" unless ctx.is_a?(Hash)
|
|
35
|
+
return (@__octorail_instance || __octorail_compiled_instance).__octorail_call(ctx) unless trace
|
|
36
|
+
|
|
37
|
+
__octorail_ensure_traced.__octorail_traced(ctx, trace)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Marks a base class that holds shared methods and macros, not a flow: it can't declare steps,
|
|
41
|
+
# can't be called, and compile_all! skips it. Not inherited.
|
|
42
|
+
def abstract!
|
|
43
|
+
if steps.any? || outputs.any?
|
|
44
|
+
raise ArgumentError, "#{self}: abstract! must come before any declaration, and an abstract operation " \
|
|
45
|
+
"can't inherit steps or outputs"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@__octorail_abstract = true
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def abstract? = equal?(Operation) || @__octorail_abstract == true
|
|
53
|
+
|
|
54
|
+
def compile!
|
|
55
|
+
COMPILE_LOCK.synchronize do
|
|
56
|
+
return self if compiled?
|
|
57
|
+
raise CompileError, "#{self}: is abstract, call an operation that inherits from it" if abstract?
|
|
58
|
+
|
|
59
|
+
install_compiled_code
|
|
60
|
+
install_traced_code unless setting(:trace) == :off # `call` itself runs the traced variant
|
|
61
|
+
# Set last: a non-nil instance is what makes `call` skip compilation.
|
|
62
|
+
@__octorail_instance = new.freeze
|
|
63
|
+
end
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def compiled?
|
|
68
|
+
!@__octorail_instance.nil?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def descendants
|
|
72
|
+
subclasses.flat_map { |subclass| [subclass, *subclass.descendants] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_ruby(traced: false)
|
|
76
|
+
Compiler.new(self, traced:).source
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# First call (or first after invalidation): compile, then hand back the instance.
|
|
82
|
+
def __octorail_compiled_instance
|
|
83
|
+
compile!
|
|
84
|
+
@__octorail_instance
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Both variants are generated now, from the same nodes, so they share OctorailRefs; the traced
|
|
88
|
+
# one is installed only when needed (a traced setting, call_traced, a traced outer operation).
|
|
89
|
+
def install_compiled_code
|
|
90
|
+
define_result_class
|
|
91
|
+
traced = Compiler.new(self, traced: true)
|
|
92
|
+
@__octorail_traced_source = traced.source
|
|
93
|
+
@__octorail_traced_installed = false
|
|
94
|
+
define_refs_const(traced.anonymous_refs)
|
|
95
|
+
redefine_method(Compiler::METHOD_NAME, Compiler.new(self).source, "octorail-generated:#{self}")
|
|
96
|
+
install_entry
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# With tracing on, `call` runs the traced variant (the trace level comes from the settings);
|
|
100
|
+
# off, `call` runs the plain method itself (an alias: no extra frame).
|
|
101
|
+
def install_entry
|
|
102
|
+
level = setting(:trace)
|
|
103
|
+
entry = Compiler::ENTRY_METHOD_NAME.to_sym
|
|
104
|
+
remove_method(entry) if method_defined?(entry, false)
|
|
105
|
+
return alias_method(entry, Compiler::METHOD_NAME) if level == :off
|
|
106
|
+
|
|
107
|
+
source = "def #{entry}(ctx) = __octorail_trace_call(ctx, #{level.inspect})\n"
|
|
108
|
+
class_eval(source, "octorail-entry:#{self}", 1)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def install_traced_code
|
|
112
|
+
redefine_method(Compiler::TRACED_METHOD_NAME, @__octorail_traced_source, "octorail-traced:#{self}")
|
|
113
|
+
@__octorail_traced_installed = true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The compiled instance, with the traced variant installed.
|
|
117
|
+
def __octorail_ensure_traced
|
|
118
|
+
instance = @__octorail_instance || __octorail_compiled_instance
|
|
119
|
+
return instance if @__octorail_traced_installed
|
|
120
|
+
|
|
121
|
+
COMPILE_LOCK.synchronize { install_traced_code unless @__octorail_traced_installed }
|
|
122
|
+
instance
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Removed first so redefining doesn't warn under -w.
|
|
126
|
+
def redefine_method(name, source, file)
|
|
127
|
+
name = name.to_sym
|
|
128
|
+
remove_method(name) if method_defined?(name, false) || private_method_defined?(name, false)
|
|
129
|
+
class_eval(source, file, 1)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def define_refs_const(refs)
|
|
133
|
+
const = Compiler::REFS_CONST
|
|
134
|
+
remove_const(const) if const_defined?(const, false)
|
|
135
|
+
const_set(const, refs.freeze) if refs.any?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def define_result_class
|
|
139
|
+
const = Compiler::RESULT_CONST
|
|
140
|
+
remove_const(const) if const_defined?(const, false)
|
|
141
|
+
const_set(const, Result.with_outputs(outputs)) if outputs.any?
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
# A plain class rather than Data: Data.new always goes through a keyword Hash, which made
|
|
5
|
+
# building the result cost more than all the compiled steps together.
|
|
6
|
+
class Result
|
|
7
|
+
# Result fields that must not appear as ctx keys (`:errors` is reserved with a meaning).
|
|
8
|
+
RESERVED_CTX_KEYS = %i[success terminus error failed_step].freeze
|
|
9
|
+
# Keys `result[]` refuses to read from ctx: they have dedicated readers.
|
|
10
|
+
BUILTIN_KEYS = (RESERVED_CTX_KEYS + [:errors]).freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :terminus, :ctx, :error, :failed_step
|
|
13
|
+
# The Trace of a traced call (docs/tracing.md), else nil. Not part of equality / to_h.
|
|
14
|
+
attr_reader :trace
|
|
15
|
+
|
|
16
|
+
def initialize(terminus, ctx, error = nil, failed_step = nil)
|
|
17
|
+
@terminus = terminus
|
|
18
|
+
@ctx = ctx
|
|
19
|
+
@error = error
|
|
20
|
+
@failed_step = failed_step
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def success?
|
|
24
|
+
terminus == :success
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def failure?
|
|
28
|
+
!success?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def errors
|
|
32
|
+
ctx[:errors] unless success?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def [](key)
|
|
36
|
+
if BUILTIN_KEYS.include?(key)
|
|
37
|
+
reader = key == :success ? "success?" : key
|
|
38
|
+
raise ReservedKeyError, "#{key.inspect} is a Result field, use result.#{reader}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
ctx[key]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def deconstruct_keys(keys)
|
|
45
|
+
check_reserved_keys!
|
|
46
|
+
return ctx.merge(builtin_fields) unless keys
|
|
47
|
+
|
|
48
|
+
pick(keys)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
{ terminus:, ctx:, error:, failed_step: }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ==(other)
|
|
56
|
+
other.instance_of?(self.class) && to_h == other.to_h
|
|
57
|
+
end
|
|
58
|
+
alias eql? ==
|
|
59
|
+
|
|
60
|
+
def hash
|
|
61
|
+
[self.class, *to_h.values].hash
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inspect
|
|
65
|
+
"#<#{self.class.name || "Octorail::Result"} terminus=#{terminus.inspect} error=#{error.inspect} " \
|
|
66
|
+
"failed_step=#{failed_step.inspect} ctx=#{ctx.inspect}>"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Builds the Result subclass for an operation with declared outputs:
|
|
70
|
+
# a reader per output, and `[]` / pattern matching limited to those outputs.
|
|
71
|
+
def self.with_outputs(outputs)
|
|
72
|
+
Class.new(self) do
|
|
73
|
+
const_set(:OUTPUTS, outputs.dup.freeze)
|
|
74
|
+
include Outputs
|
|
75
|
+
|
|
76
|
+
outputs.each do |name|
|
|
77
|
+
class_eval("def #{name} = ctx[#{name.inspect}]", __FILE__, __LINE__) # def model = ctx[:model]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A key outside the declared outputs is a mistake (a typo, or reaching into the operation's
|
|
83
|
+
# internals), so `[]` and pattern matching both raise for it.
|
|
84
|
+
module Outputs
|
|
85
|
+
def [](key)
|
|
86
|
+
return super if BUILTIN_KEYS.include?(key)
|
|
87
|
+
|
|
88
|
+
check_output!(key)
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Outputs can't be named like Result fields and other ctx keys are hidden,
|
|
93
|
+
# so there is nothing to collide: no reserved-key check here.
|
|
94
|
+
def deconstruct_keys(keys)
|
|
95
|
+
return pick([*BUILTIN_KEYS, *self.class::OUTPUTS]) unless keys
|
|
96
|
+
|
|
97
|
+
keys.each { |key| check_output!(key) unless BUILTIN_KEYS.include?(key) }
|
|
98
|
+
pick(keys)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def check_output!(key)
|
|
104
|
+
outputs = self.class::OUTPUTS
|
|
105
|
+
return if outputs.include?(key)
|
|
106
|
+
|
|
107
|
+
raise UnknownOutputError, "#{key.inspect} is not an output (outputs: #{Octorail.key_list(outputs)}); " \
|
|
108
|
+
"use result.ctx[#{key.inspect}] to debug"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def pick(keys)
|
|
115
|
+
keys.each_with_object({}) do |key, hash|
|
|
116
|
+
case key
|
|
117
|
+
when :success then hash[key] = success?
|
|
118
|
+
when :terminus, :error, :errors, :failed_step then hash[key] = public_send(key)
|
|
119
|
+
else hash[key] = ctx[key] if ctx.key?(key)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def builtin_fields
|
|
125
|
+
{ success: success?, terminus:, error:, errors:, failed_step: }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def check_reserved_keys!
|
|
129
|
+
clash = RESERVED_CTX_KEYS.select { |key| ctx.key?(key) }
|
|
130
|
+
return if clash.empty?
|
|
131
|
+
|
|
132
|
+
raise ReservedKeyError, "ctx keys #{Octorail.key_list(clash)} collide with Result fields; rename them"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
# Private instance helpers that generated code calls on the failure paths.
|
|
5
|
+
module Runtime
|
|
6
|
+
# A traced call: runs the traced variant into a new Trace, attaches it to the result, or to the
|
|
7
|
+
# exception that left the call (then re-raised as is).
|
|
8
|
+
def __octorail_trace_call(ctx, level)
|
|
9
|
+
self.class.__send__(:__octorail_ensure_traced)
|
|
10
|
+
trace = Trace.new(self.class, level)
|
|
11
|
+
trace.finish(__octorail_traced(ctx, trace.root))
|
|
12
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
13
|
+
trace&.fail(e)
|
|
14
|
+
raise
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Called from generated code on FrozenError: only an ivar write on this very instance is ours to explain.
|
|
20
|
+
def __octorail_frozen!(error)
|
|
21
|
+
raise error unless error.receiver.equal?(self)
|
|
22
|
+
|
|
23
|
+
raise FrozenOperationError, "#{self.class}: a step tried to set an instance variable, but operations " \
|
|
24
|
+
"are stateless (one frozen instance is shared by all calls). " \
|
|
25
|
+
"Store data in ctx, pass dependencies through ctx with keyword defaults."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Called from generated code when an operation succeeds without a declared output.
|
|
29
|
+
def __octorail_missing_output(name, ctx)
|
|
30
|
+
raise MissingOutputError, "#{self.class}: succeeded without setting output #{name.inspect} " \
|
|
31
|
+
"(ctx keys: #{Octorail.key_list(ctx.keys)})"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Called from generated code when a switch decider returns a value with no branch.
|
|
35
|
+
# `index`: the switch's position in method_nodes (the declaration is looked up only here).
|
|
36
|
+
def __octorail_unknown_branch(index, value)
|
|
37
|
+
node = self.class.method_nodes.fetch(index)
|
|
38
|
+
raise UnknownBranchError, "#{self.class}: switch #{node.decider.inspect} returned #{value.inspect}, " \
|
|
39
|
+
"branches: #{Octorail.key_list(node.keys)}#{Octorail.declared_at(node)}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Called from generated code when a step's required keyword is absent from ctx.
|
|
43
|
+
# `index`: the step's position in method_nodes, as for __octorail_unknown_branch;
|
|
44
|
+
# the step's name is passed too, only so that the generated code reads well.
|
|
45
|
+
def __octorail_missing_key(index, key, ctx, _step = nil)
|
|
46
|
+
node = self.class.method_nodes.fetch(index)
|
|
47
|
+
raise MissingKeyError, "#{self.class}: #{node.type} #{node.name.inspect} requires #{key.inspect}, " \
|
|
48
|
+
"but ctx has no #{key.inspect} (ctx keys: #{Octorail.key_list(ctx.keys)})" \
|
|
49
|
+
"#{Octorail.declared_at(node)}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|