igniter 0.4.0 → 0.4.3
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/CHANGELOG.md +25 -0
- data/README.md +238 -218
- data/docs/LLM_V1.md +335 -0
- data/docs/PATTERNS.md +189 -0
- data/docs/SERVER_V1.md +313 -0
- data/examples/README.md +129 -0
- data/examples/agents.rb +150 -0
- data/examples/differential.rb +161 -0
- data/examples/distributed_server.rb +94 -0
- data/examples/effects.rb +184 -0
- data/examples/invariants.rb +179 -0
- data/examples/order_pipeline.rb +163 -0
- data/examples/provenance.rb +122 -0
- data/examples/saga.rb +110 -0
- data/lib/igniter/agent/mailbox.rb +96 -0
- data/lib/igniter/agent/message.rb +21 -0
- data/lib/igniter/agent/ref.rb +86 -0
- data/lib/igniter/agent/runner.rb +129 -0
- data/lib/igniter/agent/state_holder.rb +23 -0
- data/lib/igniter/agent.rb +155 -0
- data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
- data/lib/igniter/differential/divergence.rb +29 -0
- data/lib/igniter/differential/formatter.rb +96 -0
- data/lib/igniter/differential/report.rb +86 -0
- data/lib/igniter/differential/runner.rb +130 -0
- data/lib/igniter/differential.rb +51 -0
- data/lib/igniter/dsl/contract_builder.rb +32 -0
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +11 -1
- data/lib/igniter/execution_report/builder.rb +54 -0
- data/lib/igniter/execution_report/formatter.rb +50 -0
- data/lib/igniter/execution_report/node_entry.rb +24 -0
- data/lib/igniter/execution_report/report.rb +65 -0
- data/lib/igniter/execution_report.rb +32 -0
- data/lib/igniter/extensions/differential.rb +114 -0
- data/lib/igniter/extensions/execution_report.rb +27 -0
- data/lib/igniter/extensions/invariants.rb +116 -0
- data/lib/igniter/extensions/provenance.rb +45 -0
- data/lib/igniter/extensions/saga.rb +74 -0
- data/lib/igniter/integrations/agents.rb +18 -0
- data/lib/igniter/invariant.rb +50 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model.rb +1 -0
- data/lib/igniter/property_testing/formatter.rb +66 -0
- data/lib/igniter/property_testing/generators.rb +115 -0
- data/lib/igniter/property_testing/result.rb +45 -0
- data/lib/igniter/property_testing/run.rb +43 -0
- data/lib/igniter/property_testing/runner.rb +47 -0
- data/lib/igniter/property_testing.rb +64 -0
- data/lib/igniter/provenance/builder.rb +97 -0
- data/lib/igniter/provenance/lineage.rb +82 -0
- data/lib/igniter/provenance/node_trace.rb +65 -0
- data/lib/igniter/provenance/text_formatter.rb +70 -0
- data/lib/igniter/provenance.rb +29 -0
- data/lib/igniter/registry.rb +67 -0
- data/lib/igniter/runtime/resolver.rb +15 -0
- data/lib/igniter/saga/compensation.rb +31 -0
- data/lib/igniter/saga/compensation_record.rb +20 -0
- data/lib/igniter/saga/executor.rb +85 -0
- data/lib/igniter/saga/formatter.rb +49 -0
- data/lib/igniter/saga/result.rb +47 -0
- data/lib/igniter/saga.rb +56 -0
- data/lib/igniter/stream_loop.rb +80 -0
- data/lib/igniter/supervisor.rb +167 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +10 -0
- metadata +57 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require "igniter/extensions/invariants"
|
|
5
|
+
require "igniter/property_testing/generators"
|
|
6
|
+
require "igniter/property_testing/run"
|
|
7
|
+
require "igniter/property_testing/result"
|
|
8
|
+
require "igniter/property_testing/formatter"
|
|
9
|
+
require "igniter/property_testing/runner"
|
|
10
|
+
|
|
11
|
+
module Igniter
|
|
12
|
+
# Property-based testing for Igniter contracts.
|
|
13
|
+
#
|
|
14
|
+
# Generates hundreds of random inputs, runs the contract with each set,
|
|
15
|
+
# and verifies that all declared invariants hold. Violations are collected
|
|
16
|
+
# as data so you can inspect the first counterexample.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# require "igniter/property_testing"
|
|
20
|
+
#
|
|
21
|
+
# G = Igniter::PropertyTesting::Generators
|
|
22
|
+
#
|
|
23
|
+
# class PricingContract < Igniter::Contract
|
|
24
|
+
# define do
|
|
25
|
+
# input :price
|
|
26
|
+
# input :quantity
|
|
27
|
+
# compute :total, depends_on: %i[price quantity] do |price:, quantity:|
|
|
28
|
+
# price * quantity
|
|
29
|
+
# end
|
|
30
|
+
# output :total
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# invariant(:total_non_negative) { |total:, **| total >= 0 }
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# result = PricingContract.property_test(
|
|
37
|
+
# generators: { price: G.float(0.0..500.0), quantity: G.positive_integer(max: 100) },
|
|
38
|
+
# runs: 200,
|
|
39
|
+
# seed: 42
|
|
40
|
+
# )
|
|
41
|
+
#
|
|
42
|
+
# puts result.explain
|
|
43
|
+
# puts result.counterexample&.inputs
|
|
44
|
+
#
|
|
45
|
+
module PropertyTesting
|
|
46
|
+
# Class methods added to every Igniter::Contract subclass.
|
|
47
|
+
module ClassMethods
|
|
48
|
+
# Run the contract against randomly generated inputs and verify invariants.
|
|
49
|
+
#
|
|
50
|
+
# Requires at least one `invariant` to be declared on the contract class,
|
|
51
|
+
# though it will still execute and collect execution errors without any.
|
|
52
|
+
#
|
|
53
|
+
# @param generators [Hash{Symbol => #call}] input name → generator callable
|
|
54
|
+
# @param runs [Integer] number of test runs (default: 100)
|
|
55
|
+
# @param seed [Integer, nil] optional RNG seed for reproducibility
|
|
56
|
+
# @return [Igniter::PropertyTesting::Result]
|
|
57
|
+
def property_test(generators:, runs: 100, seed: nil)
|
|
58
|
+
Runner.new(self, generators: generators, runs: runs, seed: seed).run
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Igniter::Contract.extend(Igniter::PropertyTesting::ClassMethods)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Provenance
|
|
5
|
+
# Builds a Lineage object for a named output by traversing the compiled
|
|
6
|
+
# graph and reading resolved values from the execution cache.
|
|
7
|
+
#
|
|
8
|
+
# The builder memoises each NodeTrace so that shared dependencies
|
|
9
|
+
# (diamond patterns) point to the same object rather than being duplicated.
|
|
10
|
+
class Builder
|
|
11
|
+
class << self
|
|
12
|
+
# Build lineage for +output_name+ from a resolved +execution+.
|
|
13
|
+
def build(output_name, execution)
|
|
14
|
+
new(execution).build(output_name)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(execution)
|
|
19
|
+
@graph = execution.compiled_graph
|
|
20
|
+
@cache = execution.cache
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build(output_name) # rubocop:disable Metrics/MethodLength
|
|
24
|
+
sym = output_name.to_sym
|
|
25
|
+
|
|
26
|
+
raise ProvenanceError, "No output named '#{sym}' in #{@graph.name}" unless @graph.output?(sym)
|
|
27
|
+
|
|
28
|
+
output_node = @graph.fetch_output(sym)
|
|
29
|
+
source_name = output_node.source_root
|
|
30
|
+
|
|
31
|
+
source_node = begin
|
|
32
|
+
@graph.fetch_node(source_name)
|
|
33
|
+
rescue KeyError
|
|
34
|
+
raise ProvenanceError, "Source node '#{source_name}' for output '#{sym}' not found in graph"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
trace = build_trace(source_node, {})
|
|
38
|
+
Lineage.new(trace)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Recursively build a NodeTrace for +node+.
|
|
44
|
+
# +memo+ prevents re-processing the same node in diamond-dependency graphs.
|
|
45
|
+
def build_trace(node, memo) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
46
|
+
return memo[node.name] if memo.key?(node.name)
|
|
47
|
+
|
|
48
|
+
# Reserve the slot to handle (unlikely) circular edge during traversal
|
|
49
|
+
memo[node.name] = nil
|
|
50
|
+
|
|
51
|
+
state = @cache.fetch(node.name)
|
|
52
|
+
value = extract_value(state)
|
|
53
|
+
|
|
54
|
+
contributing = {}
|
|
55
|
+
node.dependencies.each do |dep_name|
|
|
56
|
+
dep_node = safe_fetch_node(dep_name)
|
|
57
|
+
next unless dep_node
|
|
58
|
+
|
|
59
|
+
contributing[dep_name] = build_trace(dep_node, memo)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
trace = NodeTrace.new(
|
|
63
|
+
name: node.name,
|
|
64
|
+
kind: node.kind,
|
|
65
|
+
value: value,
|
|
66
|
+
contributing: contributing
|
|
67
|
+
)
|
|
68
|
+
memo[node.name] = trace
|
|
69
|
+
trace
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def safe_fetch_node(name)
|
|
73
|
+
@graph.fetch_node(name)
|
|
74
|
+
rescue KeyError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Extract a display-friendly value from a NodeState.
|
|
79
|
+
# Composition/Collection results are summarised as hashes.
|
|
80
|
+
def extract_value(state) # rubocop:disable Metrics/MethodLength
|
|
81
|
+
return nil unless state
|
|
82
|
+
|
|
83
|
+
val = state.value
|
|
84
|
+
case val
|
|
85
|
+
when Runtime::Result
|
|
86
|
+
val.to_h
|
|
87
|
+
when Runtime::CollectionResult
|
|
88
|
+
val.summary
|
|
89
|
+
when Runtime::DeferredResult
|
|
90
|
+
{ pending: true, event: val.waiting_on }
|
|
91
|
+
else
|
|
92
|
+
val
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Provenance
|
|
5
|
+
# Lineage captures the full provenance of a single contract output.
|
|
6
|
+
#
|
|
7
|
+
# It wraps the NodeTrace tree rooted at the node that produces the output
|
|
8
|
+
# and exposes query methods for understanding what inputs shaped the result.
|
|
9
|
+
#
|
|
10
|
+
# Usage (after `require "igniter/extensions/provenance"`):
|
|
11
|
+
#
|
|
12
|
+
# contract.resolve_all
|
|
13
|
+
# lin = contract.lineage(:grand_total)
|
|
14
|
+
#
|
|
15
|
+
# lin.value # => 229.95
|
|
16
|
+
# lin.contributing_inputs # => { base_price: 100.0, quantity: 2, ... }
|
|
17
|
+
# lin.sensitive_to?(:base_price) # => true
|
|
18
|
+
# lin.sensitive_to?(:user_name) # => false
|
|
19
|
+
# lin.path_to(:base_price) # => [:grand_total, :subtotal, :unit_price, :base_price]
|
|
20
|
+
# puts lin # prints ASCII tree
|
|
21
|
+
#
|
|
22
|
+
class Lineage
|
|
23
|
+
# The NodeTrace rooted at the output's source computation node.
|
|
24
|
+
attr_reader :trace
|
|
25
|
+
|
|
26
|
+
def initialize(trace)
|
|
27
|
+
@trace = trace
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The output name (same as the root trace node name).
|
|
32
|
+
def output_name
|
|
33
|
+
trace.name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The resolved output value.
|
|
37
|
+
def value
|
|
38
|
+
trace.value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# All :input nodes that transitively contributed to this output.
|
|
42
|
+
# Returns Hash{ Symbol => value }.
|
|
43
|
+
def contributing_inputs
|
|
44
|
+
trace.contributing_inputs
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Does this output's value depend (transitively) on the given input?
|
|
48
|
+
def sensitive_to?(input_name)
|
|
49
|
+
trace.sensitive_to?(input_name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Ordered path of node names from the output down to the given input.
|
|
53
|
+
# Returns nil if the input does not contribute to this output.
|
|
54
|
+
def path_to(input_name)
|
|
55
|
+
trace.path_to(input_name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Human-readable ASCII tree explaining how this output was derived.
|
|
59
|
+
def explain
|
|
60
|
+
TextFormatter.format(trace)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
alias to_s explain
|
|
64
|
+
|
|
65
|
+
# Structured (serialisable) representation of the full trace tree.
|
|
66
|
+
def to_h
|
|
67
|
+
serialize_trace(trace)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def serialize_trace(trc)
|
|
73
|
+
{
|
|
74
|
+
node: trc.name,
|
|
75
|
+
kind: trc.kind,
|
|
76
|
+
value: trc.value,
|
|
77
|
+
contributing: trc.contributing.transform_values { |dep| serialize_trace(dep) }
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Provenance
|
|
5
|
+
# Immutable snapshot of a single resolved node and its full dependency chain.
|
|
6
|
+
#
|
|
7
|
+
# The tree structure mirrors the contract's dependency graph: each NodeTrace
|
|
8
|
+
# holds the traced values of all the nodes that contributed to its result.
|
|
9
|
+
# Input nodes are leaves (no contributing dependencies).
|
|
10
|
+
#
|
|
11
|
+
# The tree may share nodes (diamond dependencies in the original graph) but
|
|
12
|
+
# each shared node is memoised once by the Builder, so the same NodeTrace
|
|
13
|
+
# object is referenced from multiple parents — it is NOT duplicated.
|
|
14
|
+
class NodeTrace
|
|
15
|
+
attr_reader :name, :kind, :value, :contributing
|
|
16
|
+
|
|
17
|
+
# contributing: Hash{ Symbol => NodeTrace } — may be empty for leaf inputs
|
|
18
|
+
def initialize(name:, kind:, value:, contributing: {})
|
|
19
|
+
@name = name.to_sym
|
|
20
|
+
@kind = kind.to_sym
|
|
21
|
+
@value = value
|
|
22
|
+
@contributing = contributing.freeze
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def input?
|
|
27
|
+
kind == :input
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# True when this node has no dependencies that contributed to its value.
|
|
31
|
+
def leaf?
|
|
32
|
+
contributing.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Recursively collect all :input nodes that influenced this trace.
|
|
36
|
+
# Returns Hash{ Symbol => value }.
|
|
37
|
+
def contributing_inputs
|
|
38
|
+
return { name => value } if input?
|
|
39
|
+
|
|
40
|
+
contributing.each_value.with_object({}) do |dep, acc|
|
|
41
|
+
acc.merge!(dep.contributing_inputs)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Does this trace transitively depend on the named input?
|
|
46
|
+
def sensitive_to?(input_name)
|
|
47
|
+
contributing_inputs.key?(input_name.to_sym)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Return the ordered path of node names from this node down to the given
|
|
51
|
+
# input, or nil if the input does not contribute to this node.
|
|
52
|
+
def path_to(input_name)
|
|
53
|
+
target = input_name.to_sym
|
|
54
|
+
return [name] if name == target
|
|
55
|
+
|
|
56
|
+
contributing.each_value do |dep|
|
|
57
|
+
sub_path = dep.path_to(target)
|
|
58
|
+
return [name] + sub_path if sub_path
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Provenance
|
|
5
|
+
# Renders a NodeTrace tree as a human-readable ASCII tree.
|
|
6
|
+
#
|
|
7
|
+
# Example output:
|
|
8
|
+
#
|
|
9
|
+
# grand_total = 229.95 [compute]
|
|
10
|
+
# ├─ subtotal = 199.96 [compute]
|
|
11
|
+
# │ ├─ unit_price = 99.98 [compute]
|
|
12
|
+
# │ │ └─ base_price = 100.0 [input]
|
|
13
|
+
# │ └─ quantity = 2 [input]
|
|
14
|
+
# └─ shipping_cost = 29.99 [compute]
|
|
15
|
+
# └─ destination = "US" [input]
|
|
16
|
+
#
|
|
17
|
+
module TextFormatter
|
|
18
|
+
VALUE_MAX_LENGTH = 60
|
|
19
|
+
|
|
20
|
+
def self.format(trace)
|
|
21
|
+
lines = []
|
|
22
|
+
render(trace, lines, prefix: "", is_root: true, is_last: true)
|
|
23
|
+
lines.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ── private helpers ──────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
def self.render(trace, lines, prefix:, is_root:, is_last:) # rubocop:disable Metrics/MethodLength
|
|
29
|
+
if is_root
|
|
30
|
+
connector = ""
|
|
31
|
+
child_pad = ""
|
|
32
|
+
elsif is_last
|
|
33
|
+
connector = "└─ "
|
|
34
|
+
child_pad = " "
|
|
35
|
+
else
|
|
36
|
+
connector = "├─ "
|
|
37
|
+
child_pad = "│ "
|
|
38
|
+
end
|
|
39
|
+
child_prefix = prefix + child_pad
|
|
40
|
+
|
|
41
|
+
lines << "#{prefix}#{connector}#{trace.name} = #{format_value(trace.value)} [#{trace.kind}]"
|
|
42
|
+
|
|
43
|
+
deps = trace.contributing.values
|
|
44
|
+
deps.each_with_index do |dep, idx|
|
|
45
|
+
render(dep, lines,
|
|
46
|
+
prefix: child_prefix,
|
|
47
|
+
is_root: false,
|
|
48
|
+
is_last: idx == deps.size - 1)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :render
|
|
52
|
+
|
|
53
|
+
def self.format_value(value) # rubocop:disable Metrics/CyclomaticComplexity
|
|
54
|
+
str = case value
|
|
55
|
+
when nil then "nil"
|
|
56
|
+
when String then value.inspect
|
|
57
|
+
when Symbol then value.inspect
|
|
58
|
+
when Hash then "{#{value.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}"
|
|
59
|
+
when Array then "[#{value.map(&:inspect).join(", ")}]"
|
|
60
|
+
else value.inspect
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return str if str.length <= VALUE_MAX_LENGTH
|
|
64
|
+
|
|
65
|
+
"#{str[0, VALUE_MAX_LENGTH - 3]}..."
|
|
66
|
+
end
|
|
67
|
+
private_class_method :format_value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "provenance/node_trace"
|
|
4
|
+
require_relative "provenance/text_formatter"
|
|
5
|
+
require_relative "provenance/lineage"
|
|
6
|
+
require_relative "provenance/builder"
|
|
7
|
+
|
|
8
|
+
module Igniter
|
|
9
|
+
# Provenance — runtime data lineage for Igniter contracts.
|
|
10
|
+
#
|
|
11
|
+
# After a contract has been resolved, provenance lets you ask:
|
|
12
|
+
# "How was this output value computed, and which inputs influenced it?"
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# require "igniter/extensions/provenance"
|
|
16
|
+
#
|
|
17
|
+
# contract.resolve_all
|
|
18
|
+
# lin = contract.lineage(:grand_total)
|
|
19
|
+
#
|
|
20
|
+
# lin.value # => 229.95
|
|
21
|
+
# lin.contributing_inputs # => { base_price: 100.0, quantity: 2 }
|
|
22
|
+
# lin.sensitive_to?(:base_price) # => true
|
|
23
|
+
# lin.path_to(:base_price) # => [:grand_total, :subtotal, :base_price]
|
|
24
|
+
# puts lin # ASCII tree
|
|
25
|
+
#
|
|
26
|
+
module Provenance
|
|
27
|
+
class ProvenanceError < Igniter::Error; end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
# Thread-safe process registry mapping names to agent Refs.
|
|
5
|
+
#
|
|
6
|
+
# Names can be any object that is a valid Hash key (Symbols recommended).
|
|
7
|
+
#
|
|
8
|
+
# Igniter::Registry.register(:counter, ref)
|
|
9
|
+
# Igniter::Registry.find(:counter) # => ref
|
|
10
|
+
# Igniter::Registry.unregister(:counter)
|
|
11
|
+
#
|
|
12
|
+
module Registry
|
|
13
|
+
class RegistryError < Igniter::Error; end
|
|
14
|
+
|
|
15
|
+
@store = {}
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Register +ref+ under +name+. Raises RegistryError if the name is taken.
|
|
20
|
+
def register(name, ref)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
raise RegistryError, "Name '#{name}' is already registered" if @store.key?(name)
|
|
23
|
+
|
|
24
|
+
@store[name] = ref
|
|
25
|
+
end
|
|
26
|
+
ref
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Register (or replace) +ref+ under +name+ without uniqueness check.
|
|
30
|
+
def register!(name, ref)
|
|
31
|
+
@mutex.synchronize { @store[name] = ref }
|
|
32
|
+
ref
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Return the Ref registered under +name+, or nil if not found.
|
|
36
|
+
def find(name)
|
|
37
|
+
@mutex.synchronize { @store[name] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Return the Ref or raise RegistryError if not found.
|
|
41
|
+
def fetch(name)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
@store.fetch(name) { raise RegistryError, "No agent registered as '#{name}'" }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Remove and return the Ref registered under +name+.
|
|
48
|
+
def unregister(name)
|
|
49
|
+
@mutex.synchronize { @store.delete(name) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def registered?(name)
|
|
53
|
+
@mutex.synchronize { @store.key?(name) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Snapshot of the current name→Ref map.
|
|
57
|
+
def all
|
|
58
|
+
@mutex.synchronize { @store.dup }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Remove all registrations (useful in tests).
|
|
62
|
+
def clear
|
|
63
|
+
@mutex.synchronize { @store.clear }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -25,6 +25,8 @@ module Igniter
|
|
|
25
25
|
resolve_branch(node)
|
|
26
26
|
when :collection
|
|
27
27
|
resolve_collection(node)
|
|
28
|
+
when :effect
|
|
29
|
+
resolve_effect(node)
|
|
28
30
|
when :await
|
|
29
31
|
resolve_await(node)
|
|
30
32
|
when :remote
|
|
@@ -63,6 +65,19 @@ module Igniter
|
|
|
63
65
|
NodeState.new(node: node, status: :succeeded, value: @execution.fetch_input!(node.name))
|
|
64
66
|
end
|
|
65
67
|
|
|
68
|
+
def resolve_effect(node)
|
|
69
|
+
dependencies = node.dependencies.each_with_object({}) do |dep, memo|
|
|
70
|
+
memo[dep] = resolve_dependency_value(dep)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
value = node.adapter_class.new(
|
|
74
|
+
execution: @execution,
|
|
75
|
+
contract: @execution.contract_instance
|
|
76
|
+
).call(**dependencies)
|
|
77
|
+
|
|
78
|
+
NodeState.new(node: node, status: :succeeded, value: value)
|
|
79
|
+
end
|
|
80
|
+
|
|
66
81
|
def resolve_await(node)
|
|
67
82
|
deferred = Runtime::DeferredResult.build(
|
|
68
83
|
payload: { event: node.event_name },
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Saga
|
|
5
|
+
# Declares the compensating action for a named compute node.
|
|
6
|
+
#
|
|
7
|
+
# The block is called with keyword arguments:
|
|
8
|
+
# inputs: Hash{ Symbol => value } — dependency values the node consumed
|
|
9
|
+
# value: Object — the value the node produced
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
# compensate :charge_card do |inputs:, value:|
|
|
13
|
+
# PaymentService.refund(value[:charge_id])
|
|
14
|
+
# end
|
|
15
|
+
class Compensation
|
|
16
|
+
attr_reader :node_name, :block
|
|
17
|
+
|
|
18
|
+
def initialize(node_name, &block)
|
|
19
|
+
raise ArgumentError, "compensate :#{node_name} requires a block" unless block
|
|
20
|
+
|
|
21
|
+
@node_name = node_name.to_sym
|
|
22
|
+
@block = block
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(inputs:, value:)
|
|
27
|
+
block.call(inputs: inputs, value: value)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Saga
|
|
5
|
+
# Immutable record of a single compensation that was attempted.
|
|
6
|
+
class CompensationRecord
|
|
7
|
+
attr_reader :node_name, :error
|
|
8
|
+
|
|
9
|
+
def initialize(node_name:, success:, error: nil)
|
|
10
|
+
@node_name = node_name
|
|
11
|
+
@success = success
|
|
12
|
+
@error = error
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def success? = @success
|
|
17
|
+
def failed? = !@success
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Saga
|
|
5
|
+
# Runs declared compensations for all successfully completed nodes,
|
|
6
|
+
# in reverse topological order.
|
|
7
|
+
#
|
|
8
|
+
# A node is eligible for compensation if:
|
|
9
|
+
# 1. Its state in the cache is `succeeded?`
|
|
10
|
+
# 2. A compensation is declared for it, via one of:
|
|
11
|
+
# a. Contract-level `compensate :node_name do ... end` (takes precedence)
|
|
12
|
+
# b. Built-in compensation defined on the Igniter::Effect adapter class
|
|
13
|
+
#
|
|
14
|
+
# Compensation failures are captured as failed CompensationRecords and
|
|
15
|
+
# do NOT halt the rollback of other nodes.
|
|
16
|
+
class Executor
|
|
17
|
+
def initialize(contract)
|
|
18
|
+
@contract = contract
|
|
19
|
+
@graph = contract.execution.compiled_graph
|
|
20
|
+
@cache = contract.execution.cache
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<CompensationRecord>]
|
|
24
|
+
def run_compensations
|
|
25
|
+
eligible_nodes_reversed.filter_map do |node|
|
|
26
|
+
compensation = find_compensation(node)
|
|
27
|
+
next unless compensation
|
|
28
|
+
|
|
29
|
+
attempt(compensation, node)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Find the first node whose state is :failed in the cache.
|
|
34
|
+
# @return [Symbol, nil]
|
|
35
|
+
def failed_node_name
|
|
36
|
+
@cache.to_h.find { |_name, state| state.failed? }&.first
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Resolve the compensation to use for a node, if any.
|
|
42
|
+
#
|
|
43
|
+
# Contract-level `compensate :node_name` takes precedence over built-in
|
|
44
|
+
# compensation declared on an Igniter::Effect subclass.
|
|
45
|
+
#
|
|
46
|
+
# @return [Igniter::Saga::Compensation, nil]
|
|
47
|
+
def find_compensation(node)
|
|
48
|
+
declared = @contract.class.compensations
|
|
49
|
+
return declared[node.name] if declared.key?(node.name)
|
|
50
|
+
|
|
51
|
+
return nil unless node.kind == :effect
|
|
52
|
+
|
|
53
|
+
built_in = node.adapter_class.built_in_compensation
|
|
54
|
+
return nil unless built_in
|
|
55
|
+
|
|
56
|
+
Compensation.new(node.name, &built_in)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Nodes that succeeded, in reverse resolution order.
|
|
60
|
+
def eligible_nodes_reversed
|
|
61
|
+
@graph.resolution_order
|
|
62
|
+
.select { |node| @cache.fetch(node.name)&.succeeded? }
|
|
63
|
+
.reverse
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def attempt(compensation, node)
|
|
67
|
+
inputs = extract_inputs(node)
|
|
68
|
+
value = @cache.fetch(node.name)&.value
|
|
69
|
+
|
|
70
|
+
compensation.run(inputs: inputs, value: value)
|
|
71
|
+
CompensationRecord.new(node_name: compensation.node_name, success: true)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
CompensationRecord.new(node_name: compensation.node_name, success: false, error: e)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Gather the resolved values of the node's direct dependencies.
|
|
77
|
+
def extract_inputs(node)
|
|
78
|
+
node.dependencies.each_with_object({}) do |dep_name, acc|
|
|
79
|
+
state = @cache.fetch(dep_name)
|
|
80
|
+
acc[dep_name.to_sym] = state&.value
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Saga
|
|
5
|
+
# Formats a SagaResult as a human-readable text block.
|
|
6
|
+
#
|
|
7
|
+
# Example:
|
|
8
|
+
#
|
|
9
|
+
# Contract: OrderWorkflow
|
|
10
|
+
# Status: FAILED
|
|
11
|
+
# Error: Insufficient funds
|
|
12
|
+
# At node: :charge_card
|
|
13
|
+
#
|
|
14
|
+
# COMPENSATIONS (1):
|
|
15
|
+
# [ok] :reserve_stock
|
|
16
|
+
#
|
|
17
|
+
module Formatter
|
|
18
|
+
class << self
|
|
19
|
+
def format(result)
|
|
20
|
+
lines = []
|
|
21
|
+
lines << "Contract: #{result.contract.class.name}"
|
|
22
|
+
lines << "Status: #{result.success? ? "SUCCESS" : "FAILED"}"
|
|
23
|
+
|
|
24
|
+
if result.failed?
|
|
25
|
+
lines << "Error: #{result.error&.message}"
|
|
26
|
+
lines << "At node: :#{result.failed_node}" if result.failed_node
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
append_compensations(result, lines)
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def append_compensations(result, lines)
|
|
36
|
+
return if result.compensations.empty?
|
|
37
|
+
|
|
38
|
+
lines << ""
|
|
39
|
+
lines << "COMPENSATIONS (#{result.compensations.size}):"
|
|
40
|
+
result.compensations.each do |rec|
|
|
41
|
+
tag = rec.success? ? "[ok] " : "[fail] "
|
|
42
|
+
lines << " #{tag} :#{rec.node_name}"
|
|
43
|
+
lines << " error: #{rec.error.message}" if rec.failed?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|