igniter 0.3.1 → 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/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- 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/distributed_workflow.rb +52 -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/compiled_graph.rb +12 -0
- data/lib/igniter/compiler/validation_pipeline.rb +3 -1
- data/lib/igniter/compiler/validators/await_validator.rb +53 -0
- data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
- data/lib/igniter/compiler/validators/dependencies_validator.rb +41 -1
- data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
- data/lib/igniter/compiler.rb +2 -0
- data/lib/igniter/contract.rb +59 -8
- 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 +74 -4
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +17 -2
- 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/integrations/llm/config.rb +69 -0
- data/lib/igniter/integrations/llm/context.rb +74 -0
- data/lib/igniter/integrations/llm/executor.rb +159 -0
- data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
- data/lib/igniter/integrations/llm/providers/base.rb +33 -0
- data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
- data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
- data/lib/igniter/integrations/llm.rb +59 -0
- data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
- data/lib/igniter/integrations/rails/contract_job.rb +76 -0
- data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
- data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
- data/lib/igniter/integrations/rails/railtie.rb +25 -0
- data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
- data/lib/igniter/integrations/rails.rb +12 -0
- data/lib/igniter/invariant.rb +50 -0
- data/lib/igniter/model/await_node.rb +21 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model/remote_node.rb +26 -0
- data/lib/igniter/model.rb +3 -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/execution.rb +2 -2
- data/lib/igniter/runtime/input_validator.rb +5 -3
- data/lib/igniter/runtime/resolver.rb +58 -1
- data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
- data/lib/igniter/runtime/stores/file_store.rb +50 -2
- data/lib/igniter/runtime/stores/memory_store.rb +55 -2
- data/lib/igniter/runtime/stores/redis_store.rb +13 -1
- 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/server/client.rb +123 -0
- data/lib/igniter/server/config.rb +27 -0
- data/lib/igniter/server/handlers/base.rb +105 -0
- data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
- data/lib/igniter/server/handlers/event_handler.rb +28 -0
- data/lib/igniter/server/handlers/execute_handler.rb +37 -0
- data/lib/igniter/server/handlers/health_handler.rb +32 -0
- data/lib/igniter/server/handlers/status_handler.rb +27 -0
- data/lib/igniter/server/http_server.rb +109 -0
- data/lib/igniter/server/rack_app.rb +35 -0
- data/lib/igniter/server/registry.rb +56 -0
- data/lib/igniter/server/router.rb +75 -0
- data/lib/igniter/server.rb +67 -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 +14 -0
- metadata +92 -2
|
@@ -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
|
|
@@ -11,10 +11,10 @@ module Igniter
|
|
|
11
11
|
@runner_strategy = runner
|
|
12
12
|
@max_workers = max_workers
|
|
13
13
|
@store = store
|
|
14
|
-
@
|
|
14
|
+
@events = Events::Bus.new
|
|
15
|
+
@input_validator = InputValidator.new(compiled_graph, execution_id: @events.execution_id)
|
|
15
16
|
@inputs = @input_validator.normalize_initial_inputs(inputs)
|
|
16
17
|
@cache = Cache.new
|
|
17
|
-
@events = Events::Bus.new
|
|
18
18
|
@audit = Extensions::Auditing::Timeline.new(self)
|
|
19
19
|
@events.subscribe(@audit)
|
|
20
20
|
@resolver = Resolver.new(self)
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
module Runtime
|
|
5
5
|
class InputValidator
|
|
6
|
-
def initialize(compiled_graph)
|
|
6
|
+
def initialize(compiled_graph, execution_id: nil)
|
|
7
7
|
@compiled_graph = compiled_graph
|
|
8
|
+
@execution_id = execution_id
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def normalize_initial_inputs(raw_inputs)
|
|
@@ -106,7 +107,7 @@ module Igniter
|
|
|
106
107
|
hash.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = value }
|
|
107
108
|
end
|
|
108
109
|
|
|
109
|
-
def input_error(input_node, message)
|
|
110
|
+
def input_error(input_node, message) # rubocop:disable Metrics/MethodLength
|
|
110
111
|
InputError.new(
|
|
111
112
|
message,
|
|
112
113
|
context: {
|
|
@@ -114,7 +115,8 @@ module Igniter
|
|
|
114
115
|
node_id: input_node.id,
|
|
115
116
|
node_name: input_node.name,
|
|
116
117
|
node_path: input_node.path,
|
|
117
|
-
source_location: input_node.source_location
|
|
118
|
+
source_location: input_node.source_location,
|
|
119
|
+
execution_id: @execution_id
|
|
118
120
|
}
|
|
119
121
|
)
|
|
120
122
|
end
|
|
@@ -25,6 +25,12 @@ module Igniter
|
|
|
25
25
|
resolve_branch(node)
|
|
26
26
|
when :collection
|
|
27
27
|
resolve_collection(node)
|
|
28
|
+
when :effect
|
|
29
|
+
resolve_effect(node)
|
|
30
|
+
when :await
|
|
31
|
+
resolve_await(node)
|
|
32
|
+
when :remote
|
|
33
|
+
resolve_remote(node)
|
|
28
34
|
else
|
|
29
35
|
raise ResolutionError, "Unsupported node kind: #{node.kind}"
|
|
30
36
|
end
|
|
@@ -59,6 +65,56 @@ module Igniter
|
|
|
59
65
|
NodeState.new(node: node, status: :succeeded, value: @execution.fetch_input!(node.name))
|
|
60
66
|
end
|
|
61
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
|
+
|
|
81
|
+
def resolve_await(node)
|
|
82
|
+
deferred = Runtime::DeferredResult.build(
|
|
83
|
+
payload: { event: node.event_name },
|
|
84
|
+
source_node: node.name,
|
|
85
|
+
waiting_on: node.name
|
|
86
|
+
)
|
|
87
|
+
raise PendingDependencyError.new(deferred, "Waiting for external event '#{node.event_name}'")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def resolve_remote(node) # rubocop:disable Metrics/MethodLength
|
|
91
|
+
unless defined?(Igniter::Server::Client)
|
|
92
|
+
raise ResolutionError,
|
|
93
|
+
"remote: nodes require `require 'igniter/server'` (server integration not loaded)"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
inputs = node.input_mapping.each_with_object({}) do |(child_input, dep_name), memo|
|
|
97
|
+
memo[child_input] = resolve_dependency_value(dep_name)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
client = Igniter::Server::Client.new(node.node_url, timeout: node.timeout)
|
|
101
|
+
response = client.execute(node.contract_name, inputs: inputs)
|
|
102
|
+
|
|
103
|
+
case response[:status]
|
|
104
|
+
when :succeeded
|
|
105
|
+
NodeState.new(node: node, status: :succeeded, value: response[:outputs])
|
|
106
|
+
when :failed
|
|
107
|
+
error_message = response.dig(:error, :message) || response.dig(:error, "message")
|
|
108
|
+
raise ResolutionError,
|
|
109
|
+
"Remote #{node.contract_name}@#{node.node_url}: #{error_message}"
|
|
110
|
+
else
|
|
111
|
+
raise ResolutionError,
|
|
112
|
+
"Remote #{node.contract_name}@#{node.node_url}: unexpected status '#{response[:status]}'"
|
|
113
|
+
end
|
|
114
|
+
rescue Igniter::Server::Client::ConnectionError => e
|
|
115
|
+
raise ResolutionError, "Cannot reach #{node.node_url}: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
|
|
62
118
|
def resolve_compute(node)
|
|
63
119
|
dependencies = node.dependencies.each_with_object({}) do |dependency_name, memo|
|
|
64
120
|
memo[dependency_name] = resolve_dependency_value(dependency_name)
|
|
@@ -310,7 +366,8 @@ module Igniter
|
|
|
310
366
|
node_id: node.id,
|
|
311
367
|
node_name: node.name,
|
|
312
368
|
node_path: node.path,
|
|
313
|
-
source_location: node.source_location
|
|
369
|
+
source_location: node.source_location,
|
|
370
|
+
execution_id: @execution.events.execution_id
|
|
314
371
|
}
|
|
315
372
|
)
|
|
316
373
|
end
|
|
@@ -12,7 +12,7 @@ module Igniter
|
|
|
12
12
|
@snapshot_column = snapshot_column.to_sym
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def save(snapshot)
|
|
15
|
+
def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
16
16
|
execution_id = snapshot[:execution_id] || snapshot["execution_id"]
|
|
17
17
|
record = @record_class.find_or_initialize_by(@execution_id_column => execution_id)
|
|
18
18
|
record.public_send(:"#{@snapshot_column}=", JSON.generate(snapshot))
|
|
@@ -20,6 +20,18 @@ module Igniter
|
|
|
20
20
|
execution_id
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def find_by_correlation(graph:, correlation:)
|
|
24
|
+
raise NotImplementedError, "find_by_correlation is not implemented for ActiveRecordStore"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def list_all(graph: nil)
|
|
28
|
+
raise NotImplementedError, "list_all is not implemented for ActiveRecordStore"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def list_pending(graph: nil)
|
|
32
|
+
raise NotImplementedError, "list_pending is not implemented for ActiveRecordStore"
|
|
33
|
+
end
|
|
34
|
+
|
|
23
35
|
def fetch(execution_id)
|
|
24
36
|
record = @record_class.find_by(@execution_id_column => execution_id)
|
|
25
37
|
raise Igniter::ResolutionError, "No execution snapshot found for '#{execution_id}'" unless record
|
|
@@ -12,12 +12,51 @@ module Igniter
|
|
|
12
12
|
FileUtils.mkdir_p(@root)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def save(snapshot)
|
|
15
|
+
def save(snapshot, correlation: nil, graph: nil)
|
|
16
16
|
execution_id = snapshot[:execution_id] || snapshot["execution_id"]
|
|
17
|
-
|
|
17
|
+
data = snapshot.merge(
|
|
18
|
+
_graph: graph,
|
|
19
|
+
_correlation: correlation&.transform_keys(&:to_s)
|
|
20
|
+
).compact
|
|
21
|
+
File.write(path_for(execution_id), JSON.pretty_generate(data))
|
|
18
22
|
execution_id
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
def find_by_correlation(graph:, correlation:)
|
|
26
|
+
normalized = correlation.transform_keys(&:to_s)
|
|
27
|
+
each_snapshot do |data|
|
|
28
|
+
next unless data["_graph"] == graph
|
|
29
|
+
|
|
30
|
+
stored_corr = data["_correlation"] || {}
|
|
31
|
+
return data["execution_id"] if stored_corr == normalized
|
|
32
|
+
end
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def list_all(graph: nil)
|
|
37
|
+
results = []
|
|
38
|
+
each_snapshot do |data|
|
|
39
|
+
next if graph && data["_graph"] != graph
|
|
40
|
+
|
|
41
|
+
results << data["execution_id"]
|
|
42
|
+
end
|
|
43
|
+
results
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def list_pending(graph: nil)
|
|
47
|
+
results = []
|
|
48
|
+
each_snapshot do |data|
|
|
49
|
+
next if graph && data["_graph"] != graph
|
|
50
|
+
|
|
51
|
+
states = data["states"] || {}
|
|
52
|
+
pending = states.any? do |_name, state|
|
|
53
|
+
(state["status"] || state[:status]).to_s == "pending"
|
|
54
|
+
end
|
|
55
|
+
results << data["execution_id"] if pending
|
|
56
|
+
end
|
|
57
|
+
results
|
|
58
|
+
end
|
|
59
|
+
|
|
21
60
|
def fetch(execution_id)
|
|
22
61
|
JSON.parse(File.read(path_for(execution_id)))
|
|
23
62
|
rescue Errno::ENOENT
|
|
@@ -37,6 +76,15 @@ module Igniter
|
|
|
37
76
|
def path_for(execution_id)
|
|
38
77
|
File.join(@root, "#{execution_id}.json")
|
|
39
78
|
end
|
|
79
|
+
|
|
80
|
+
def each_snapshot(&block)
|
|
81
|
+
Dir.glob(File.join(@root, "*.json")).each do |file|
|
|
82
|
+
data = JSON.parse(File.read(file))
|
|
83
|
+
block.call(data)
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
end
|
|
40
88
|
end
|
|
41
89
|
end
|
|
42
90
|
end
|
|
@@ -6,15 +6,68 @@ module Igniter
|
|
|
6
6
|
class MemoryStore
|
|
7
7
|
def initialize
|
|
8
8
|
@snapshots = {}
|
|
9
|
+
@correlation_index = {}
|
|
9
10
|
@mutex = Mutex.new
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
def save(snapshot)
|
|
13
|
+
def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Metrics/MethodLength
|
|
13
14
|
execution_id = snapshot[:execution_id] || snapshot["execution_id"]
|
|
14
|
-
@mutex.synchronize
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
@snapshots[execution_id] = deep_copy(snapshot)
|
|
17
|
+
if graph
|
|
18
|
+
@correlation_index[execution_id] = {
|
|
19
|
+
graph: graph,
|
|
20
|
+
correlation: (correlation || {}).transform_keys(&:to_sym)
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
15
24
|
execution_id
|
|
16
25
|
end
|
|
17
26
|
|
|
27
|
+
def find_by_correlation(graph:, correlation:)
|
|
28
|
+
normalized = correlation.transform_keys(&:to_sym)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@correlation_index.each do |execution_id, entry|
|
|
31
|
+
next unless entry[:graph] == graph
|
|
32
|
+
return execution_id if entry[:correlation] == normalized
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def list_all(graph: nil)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
if graph
|
|
41
|
+
@correlation_index.select { |_id, entry| entry[:graph] == graph }.keys
|
|
42
|
+
else
|
|
43
|
+
@snapshots.keys
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def list_pending(graph: nil) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
49
|
+
ids = @mutex.synchronize do
|
|
50
|
+
if graph
|
|
51
|
+
@correlation_index.select { |_id, entry| entry[:graph] == graph }.keys
|
|
52
|
+
else
|
|
53
|
+
@snapshots.keys
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
ids.select do |id|
|
|
59
|
+
snapshot = @snapshots[id]
|
|
60
|
+
next false unless snapshot
|
|
61
|
+
|
|
62
|
+
states = snapshot[:states] || snapshot["states"] || {}
|
|
63
|
+
states.any? do |_name, state|
|
|
64
|
+
status = state[:status] || state["status"]
|
|
65
|
+
status.to_s == "pending"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
18
71
|
def fetch(execution_id)
|
|
19
72
|
@mutex.synchronize { deep_copy(@snapshots.fetch(execution_id)) }
|
|
20
73
|
rescue KeyError
|
|
@@ -11,12 +11,24 @@ module Igniter
|
|
|
11
11
|
@namespace = namespace
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def save(snapshot)
|
|
14
|
+
def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
15
|
execution_id = snapshot[:execution_id] || snapshot["execution_id"]
|
|
16
16
|
@redis.set(redis_key(execution_id), JSON.generate(snapshot))
|
|
17
17
|
execution_id
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
def find_by_correlation(graph:, correlation:)
|
|
21
|
+
raise NotImplementedError, "find_by_correlation is not implemented for RedisStore"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def list_all(graph: nil)
|
|
25
|
+
raise NotImplementedError, "list_all is not implemented for RedisStore"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def list_pending(graph: nil)
|
|
29
|
+
raise NotImplementedError, "list_pending is not implemented for RedisStore"
|
|
30
|
+
end
|
|
31
|
+
|
|
20
32
|
def fetch(execution_id)
|
|
21
33
|
payload = @redis.get(redis_key(execution_id))
|
|
22
34
|
raise Igniter::ResolutionError, "No execution snapshot found for '#{execution_id}'" unless payload
|
|
@@ -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
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Saga
|
|
5
|
+
# Immutable result of a saga execution (resolve_saga call).
|
|
6
|
+
#
|
|
7
|
+
# Attributes:
|
|
8
|
+
# contract — the contract instance that was executed
|
|
9
|
+
# error — Igniter::Error that caused failure (nil on success)
|
|
10
|
+
# failed_node — Symbol name of the first node that failed (nil on success)
|
|
11
|
+
# compensations — Array<CompensationRecord> for all attempted compensations
|
|
12
|
+
class Result
|
|
13
|
+
attr_reader :contract, :error, :failed_node, :compensations
|
|
14
|
+
|
|
15
|
+
def initialize(success:, contract:, error: nil, failed_node: nil, compensations: [])
|
|
16
|
+
@success = success
|
|
17
|
+
@contract = contract
|
|
18
|
+
@error = error
|
|
19
|
+
@failed_node = failed_node
|
|
20
|
+
@compensations = compensations.freeze
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def success? = @success
|
|
25
|
+
def failed? = !@success
|
|
26
|
+
|
|
27
|
+
# Human-readable saga report.
|
|
28
|
+
def explain
|
|
29
|
+
Formatter.format(self)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
alias to_s explain
|
|
33
|
+
|
|
34
|
+
# Structured (serialisable) representation.
|
|
35
|
+
def to_h
|
|
36
|
+
{
|
|
37
|
+
success: success?,
|
|
38
|
+
failed_node: failed_node,
|
|
39
|
+
error: error&.message,
|
|
40
|
+
compensations: compensations.map do |rec|
|
|
41
|
+
{ node: rec.node_name, success: rec.success?, error: rec.error&.message }
|
|
42
|
+
end
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|