igniter 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +2 -2
- data/docs/API_V2.md +58 -0
- data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- data/examples/README.md +3 -0
- data/examples/distributed_workflow.rb +52 -0
- data/examples/ringcentral_routing.rb +26 -35
- data/lib/igniter/compiler/compiled_graph.rb +20 -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/dependencies_validator.rb +43 -1
- data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
- data/lib/igniter/compiler.rb +2 -0
- data/lib/igniter/contract.rb +75 -8
- data/lib/igniter/diagnostics/report.rb +102 -3
- data/lib/igniter/dsl/contract_builder.rb +109 -8
- data/lib/igniter/errors.rb +6 -1
- data/lib/igniter/extensions/introspection/graph_formatter.rb +4 -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/model/await_node.rb +21 -0
- data/lib/igniter/model/branch_node.rb +9 -3
- data/lib/igniter/model/collection_node.rb +9 -3
- data/lib/igniter/model/remote_node.rb +26 -0
- data/lib/igniter/model.rb +2 -0
- data/lib/igniter/runtime/execution.rb +2 -2
- data/lib/igniter/runtime/input_validator.rb +5 -3
- data/lib/igniter/runtime/resolver.rb +91 -8
- 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/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/version.rb +1 -1
- data/lib/igniter.rb +4 -0
- metadata +36 -2
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
module Model
|
|
5
5
|
class BranchNode < Node
|
|
6
|
-
attr_reader :selector_dependency, :cases, :default_contract, :input_mapping
|
|
6
|
+
attr_reader :selector_dependency, :cases, :default_contract, :input_mapping, :context_dependencies, :input_mapper
|
|
7
7
|
|
|
8
|
-
def initialize(id:, name:, selector_dependency:, cases:, default_contract:, input_mapping:, path: nil, metadata: {})
|
|
9
|
-
dependencies = ([selector_dependency] + input_mapping.values).uniq
|
|
8
|
+
def initialize(id:, name:, selector_dependency:, cases:, default_contract:, input_mapping:, context_dependencies: [], input_mapper: nil, path: nil, metadata: {})
|
|
9
|
+
dependencies = ([selector_dependency] + input_mapping.values + context_dependencies).uniq
|
|
10
10
|
|
|
11
11
|
super(
|
|
12
12
|
id: id,
|
|
@@ -21,12 +21,18 @@ module Igniter
|
|
|
21
21
|
@cases = cases.map { |entry| normalize_case(entry) }.freeze
|
|
22
22
|
@default_contract = default_contract
|
|
23
23
|
@input_mapping = input_mapping.transform_keys(&:to_sym).transform_values(&:to_sym).freeze
|
|
24
|
+
@context_dependencies = Array(context_dependencies).map(&:to_sym).freeze
|
|
25
|
+
@input_mapper = input_mapper
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def possible_contracts
|
|
27
29
|
(cases.map { |entry| entry[:contract] } + [default_contract]).uniq
|
|
28
30
|
end
|
|
29
31
|
|
|
32
|
+
def input_mapper?
|
|
33
|
+
!input_mapper.nil?
|
|
34
|
+
end
|
|
35
|
+
|
|
30
36
|
private
|
|
31
37
|
|
|
32
38
|
def normalize_case(entry)
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
module Model
|
|
5
5
|
class CollectionNode < Node
|
|
6
|
-
attr_reader :source_dependency, :contract_class, :key_name, :mode
|
|
6
|
+
attr_reader :source_dependency, :contract_class, :key_name, :mode, :context_dependencies, :input_mapper
|
|
7
7
|
|
|
8
|
-
def initialize(id:, name:, source_dependency:, contract_class:, key_name:, mode:, path: nil, metadata: {})
|
|
8
|
+
def initialize(id:, name:, source_dependency:, contract_class:, key_name:, mode:, context_dependencies: [], input_mapper: nil, path: nil, metadata: {})
|
|
9
9
|
super(
|
|
10
10
|
id: id,
|
|
11
11
|
kind: :collection,
|
|
12
12
|
name: name,
|
|
13
13
|
path: (path || name),
|
|
14
|
-
dependencies: [source_dependency],
|
|
14
|
+
dependencies: [source_dependency, *context_dependencies],
|
|
15
15
|
metadata: metadata
|
|
16
16
|
)
|
|
17
17
|
|
|
@@ -19,6 +19,12 @@ module Igniter
|
|
|
19
19
|
@contract_class = contract_class
|
|
20
20
|
@key_name = key_name.to_sym
|
|
21
21
|
@mode = mode.to_sym
|
|
22
|
+
@context_dependencies = Array(context_dependencies).map(&:to_sym)
|
|
23
|
+
@input_mapper = input_mapper
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def input_mapper?
|
|
27
|
+
!input_mapper.nil?
|
|
22
28
|
end
|
|
23
29
|
end
|
|
24
30
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Model
|
|
5
|
+
# Represents a node that executes a contract on a remote igniter-server node.
|
|
6
|
+
# The result is the outputs hash returned by the remote contract.
|
|
7
|
+
class RemoteNode < Node
|
|
8
|
+
attr_reader :contract_name, :node_url, :input_mapping, :timeout
|
|
9
|
+
|
|
10
|
+
def initialize(id:, name:, contract_name:, node_url:, input_mapping:, timeout: 30, path: nil, metadata: {})
|
|
11
|
+
super(
|
|
12
|
+
id: id,
|
|
13
|
+
kind: :remote,
|
|
14
|
+
name: name,
|
|
15
|
+
path: path || name.to_s,
|
|
16
|
+
dependencies: input_mapping.values.map(&:to_sym),
|
|
17
|
+
metadata: metadata
|
|
18
|
+
)
|
|
19
|
+
@contract_name = contract_name.to_s
|
|
20
|
+
@node_url = node_url.to_s
|
|
21
|
+
@input_mapping = input_mapping.transform_keys(&:to_sym).transform_values(&:to_sym).freeze
|
|
22
|
+
@timeout = Integer(timeout)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/igniter/model.rb
CHANGED
|
@@ -8,6 +8,8 @@ require_relative "model/composition_node"
|
|
|
8
8
|
require_relative "model/branch_node"
|
|
9
9
|
require_relative "model/collection_node"
|
|
10
10
|
require_relative "model/output_node"
|
|
11
|
+
require_relative "model/await_node"
|
|
12
|
+
require_relative "model/remote_node"
|
|
11
13
|
|
|
12
14
|
module Igniter
|
|
13
15
|
module Model
|
|
@@ -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,10 @@ module Igniter
|
|
|
25
25
|
resolve_branch(node)
|
|
26
26
|
when :collection
|
|
27
27
|
resolve_collection(node)
|
|
28
|
+
when :await
|
|
29
|
+
resolve_await(node)
|
|
30
|
+
when :remote
|
|
31
|
+
resolve_remote(node)
|
|
28
32
|
else
|
|
29
33
|
raise ResolutionError, "Unsupported node kind: #{node.kind}"
|
|
30
34
|
end
|
|
@@ -59,6 +63,43 @@ module Igniter
|
|
|
59
63
|
NodeState.new(node: node, status: :succeeded, value: @execution.fetch_input!(node.name))
|
|
60
64
|
end
|
|
61
65
|
|
|
66
|
+
def resolve_await(node)
|
|
67
|
+
deferred = Runtime::DeferredResult.build(
|
|
68
|
+
payload: { event: node.event_name },
|
|
69
|
+
source_node: node.name,
|
|
70
|
+
waiting_on: node.name
|
|
71
|
+
)
|
|
72
|
+
raise PendingDependencyError.new(deferred, "Waiting for external event '#{node.event_name}'")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve_remote(node) # rubocop:disable Metrics/MethodLength
|
|
76
|
+
unless defined?(Igniter::Server::Client)
|
|
77
|
+
raise ResolutionError,
|
|
78
|
+
"remote: nodes require `require 'igniter/server'` (server integration not loaded)"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
inputs = node.input_mapping.each_with_object({}) do |(child_input, dep_name), memo|
|
|
82
|
+
memo[child_input] = resolve_dependency_value(dep_name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
client = Igniter::Server::Client.new(node.node_url, timeout: node.timeout)
|
|
86
|
+
response = client.execute(node.contract_name, inputs: inputs)
|
|
87
|
+
|
|
88
|
+
case response[:status]
|
|
89
|
+
when :succeeded
|
|
90
|
+
NodeState.new(node: node, status: :succeeded, value: response[:outputs])
|
|
91
|
+
when :failed
|
|
92
|
+
error_message = response.dig(:error, :message) || response.dig(:error, "message")
|
|
93
|
+
raise ResolutionError,
|
|
94
|
+
"Remote #{node.contract_name}@#{node.node_url}: #{error_message}"
|
|
95
|
+
else
|
|
96
|
+
raise ResolutionError,
|
|
97
|
+
"Remote #{node.contract_name}@#{node.node_url}: unexpected status '#{response[:status]}'"
|
|
98
|
+
end
|
|
99
|
+
rescue Igniter::Server::Client::ConnectionError => e
|
|
100
|
+
raise ResolutionError, "Cannot reach #{node.node_url}: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
|
|
62
103
|
def resolve_compute(node)
|
|
63
104
|
dependencies = node.dependencies.each_with_object({}) do |dependency_name, memo|
|
|
64
105
|
memo[dependency_name] = resolve_dependency_value(dependency_name)
|
|
@@ -123,10 +164,18 @@ module Igniter
|
|
|
123
164
|
|
|
124
165
|
raise BranchSelectionError, "Branch '#{node.name}' has no matching case and no default" unless selected_contract
|
|
125
166
|
|
|
126
|
-
|
|
127
|
-
memo[
|
|
167
|
+
context_values = node.context_dependencies.each_with_object({}) do |dependency_name, memo|
|
|
168
|
+
memo[dependency_name] = resolve_dependency_value(dependency_name)
|
|
128
169
|
end
|
|
129
170
|
|
|
171
|
+
child_inputs = if node.input_mapper?
|
|
172
|
+
map_branch_inputs(node, selector_value, context_values)
|
|
173
|
+
else
|
|
174
|
+
node.input_mapping.each_with_object({}) do |(child_input_name, dependency_name), memo|
|
|
175
|
+
memo[child_input_name] = resolve_dependency_value(dependency_name)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
130
179
|
@execution.events.emit(
|
|
131
180
|
:branch_selected,
|
|
132
181
|
node: node,
|
|
@@ -147,9 +196,22 @@ module Igniter
|
|
|
147
196
|
NodeState.new(node: node, status: :succeeded, value: child_contract.result)
|
|
148
197
|
end
|
|
149
198
|
|
|
199
|
+
def map_branch_inputs(node, selector_value, context_values)
|
|
200
|
+
mapper = node.input_mapper
|
|
201
|
+
|
|
202
|
+
if mapper.is_a?(Symbol) || mapper.is_a?(String)
|
|
203
|
+
return @execution.contract_instance.public_send(mapper, selector: selector_value, **context_values)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
mapper.call(selector: selector_value, **context_values)
|
|
207
|
+
end
|
|
208
|
+
|
|
150
209
|
def resolve_collection(node)
|
|
151
210
|
items = resolve_dependency_value(node.source_dependency)
|
|
152
|
-
|
|
211
|
+
context_values = node.context_dependencies.each_with_object({}) do |dependency_name, memo|
|
|
212
|
+
memo[dependency_name] = resolve_dependency_value(dependency_name)
|
|
213
|
+
end
|
|
214
|
+
normalized_items = normalize_collection_items(node, items, context_values)
|
|
153
215
|
collection_items = {}
|
|
154
216
|
|
|
155
217
|
normalized_items.each do |item_inputs|
|
|
@@ -289,7 +351,8 @@ module Igniter
|
|
|
289
351
|
node_id: node.id,
|
|
290
352
|
node_name: node.name,
|
|
291
353
|
node_path: node.path,
|
|
292
|
-
source_location: node.source_location
|
|
354
|
+
source_location: node.source_location,
|
|
355
|
+
execution_id: @execution.events.execution_id
|
|
293
356
|
}
|
|
294
357
|
)
|
|
295
358
|
end
|
|
@@ -310,7 +373,11 @@ module Igniter
|
|
|
310
373
|
)
|
|
311
374
|
end
|
|
312
375
|
|
|
313
|
-
def normalize_collection_items(node, items)
|
|
376
|
+
def normalize_collection_items(node, items, context_values = {})
|
|
377
|
+
if node.input_mapper? && items.is_a?(Hash)
|
|
378
|
+
items = items.to_a
|
|
379
|
+
end
|
|
380
|
+
|
|
314
381
|
unless items.is_a?(Array)
|
|
315
382
|
raise CollectionInputError.new(
|
|
316
383
|
"Collection '#{node.name}' expects an array, got #{items.class}",
|
|
@@ -318,7 +385,13 @@ module Igniter
|
|
|
318
385
|
)
|
|
319
386
|
end
|
|
320
387
|
|
|
321
|
-
|
|
388
|
+
mapped_items = if node.input_mapper?
|
|
389
|
+
items.map { |item| map_collection_item_inputs(node, item, context_values) }
|
|
390
|
+
else
|
|
391
|
+
items
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
mapped_items.each do |item|
|
|
322
395
|
next if item.is_a?(Hash)
|
|
323
396
|
|
|
324
397
|
raise CollectionInputError.new(
|
|
@@ -327,8 +400,18 @@ module Igniter
|
|
|
327
400
|
)
|
|
328
401
|
end
|
|
329
402
|
|
|
330
|
-
ensure_unique_collection_keys!(node,
|
|
331
|
-
|
|
403
|
+
ensure_unique_collection_keys!(node, mapped_items)
|
|
404
|
+
mapped_items.map { |item| item.transform_keys(&:to_sym) }
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def map_collection_item_inputs(node, item, context_values)
|
|
408
|
+
mapper = node.input_mapper
|
|
409
|
+
|
|
410
|
+
if mapper.is_a?(Symbol) || mapper.is_a?(String)
|
|
411
|
+
return @execution.contract_instance.public_send(mapper, item: item, **context_values)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
mapper.call(item: item, **context_values)
|
|
332
415
|
end
|
|
333
416
|
|
|
334
417
|
def extract_collection_key(node, item_inputs)
|
|
@@ -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,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Igniter
|
|
8
|
+
module Server
|
|
9
|
+
# HTTP client for calling remote igniter-server nodes.
|
|
10
|
+
# Uses only stdlib (Net::HTTP + JSON), no external gems required.
|
|
11
|
+
class Client
|
|
12
|
+
class Error < Igniter::Server::Error; end
|
|
13
|
+
class ConnectionError < Error; end
|
|
14
|
+
class RemoteError < Error; end
|
|
15
|
+
|
|
16
|
+
def initialize(base_url, timeout: 30)
|
|
17
|
+
@base_url = base_url.chomp("/")
|
|
18
|
+
@timeout = timeout
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Execute a contract on the remote node synchronously.
|
|
22
|
+
#
|
|
23
|
+
# Returns a symbolized hash:
|
|
24
|
+
# { status: :succeeded, execution_id: "uuid", outputs: { result: 42 } }
|
|
25
|
+
# { status: :failed, execution_id: "uuid", error: { message: "..." } }
|
|
26
|
+
# { status: :pending, execution_id: "uuid", waiting_for: ["event"] }
|
|
27
|
+
def execute(contract_name, inputs: {})
|
|
28
|
+
response = post(
|
|
29
|
+
"/v1/contracts/#{uri_encode(contract_name)}/execute",
|
|
30
|
+
{ inputs: inputs }
|
|
31
|
+
)
|
|
32
|
+
symbolize_response(response)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Deliver an event to a pending distributed workflow on the remote node.
|
|
36
|
+
def deliver_event(contract_name, event:, correlation:, payload: {})
|
|
37
|
+
response = post(
|
|
38
|
+
"/v1/contracts/#{uri_encode(contract_name)}/events",
|
|
39
|
+
{ event: event, correlation: correlation, payload: payload }
|
|
40
|
+
)
|
|
41
|
+
symbolize_response(response)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Fetch execution status by ID.
|
|
45
|
+
def status(execution_id)
|
|
46
|
+
symbolize_response(get("/v1/executions/#{uri_encode(execution_id)}"))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check remote node health.
|
|
50
|
+
def health
|
|
51
|
+
get("/v1/health")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def post(path, body)
|
|
57
|
+
uri = build_uri(path)
|
|
58
|
+
http = build_http(uri)
|
|
59
|
+
req = Net::HTTP::Post.new(uri.path, json_headers)
|
|
60
|
+
req.body = JSON.generate(body)
|
|
61
|
+
parse_response(http.request(req))
|
|
62
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
|
|
63
|
+
raise ConnectionError, "Cannot connect to #{@base_url}: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def get(path)
|
|
67
|
+
uri = build_uri(path)
|
|
68
|
+
http = build_http(uri)
|
|
69
|
+
req = Net::HTTP::Get.new(uri.path, json_headers)
|
|
70
|
+
parse_response(http.request(req))
|
|
71
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
|
|
72
|
+
raise ConnectionError, "Cannot connect to #{@base_url}: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_uri(path)
|
|
76
|
+
URI.parse("#{@base_url}#{path}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_http(uri)
|
|
80
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
81
|
+
http.use_ssl = uri.scheme == "https"
|
|
82
|
+
http.read_timeout = @timeout
|
|
83
|
+
http.open_timeout = 10
|
|
84
|
+
http
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def json_headers
|
|
88
|
+
{ "Content-Type" => "application/json", "Accept" => "application/json" }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_response(response)
|
|
92
|
+
body = begin
|
|
93
|
+
JSON.parse(response.body.to_s)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
{}
|
|
96
|
+
end
|
|
97
|
+
raise RemoteError, "Remote error #{response.code}: #{body["error"]}" unless response.is_a?(Net::HTTPSuccess)
|
|
98
|
+
|
|
99
|
+
body
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def symbolize_response(hash)
|
|
103
|
+
{
|
|
104
|
+
status: hash["status"]&.to_sym,
|
|
105
|
+
execution_id: hash["execution_id"],
|
|
106
|
+
outputs: symbolize_keys(hash["outputs"] || {}),
|
|
107
|
+
waiting_for: hash["waiting_for"] || [],
|
|
108
|
+
error: hash["error"]
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def symbolize_keys(hash)
|
|
113
|
+
return hash unless hash.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def uri_encode(str)
|
|
119
|
+
URI.encode_uri_component(str.to_s)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Server
|
|
5
|
+
class Config
|
|
6
|
+
attr_accessor :host, :port, :store, :logger
|
|
7
|
+
attr_reader :registry
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@host = "0.0.0.0"
|
|
11
|
+
@port = 4567
|
|
12
|
+
@store = Igniter::Runtime::Stores::MemoryStore.new
|
|
13
|
+
@registry = Registry.new
|
|
14
|
+
@logger = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register(name, contract_class)
|
|
18
|
+
@registry.register(name, contract_class)
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def contracts=(hash)
|
|
23
|
+
hash.each { |name, klass| register(name.to_s, klass) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|