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,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
class Trace
|
|
5
|
+
# Trace#to_s: the path as a tree.
|
|
6
|
+
#
|
|
7
|
+
# Orders::Checkout 12.4 ms
|
|
8
|
+
# ├── ✓ validate 0.1 ms
|
|
9
|
+
# ├── switch flow_for → :admin
|
|
10
|
+
# │ └── ✗ grant_all
|
|
11
|
+
# └── failure error=:failure failed_step=:grant_all
|
|
12
|
+
class Rendering
|
|
13
|
+
MARKS = {
|
|
14
|
+
success: "✓", failure: "✗", ran: "·", recovered: "↺", not_recovered: "↺",
|
|
15
|
+
failed: "✗", raised: "!"
|
|
16
|
+
}.freeze
|
|
17
|
+
KINDS = { nested: "nested ", failure: "failure " }.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(trace, lines = [])
|
|
20
|
+
@trace = trace
|
|
21
|
+
@lines = lines
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_s
|
|
25
|
+
@lines << with_duration(@trace.root.name, @trace.duration)
|
|
26
|
+
render_body("")
|
|
27
|
+
"#{@lines.join("\n")}\n"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The tree under the root line; also used for a trace carried in from a separate call.
|
|
31
|
+
def render_body(indent)
|
|
32
|
+
nodes = @trace.root.children
|
|
33
|
+
last = summary? ? nil : nodes.size - 1
|
|
34
|
+
nodes.each_with_index { |node, index| render(node, indent, index == last, []) }
|
|
35
|
+
@lines << "#{indent}└── #{summary}" if summary?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def render(node, indent, last, wraps)
|
|
41
|
+
@lines << "#{indent}#{branch_mark(last)}#{with_duration(label(node, wraps), node.duration)}"
|
|
42
|
+
render_children(node, indent + child_mark(last), node.type == :wrap ? [*wraps, node] : wraps)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def render_children(node, indent, wraps)
|
|
46
|
+
render_inner(node.inner, indent) if node.inner
|
|
47
|
+
node.children.each_with_index do |child, index|
|
|
48
|
+
render(child, indent, index == node.children.size - 1, wraps)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def branch_mark(last) = last ? "└── " : "├── "
|
|
53
|
+
def child_mark(last) = last ? " " : "│ "
|
|
54
|
+
|
|
55
|
+
def render_inner(inner, indent)
|
|
56
|
+
@lines << "#{indent}└── #{with_duration(inner.root.name, inner.duration)}"
|
|
57
|
+
Rendering.new(inner, @lines).render_body("#{indent} ")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def label(node, wraps)
|
|
61
|
+
case node.type
|
|
62
|
+
when :switch then "switch #{node.name}#{" → #{node.branch.inspect}" if node.taken?}#{suffix(node, wraps)}"
|
|
63
|
+
when :wrap then "wrap #{node.name} → #{wrap_result(node)}"
|
|
64
|
+
when :recover then "#{mark(node)} recover #{node.name} (#{node.outcome.to_s.tr("_", " ")})"
|
|
65
|
+
else plain_label(node, wraps)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def plain_label(node, wraps)
|
|
70
|
+
"#{mark(node)} #{KINDS[node.type]}#{node.name}#{suffix(node, wraps) unless node.type == :nested}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def mark(node) = MARKS.fetch(node.outcome, "?")
|
|
74
|
+
|
|
75
|
+
def wrap_result(node)
|
|
76
|
+
node.outcome == :raised ? "raised" : node.value.inspect
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def suffix(node, wraps = [])
|
|
80
|
+
case node.outcome
|
|
81
|
+
when :raised then " (raised #{(node.exception || @trace.exception).class}#{rescuer(wraps)})"
|
|
82
|
+
when :failed then " (left on failure)"
|
|
83
|
+
else ""
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A raise that didn't leave the call was rescued by the innermost enclosing wrap that ended normally.
|
|
88
|
+
def rescuer(wraps)
|
|
89
|
+
wrap = wraps.reverse.find { |node| node.outcome != :raised }
|
|
90
|
+
wrap ? ", rescued by #{wrap.name}" : ""
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def summary?
|
|
94
|
+
@trace.exception || @trace.terminus
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def summary
|
|
98
|
+
return "raised #{@trace.exception.class}: #{@trace.exception.message}" if @trace.exception
|
|
99
|
+
|
|
100
|
+
details = [("error=#{@trace.error.inspect}" if @trace.error),
|
|
101
|
+
("failed_step=#{@trace.failed_step.inspect}" if @trace.failed_step)].compact
|
|
102
|
+
[@trace.terminus, *details].join(" ")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def with_duration(text, seconds)
|
|
106
|
+
seconds ? "#{text} #{Trace.ms(seconds)} ms" : text
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octorail
|
|
4
|
+
# The path one traced call took through an operation (docs/tracing.md). Built by the traced
|
|
5
|
+
# variant of the generated code: every step, wrap, switch and nested operation becomes a Node.
|
|
6
|
+
# Holds names, outcomes, branch keys and durations only, never ctx values.
|
|
7
|
+
class Trace
|
|
8
|
+
LEVELS = %i[off path profile].freeze
|
|
9
|
+
|
|
10
|
+
# One traced node. Containers (wrap, switch, nested, the operation itself) hold child nodes.
|
|
11
|
+
class Node
|
|
12
|
+
NONE = [].freeze
|
|
13
|
+
private_constant :NONE
|
|
14
|
+
|
|
15
|
+
# `value`: true / false only, what the node returned taken as a condition (never the object
|
|
16
|
+
# itself: it may hold ctx data, and must not stop the exception carrying the trace from
|
|
17
|
+
# being serialized). `exception`: what a step raised. `inner`: the trace of a separate
|
|
18
|
+
# traced operation that step called, carried in by the exception.
|
|
19
|
+
attr_reader :type, :name, :value, :branch, :duration, :exception, :inner
|
|
20
|
+
|
|
21
|
+
def initialize(type, name, profile)
|
|
22
|
+
@type = type
|
|
23
|
+
@name = name
|
|
24
|
+
@profile = profile
|
|
25
|
+
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC) if profile
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Generated code for a step call: `trace.run(:step, :charge) { charge(ctx) }`.
|
|
29
|
+
# A raise is recorded on the step's own node, even if a wrapper rescues it later.
|
|
30
|
+
def run(type, name)
|
|
31
|
+
node = start(type, name)
|
|
32
|
+
value = yield
|
|
33
|
+
node.close(value)
|
|
34
|
+
value
|
|
35
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
36
|
+
node&.raised(e)
|
|
37
|
+
raise
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Generated code for a container: `__tw1 = trace.start(:wrap, "Transaction")`, then `done`.
|
|
41
|
+
def start(type, name)
|
|
42
|
+
node = Node.new(type, name, @profile)
|
|
43
|
+
(@children ||= []) << node
|
|
44
|
+
node
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Only containers get an array: steps are leaves.
|
|
48
|
+
def children = @children || NONE
|
|
49
|
+
|
|
50
|
+
# Records what the node returned and passes it through unchanged.
|
|
51
|
+
def done(node, value)
|
|
52
|
+
node.close(value)
|
|
53
|
+
value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The switch decider's result: recorded, then passed through to `case`.
|
|
57
|
+
def taken(value)
|
|
58
|
+
@taken = true
|
|
59
|
+
@branch = value
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# False when the switch decider raised.
|
|
64
|
+
def taken? = @taken == true
|
|
65
|
+
|
|
66
|
+
# Children still open were left by a failure exit (`next false` in a wrap, a nested
|
|
67
|
+
# operation's `return`): they end here, not when the whole call ends.
|
|
68
|
+
def close(value)
|
|
69
|
+
return if closed?
|
|
70
|
+
|
|
71
|
+
@children&.each { |child| child.close_open(:failed) }
|
|
72
|
+
@closed = true
|
|
73
|
+
@value = value ? true : false
|
|
74
|
+
@duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started if @profile
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def raised(exception)
|
|
78
|
+
@exception = exception
|
|
79
|
+
@inner = exception.octorail_trace if exception.respond_to?(:octorail_trace)
|
|
80
|
+
@outcome = :raised
|
|
81
|
+
close(nil)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def closed? = @closed == true
|
|
85
|
+
|
|
86
|
+
# Nodes still open when the call ended were left early: by a failure exit (`failed`) or by an
|
|
87
|
+
# exception (`raised`, only for the innermost open path).
|
|
88
|
+
def close_open(outcome)
|
|
89
|
+
children.each { |child| child.close_open(outcome) }
|
|
90
|
+
return if closed?
|
|
91
|
+
|
|
92
|
+
@outcome = outcome
|
|
93
|
+
close(nil)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def outcome
|
|
97
|
+
@outcome || OUTCOMES.fetch(type).call(self)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
OUTCOMES = {
|
|
101
|
+
step: ->(node) { node.value ? :success : :failure },
|
|
102
|
+
pass: ->(_node) { :ran },
|
|
103
|
+
failure: ->(_node) { :ran },
|
|
104
|
+
recover: ->(node) { node.value ? :recovered : :not_recovered },
|
|
105
|
+
wrap: ->(node) { node.value ? :success : :failure },
|
|
106
|
+
nested: ->(node) { node.value ? :success : :failure },
|
|
107
|
+
switch: ->(_node) { :success }
|
|
108
|
+
}.freeze
|
|
109
|
+
private_constant :OUTCOMES
|
|
110
|
+
|
|
111
|
+
def to_h
|
|
112
|
+
hash = { type:, name:, outcome:, **details }
|
|
113
|
+
hash[:duration_ms] = Trace.ms(duration) if duration
|
|
114
|
+
hash[:nodes] = children.map(&:to_h) unless children.empty?
|
|
115
|
+
hash
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def details
|
|
121
|
+
{ branch: (branch if type == :switch && taken?), exception: exception&.class&.name, trace: inner&.to_h }
|
|
122
|
+
.compact
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Attached to an exception that left a traced call: `e.octorail_trace`.
|
|
127
|
+
module TracedException
|
|
128
|
+
attr_accessor :octorail_trace
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.ms(seconds) = (seconds * 1000).round(3)
|
|
132
|
+
|
|
133
|
+
attr_reader :level, :root, :terminus, :error, :failed_step, :exception
|
|
134
|
+
|
|
135
|
+
# The operation's name, not the class: a trace holds no objects (see Node#value).
|
|
136
|
+
def initialize(operation, level)
|
|
137
|
+
@level = level
|
|
138
|
+
@root = Node.new(:operation, operation.name || operation.inspect, level == :profile)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def operation = root.name
|
|
142
|
+
|
|
143
|
+
def profile? = level == :profile
|
|
144
|
+
|
|
145
|
+
def duration = root.duration
|
|
146
|
+
|
|
147
|
+
# The call returned: close what is still open (left by a failure exit) and attach to the result.
|
|
148
|
+
def finish(result)
|
|
149
|
+
@terminus = result.terminus
|
|
150
|
+
@error = result.error
|
|
151
|
+
@failed_step = result.failed_step
|
|
152
|
+
root.close_open(:failed)
|
|
153
|
+
result.instance_variable_set(:@trace, self)
|
|
154
|
+
result
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The call raised: the open nodes are the path to where it happened. A trace the exception
|
|
158
|
+
# already carries (from a separate traced call inside a step) moves under that step's node.
|
|
159
|
+
def fail(exception)
|
|
160
|
+
@exception = exception
|
|
161
|
+
root.close_open(:raised)
|
|
162
|
+
return if exception.frozen?
|
|
163
|
+
|
|
164
|
+
exception.extend(TracedException)
|
|
165
|
+
exception.octorail_trace = self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def to_h
|
|
169
|
+
{
|
|
170
|
+
operation: root.name, level:, terminus:, error:, failed_step:,
|
|
171
|
+
exception: exception&.class&.name, duration_ms: duration && Trace.ms(duration),
|
|
172
|
+
nodes: root.children.map(&:to_h)
|
|
173
|
+
}.compact
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def to_s = Rendering.new(self).to_s
|
|
177
|
+
|
|
178
|
+
def inspect = "#<#{self.class} #{operation} #{exception ? "raised #{exception.class}" : terminus}>"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
require_relative "trace/rendering"
|
data/lib/octorail.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "octorail/version"
|
|
4
|
+
require_relative "octorail/errors"
|
|
5
|
+
require_relative "octorail/result"
|
|
6
|
+
require_relative "octorail/trace"
|
|
7
|
+
require_relative "octorail/config"
|
|
8
|
+
require_relative "octorail/nodes"
|
|
9
|
+
require_relative "octorail/dsl"
|
|
10
|
+
require_relative "octorail/compiler"
|
|
11
|
+
require_relative "octorail/runtime"
|
|
12
|
+
require_relative "octorail/invalidation"
|
|
13
|
+
require_relative "octorail/operation"
|
|
14
|
+
|
|
15
|
+
module Octorail
|
|
16
|
+
# Compiles every operation under `root` (all of them by default), so compile errors
|
|
17
|
+
# surface at boot or in CI instead of on the first call. Returns the compiled classes.
|
|
18
|
+
def self.compile_all!(root = Operation)
|
|
19
|
+
root.descendants.reject(&:abstract?).each(&:compile!)
|
|
20
|
+
end
|
|
21
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: octorail
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Dmytro Bereza
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Octorail provides a strict, Trailblazer-like railway DSL (step, pass,
|
|
14
|
+
failure, wrap, nested, switch, recover) that is compiled into a plain Ruby
|
|
15
|
+
method when the operation is first used, so a call costs about as much as
|
|
16
|
+
hand-written code. Optional tracing shows the path a call took.
|
|
17
|
+
email:
|
|
18
|
+
- dmitrybereza2001@gmail.com
|
|
19
|
+
executables: []
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- CHANGELOG.md
|
|
24
|
+
- LICENSE.txt
|
|
25
|
+
- README.md
|
|
26
|
+
- lib/octorail.rb
|
|
27
|
+
- lib/octorail/compiler.rb
|
|
28
|
+
- lib/octorail/compiler/code.rb
|
|
29
|
+
- lib/octorail/compiler/nested_cycles.rb
|
|
30
|
+
- lib/octorail/compiler/nested_emitter.rb
|
|
31
|
+
- lib/octorail/compiler/recovery_emitter.rb
|
|
32
|
+
- lib/octorail/compiler/scopes.rb
|
|
33
|
+
- lib/octorail/compiler/signature.rb
|
|
34
|
+
- lib/octorail/compiler/step_emitter.rb
|
|
35
|
+
- lib/octorail/compiler/switch_emitter.rb
|
|
36
|
+
- lib/octorail/compiler/tracing.rb
|
|
37
|
+
- lib/octorail/compiler/wrap_emitter.rb
|
|
38
|
+
- lib/octorail/config.rb
|
|
39
|
+
- lib/octorail/dsl.rb
|
|
40
|
+
- lib/octorail/dsl/nested.rb
|
|
41
|
+
- lib/octorail/dsl/outputs.rb
|
|
42
|
+
- lib/octorail/dsl/settings.rb
|
|
43
|
+
- lib/octorail/dsl/steps.rb
|
|
44
|
+
- lib/octorail/dsl/switches.rb
|
|
45
|
+
- lib/octorail/dsl/wraps.rb
|
|
46
|
+
- lib/octorail/errors.rb
|
|
47
|
+
- lib/octorail/invalidation.rb
|
|
48
|
+
- lib/octorail/nodes.rb
|
|
49
|
+
- lib/octorail/nodes/nested.rb
|
|
50
|
+
- lib/octorail/nodes/step.rb
|
|
51
|
+
- lib/octorail/nodes/switch.rb
|
|
52
|
+
- lib/octorail/nodes/wrap.rb
|
|
53
|
+
- lib/octorail/operation.rb
|
|
54
|
+
- lib/octorail/result.rb
|
|
55
|
+
- lib/octorail/runtime.rb
|
|
56
|
+
- lib/octorail/trace.rb
|
|
57
|
+
- lib/octorail/trace/rendering.rb
|
|
58
|
+
- lib/octorail/version.rb
|
|
59
|
+
homepage: https://github.com/Octorail-team/octorail
|
|
60
|
+
licenses:
|
|
61
|
+
- MIT
|
|
62
|
+
metadata:
|
|
63
|
+
source_code_uri: https://github.com/Octorail-team/octorail
|
|
64
|
+
changelog_uri: https://github.com/Octorail-team/octorail/blob/main/CHANGELOG.md
|
|
65
|
+
documentation_uri: https://github.com/Octorail-team/octorail/tree/main/docs
|
|
66
|
+
bug_tracker_uri: https://github.com/Octorail-team/octorail/issues
|
|
67
|
+
rubygems_mfa_required: 'true'
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.3'
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 3.6.9
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Lightweight railway-oriented operations compiled to plain Ruby at load time.
|
|
85
|
+
test_files: []
|