igniter 0.3.1 → 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/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- data/examples/distributed_workflow.rb +52 -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/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/dsl/contract_builder.rb +42 -4
- data/lib/igniter/errors.rb +6 -1
- 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/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 +43 -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/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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00ad08d39fff726f2d88cefe18758dca71dc7b6fbccce872199347e23bf86e9a
|
|
4
|
+
data.tar.gz: 42791bdf591fbab256bb32a7da1b37466317e0c190d3693830ee82885f3fe238
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 45034ac4e94ee4c1864a8ca5222505d2192c3cf4667c1ba6149161cf15079c7000a80c75e2dad6baafb79dfde0b9cb5657fd0ffd2d869f3600210ec3429268a9
|
|
7
|
+
data.tar.gz: 4f797eb253d945fd459f2ef29b64a4330616ecfcc0d8d6f1dfbbf98da24bfe090da6e6c28cda2ddf8203f71c52af98ec275cb157829febd032ab9846ccc883e8
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# Distributed Contracts v1
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
`Distributed Contracts` extend `igniter` from in-process orchestration to long-running, event-driven workflow execution.
|
|
6
|
+
|
|
7
|
+
The feature should make distributed workflows:
|
|
8
|
+
|
|
9
|
+
- explicit
|
|
10
|
+
- correlation-aware
|
|
11
|
+
- resumable
|
|
12
|
+
- observable end to end
|
|
13
|
+
- compatible with the existing graph model
|
|
14
|
+
|
|
15
|
+
It should avoid pushing cross-system workflow state into ad hoc service objects, background jobs, or loosely related tables.
|
|
16
|
+
|
|
17
|
+
## Problem Shape
|
|
18
|
+
|
|
19
|
+
Typical legacy pain looks like this:
|
|
20
|
+
|
|
21
|
+
- one business flow starts from an inbound request
|
|
22
|
+
- later signals arrive from different systems
|
|
23
|
+
- signals must be matched by business identity, not by process memory
|
|
24
|
+
- some steps complete immediately, others wait minutes or hours
|
|
25
|
+
- failures are not local, they are causal and temporal
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
|
|
29
|
+
- vendor request -> callrail webhook -> ringcentral webhook -> operator match -> order creation -> billing decision -> invoice
|
|
30
|
+
- lead accepted now, enrichment later
|
|
31
|
+
- call started now, attribution resolved later
|
|
32
|
+
|
|
33
|
+
`igniter` already has useful building blocks:
|
|
34
|
+
|
|
35
|
+
- `pending`
|
|
36
|
+
- snapshot / restore
|
|
37
|
+
- store-backed execution
|
|
38
|
+
- token-based resume
|
|
39
|
+
- diagnostics and audit
|
|
40
|
+
|
|
41
|
+
Distributed Contracts v1 should formalize these into a workflow model.
|
|
42
|
+
|
|
43
|
+
## Core Principles
|
|
44
|
+
|
|
45
|
+
### Correlation first
|
|
46
|
+
|
|
47
|
+
The primary question is not:
|
|
48
|
+
|
|
49
|
+
- "which service should run next?"
|
|
50
|
+
|
|
51
|
+
It is:
|
|
52
|
+
|
|
53
|
+
- "which execution does this external signal belong to?"
|
|
54
|
+
|
|
55
|
+
Distributed execution must be resumable by stable business correlation keys.
|
|
56
|
+
|
|
57
|
+
### External signals are graph-visible
|
|
58
|
+
|
|
59
|
+
Waiting for an external system should be represented in the graph model, not hidden inside generic polling or callback code.
|
|
60
|
+
|
|
61
|
+
### Transport stays outside
|
|
62
|
+
|
|
63
|
+
Controllers, jobs, consumers, and webhook adapters remain outside `igniter`.
|
|
64
|
+
|
|
65
|
+
They should translate transport concerns into workflow operations such as:
|
|
66
|
+
|
|
67
|
+
- start workflow
|
|
68
|
+
- deliver signal
|
|
69
|
+
- resume execution
|
|
70
|
+
|
|
71
|
+
`igniter` should not become a message bus.
|
|
72
|
+
|
|
73
|
+
## Proposed v1 Surface
|
|
74
|
+
|
|
75
|
+
### Starting a distributed workflow
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
execution = CallLifecycleContract.start(
|
|
79
|
+
request_id: "req-123",
|
|
80
|
+
company_id: "42",
|
|
81
|
+
vendor_lead_id: "lead-77"
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This is conceptually similar to `new(...).resolve`, but v1 should expose a clearer workflow-oriented entry point.
|
|
86
|
+
|
|
87
|
+
### Correlation metadata
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class CallLifecycleContract < Igniter::Contract
|
|
91
|
+
correlate_by :request_id, :company_id, :vendor_lead_id
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This should define the business keys used to find the execution later.
|
|
96
|
+
|
|
97
|
+
Suggested v1 requirements:
|
|
98
|
+
|
|
99
|
+
- correlation keys are declared explicitly
|
|
100
|
+
- correlation values come from initial inputs and/or delivered signals
|
|
101
|
+
- the execution store can index and look up executions by correlation values
|
|
102
|
+
|
|
103
|
+
### Awaiting an external signal
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
await :callrail_call,
|
|
107
|
+
event: :callrail_webhook_received
|
|
108
|
+
|
|
109
|
+
await :ringcentral_match,
|
|
110
|
+
event: :ringcentral_call_matched
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This is preferable to generic `defer` for distributed flows because it makes the waiting state explicit in the graph.
|
|
114
|
+
|
|
115
|
+
### Delivering an external signal
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
CallLifecycleContract.deliver_event(
|
|
119
|
+
:callrail_webhook_received,
|
|
120
|
+
correlation: { request_id: "req-123", company_id: "42" },
|
|
121
|
+
payload: { call_id: "cr-1", started_at: "2026-03-19T10:00:00Z" }
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Expected behavior:
|
|
126
|
+
|
|
127
|
+
1. Find the matching execution by correlation.
|
|
128
|
+
2. Find a matching `await` node for the event.
|
|
129
|
+
3. Bind the payload to that waiting node.
|
|
130
|
+
4. Resume the workflow.
|
|
131
|
+
|
|
132
|
+
### Diagnostics for waiting workflows
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
contract.diagnostics.to_h
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Should make waiting state explicit:
|
|
139
|
+
|
|
140
|
+
- current status
|
|
141
|
+
- known correlation keys
|
|
142
|
+
- expected external events
|
|
143
|
+
- last delivered event
|
|
144
|
+
- current blocked / waiting nodes
|
|
145
|
+
|
|
146
|
+
## Scope of v1
|
|
147
|
+
|
|
148
|
+
Supported:
|
|
149
|
+
|
|
150
|
+
- explicit correlation keys
|
|
151
|
+
- explicit external wait nodes
|
|
152
|
+
- event delivery by correlation
|
|
153
|
+
- store-backed lookup and resume
|
|
154
|
+
- diagnostics for waiting state
|
|
155
|
+
- audit trail of delivered external signals
|
|
156
|
+
|
|
157
|
+
Not supported in v1:
|
|
158
|
+
|
|
159
|
+
- message bus integration
|
|
160
|
+
- retries / dead-letter routing
|
|
161
|
+
- event schema registry
|
|
162
|
+
- compensation / saga semantics
|
|
163
|
+
- multi-execution joins
|
|
164
|
+
- time-based wakeups or cron waits
|
|
165
|
+
- out-of-order signal reconciliation beyond simple correlation lookup
|
|
166
|
+
|
|
167
|
+
## DSL
|
|
168
|
+
|
|
169
|
+
### Basic shape
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class CallLifecycleContract < Igniter::Contract
|
|
173
|
+
correlate_by :request_id, :company_id, :vendor_lead_id
|
|
174
|
+
|
|
175
|
+
define do
|
|
176
|
+
input :request_id, type: :string
|
|
177
|
+
input :company_id, type: :string
|
|
178
|
+
input :vendor_lead_id, type: :string
|
|
179
|
+
|
|
180
|
+
await :callrail_call, event: :callrail_webhook_received
|
|
181
|
+
await :ringcentral_match, event: :ringcentral_call_matched
|
|
182
|
+
|
|
183
|
+
aggregate :billing_context, with: %i[callrail_call ringcentral_match] do |callrail_call:, ringcentral_match:|
|
|
184
|
+
{
|
|
185
|
+
call_id: callrail_call[:call_id],
|
|
186
|
+
operator_id: ringcentral_match[:operator_id],
|
|
187
|
+
channel: ringcentral_match[:channel]
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
output :billing_context
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Signal delivery
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
CallLifecycleContract.deliver_event(
|
|
200
|
+
:ringcentral_call_matched,
|
|
201
|
+
correlation: {
|
|
202
|
+
request_id: "req-123",
|
|
203
|
+
company_id: "42",
|
|
204
|
+
vendor_lead_id: "lead-77"
|
|
205
|
+
},
|
|
206
|
+
payload: {
|
|
207
|
+
operator_id: 55,
|
|
208
|
+
channel: "paid_search"
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Why `await` instead of reusing generic `defer`
|
|
214
|
+
|
|
215
|
+
`defer` is useful as a low-level runtime primitive.
|
|
216
|
+
|
|
217
|
+
But distributed workflows need more explicit semantics:
|
|
218
|
+
|
|
219
|
+
- what event is being awaited
|
|
220
|
+
- how to resume it
|
|
221
|
+
- how to show it in diagnostics
|
|
222
|
+
- how to route external signals safely
|
|
223
|
+
|
|
224
|
+
`await` should be modeled as a graph primitive, not just a custom executor returning `DeferredResult`.
|
|
225
|
+
|
|
226
|
+
## Runtime Semantics
|
|
227
|
+
|
|
228
|
+
### Starting execution
|
|
229
|
+
|
|
230
|
+
1. Create a normal execution.
|
|
231
|
+
2. Persist workflow metadata:
|
|
232
|
+
- execution id
|
|
233
|
+
- graph name
|
|
234
|
+
- correlation keys
|
|
235
|
+
- workflow status
|
|
236
|
+
3. Resolve until:
|
|
237
|
+
- success
|
|
238
|
+
- failure
|
|
239
|
+
- waiting on one or more `await` nodes
|
|
240
|
+
|
|
241
|
+
### Await resolution
|
|
242
|
+
|
|
243
|
+
When an `await` node is reached:
|
|
244
|
+
|
|
245
|
+
1. Mark node status as `waiting_for_event`
|
|
246
|
+
2. Persist expected event metadata
|
|
247
|
+
3. Stop downstream execution until signal arrives
|
|
248
|
+
|
|
249
|
+
### Event delivery
|
|
250
|
+
|
|
251
|
+
When `deliver_event` is called:
|
|
252
|
+
|
|
253
|
+
1. Resolve execution lookup by correlation
|
|
254
|
+
2. Validate event name
|
|
255
|
+
3. Match event to a waiting `await` node
|
|
256
|
+
4. Persist audit metadata for the incoming signal
|
|
257
|
+
5. Resume execution with delivered payload
|
|
258
|
+
|
|
259
|
+
### Terminal states
|
|
260
|
+
|
|
261
|
+
Suggested workflow-level statuses:
|
|
262
|
+
|
|
263
|
+
- `running`
|
|
264
|
+
- `waiting`
|
|
265
|
+
- `succeeded`
|
|
266
|
+
- `failed`
|
|
267
|
+
|
|
268
|
+
Later possible additions:
|
|
269
|
+
|
|
270
|
+
- `cancelled`
|
|
271
|
+
- `timed_out`
|
|
272
|
+
- `abandoned`
|
|
273
|
+
|
|
274
|
+
## Graph Model
|
|
275
|
+
|
|
276
|
+
Distributed Contracts v1 should introduce a dedicated node kind:
|
|
277
|
+
|
|
278
|
+
- `:await`
|
|
279
|
+
|
|
280
|
+
Suggested internal shape:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
AwaitNode.new(
|
|
284
|
+
name: :ringcentral_match,
|
|
285
|
+
event: :ringcentral_call_matched
|
|
286
|
+
)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
This should not be modeled as generic compute.
|
|
290
|
+
|
|
291
|
+
## Correlation Model
|
|
292
|
+
|
|
293
|
+
Correlation should be explicit and inspectable.
|
|
294
|
+
|
|
295
|
+
Suggested v1 API:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
correlate_by :request_id, :company_id, :vendor_lead_id
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Suggested stored metadata:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
{
|
|
305
|
+
request_id: "req-123",
|
|
306
|
+
company_id: "42",
|
|
307
|
+
vendor_lead_id: "lead-77"
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
V1 should assume exact equality matching.
|
|
312
|
+
|
|
313
|
+
Future versions may allow:
|
|
314
|
+
|
|
315
|
+
- computed correlation values
|
|
316
|
+
- partial lookup
|
|
317
|
+
- secondary indexes
|
|
318
|
+
|
|
319
|
+
## Store Requirements
|
|
320
|
+
|
|
321
|
+
The execution store needs more than snapshot persistence.
|
|
322
|
+
|
|
323
|
+
Distributed Contracts v1 should require:
|
|
324
|
+
|
|
325
|
+
- fetch by `execution_id`
|
|
326
|
+
- save snapshot
|
|
327
|
+
- delete snapshot
|
|
328
|
+
- lookup execution ids by correlation keys
|
|
329
|
+
- persist workflow metadata
|
|
330
|
+
|
|
331
|
+
Conceptually:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
store.save(snapshot:, metadata:)
|
|
335
|
+
store.find_by_correlation(graph: "CallLifecycleContract", correlation: { ... })
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
This probably means a new workflow-aware store interface, not just extending the current minimal snapshot store informally.
|
|
339
|
+
|
|
340
|
+
## Compile-Time Validation
|
|
341
|
+
|
|
342
|
+
The compiler should validate:
|
|
343
|
+
|
|
344
|
+
- declared correlation keys exist as contract inputs
|
|
345
|
+
- `await` node names are unique
|
|
346
|
+
- awaited event names are unique within a graph unless explicitly allowed
|
|
347
|
+
- `await` nodes are valid dependencies for downstream nodes
|
|
348
|
+
|
|
349
|
+
Suggested v1 restriction:
|
|
350
|
+
|
|
351
|
+
- one `await` node per event name within a contract
|
|
352
|
+
|
|
353
|
+
This keeps signal delivery unambiguous.
|
|
354
|
+
|
|
355
|
+
## Runtime Validation
|
|
356
|
+
|
|
357
|
+
At runtime, delivery should validate:
|
|
358
|
+
|
|
359
|
+
- the target execution exists
|
|
360
|
+
- the execution belongs to the expected contract graph
|
|
361
|
+
- the event is currently awaited
|
|
362
|
+
- the same awaited signal is not delivered twice unless idempotency policy allows it
|
|
363
|
+
|
|
364
|
+
Suggested runtime errors:
|
|
365
|
+
|
|
366
|
+
- `Igniter::WorkflowNotFoundError`
|
|
367
|
+
- `Igniter::AwaitedEventMismatchError`
|
|
368
|
+
- `Igniter::DuplicateSignalError`
|
|
369
|
+
|
|
370
|
+
## Events and Audit
|
|
371
|
+
|
|
372
|
+
Distributed Contracts v1 should add workflow-aware events.
|
|
373
|
+
|
|
374
|
+
Suggested additions:
|
|
375
|
+
|
|
376
|
+
- `workflow_started`
|
|
377
|
+
- `workflow_waiting`
|
|
378
|
+
- `workflow_resumed`
|
|
379
|
+
- `external_event_received`
|
|
380
|
+
- `await_satisfied`
|
|
381
|
+
|
|
382
|
+
Suggested payload for incoming external signal:
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
{
|
|
386
|
+
event: :ringcentral_call_matched,
|
|
387
|
+
correlation: {
|
|
388
|
+
request_id: "req-123",
|
|
389
|
+
company_id: "42",
|
|
390
|
+
vendor_lead_id: "lead-77"
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Audit should make it possible to answer:
|
|
396
|
+
|
|
397
|
+
- which external events were received
|
|
398
|
+
- in what order
|
|
399
|
+
- which waiting node they satisfied
|
|
400
|
+
- why the workflow is still blocked
|
|
401
|
+
|
|
402
|
+
## Introspection
|
|
403
|
+
|
|
404
|
+
### Graph
|
|
405
|
+
|
|
406
|
+
`await` nodes should render distinctly from compute, branch, collection, and composition.
|
|
407
|
+
|
|
408
|
+
The graph should show:
|
|
409
|
+
|
|
410
|
+
- awaited event name
|
|
411
|
+
- correlation keys at the workflow level
|
|
412
|
+
|
|
413
|
+
### Plan
|
|
414
|
+
|
|
415
|
+
Before execution:
|
|
416
|
+
|
|
417
|
+
- `await` nodes look like normal pending nodes
|
|
418
|
+
|
|
419
|
+
When waiting:
|
|
420
|
+
|
|
421
|
+
- plan should show explicit waiting state
|
|
422
|
+
- ideally also the awaited event name
|
|
423
|
+
|
|
424
|
+
### Diagnostics
|
|
425
|
+
|
|
426
|
+
Diagnostics should surface:
|
|
427
|
+
|
|
428
|
+
- workflow status
|
|
429
|
+
- correlation keys
|
|
430
|
+
- awaited events
|
|
431
|
+
- satisfied events
|
|
432
|
+
- last external event
|
|
433
|
+
- blocked nodes
|
|
434
|
+
|
|
435
|
+
Example conceptual shape:
|
|
436
|
+
|
|
437
|
+
```ruby
|
|
438
|
+
{
|
|
439
|
+
status: :waiting,
|
|
440
|
+
correlation: {
|
|
441
|
+
request_id: "req-123",
|
|
442
|
+
company_id: "42"
|
|
443
|
+
},
|
|
444
|
+
waiting_on: [
|
|
445
|
+
{ node: :ringcentral_match, event: :ringcentral_call_matched }
|
|
446
|
+
],
|
|
447
|
+
last_external_event: :callrail_webhook_received
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
## Relation to Existing Pending Support
|
|
452
|
+
|
|
453
|
+
Distributed Contracts v1 should build on existing pending/store/resume support instead of replacing it.
|
|
454
|
+
|
|
455
|
+
Conceptually:
|
|
456
|
+
|
|
457
|
+
- `await` is a higher-level workflow primitive
|
|
458
|
+
- internally it may still use pending state and resume mechanics
|
|
459
|
+
- `deliver_event` is a safer, domain-oriented wrapper over generic token resume
|
|
460
|
+
|
|
461
|
+
This keeps the runtime coherent and incremental.
|
|
462
|
+
|
|
463
|
+
## Recommended Architectural Pattern
|
|
464
|
+
|
|
465
|
+
Do not start with one giant distributed contract from request to invoice.
|
|
466
|
+
|
|
467
|
+
Prefer stage-oriented workflow design:
|
|
468
|
+
|
|
469
|
+
- `InboundLeadContract`
|
|
470
|
+
- `CallAttributionContract`
|
|
471
|
+
- `OrderBillingContract`
|
|
472
|
+
- optional orchestration shell above them
|
|
473
|
+
|
|
474
|
+
This keeps contracts:
|
|
475
|
+
|
|
476
|
+
- understandable
|
|
477
|
+
- diagnosable
|
|
478
|
+
- evolvable
|
|
479
|
+
|
|
480
|
+
## Future Extensions
|
|
481
|
+
|
|
482
|
+
Possible later additions:
|
|
483
|
+
|
|
484
|
+
- event payload schema validation
|
|
485
|
+
- timeout / expiry policies on `await`
|
|
486
|
+
- idempotency keys for signal delivery
|
|
487
|
+
- compensation hooks
|
|
488
|
+
- `await_any` / `await_all`
|
|
489
|
+
- workflow versioning and migration
|
|
490
|
+
- cross-workflow linking
|
|
491
|
+
- visual workflow tracing
|
|
492
|
+
|
|
493
|
+
These should not be part of v1.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift File.join(__dir__, "../lib")
|
|
4
|
+
require "igniter"
|
|
5
|
+
|
|
6
|
+
store = Igniter::Runtime::Stores::MemoryStore.new
|
|
7
|
+
|
|
8
|
+
class LeadWorkflow < Igniter::Contract
|
|
9
|
+
correlate_by :request_id, :company_id
|
|
10
|
+
|
|
11
|
+
define do
|
|
12
|
+
input :request_id
|
|
13
|
+
input :company_id
|
|
14
|
+
|
|
15
|
+
await :crm_data, event: :crm_webhook_received
|
|
16
|
+
await :billing_data, event: :billing_data_fetched
|
|
17
|
+
|
|
18
|
+
aggregate :report, with: %i[crm_data billing_data] do |crm_data:, billing_data:|
|
|
19
|
+
{ crm: crm_data, billing: billing_data }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
output :report
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
puts "==> Starting execution..."
|
|
27
|
+
execution = LeadWorkflow.start(
|
|
28
|
+
{ request_id: "req-1", company_id: "co-42" },
|
|
29
|
+
store: store
|
|
30
|
+
)
|
|
31
|
+
puts "pending? #{execution.pending?}"
|
|
32
|
+
|
|
33
|
+
puts "\n==> Delivering CRM event..."
|
|
34
|
+
execution = LeadWorkflow.deliver_event(
|
|
35
|
+
:crm_webhook_received,
|
|
36
|
+
correlation: { request_id: "req-1", company_id: "co-42" },
|
|
37
|
+
payload: { name: "Acme Corp", tier: "enterprise" },
|
|
38
|
+
store: store
|
|
39
|
+
)
|
|
40
|
+
puts "still pending? #{execution.pending?}"
|
|
41
|
+
|
|
42
|
+
puts "\n==> Delivering billing event..."
|
|
43
|
+
execution = LeadWorkflow.deliver_event(
|
|
44
|
+
:billing_data_fetched,
|
|
45
|
+
correlation: { request_id: "req-1", company_id: "co-42" },
|
|
46
|
+
payload: { plan: "pro", mrr: 500 },
|
|
47
|
+
store: store
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
puts "\n==> Final result:"
|
|
51
|
+
puts "success? #{execution.success?}"
|
|
52
|
+
puts "report: #{execution.result.report.inspect}"
|
|
@@ -49,6 +49,10 @@ module Igniter
|
|
|
49
49
|
raise KeyError, "Unknown dependency '#{name}'"
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
def await_nodes
|
|
53
|
+
@nodes.select { |n| n.kind == :await }
|
|
54
|
+
end
|
|
55
|
+
|
|
52
56
|
def to_h
|
|
53
57
|
{
|
|
54
58
|
name: name,
|
|
@@ -80,6 +84,7 @@ module Igniter
|
|
|
80
84
|
base[:mode] = node.mode
|
|
81
85
|
base[:mapper] = node.input_mapper.to_s if node.input_mapper?
|
|
82
86
|
end
|
|
87
|
+
base[:event] = node.event_name if node.kind == :await
|
|
83
88
|
base
|
|
84
89
|
end,
|
|
85
90
|
outputs: outputs.map do |output|
|
|
@@ -117,6 +122,13 @@ module Igniter
|
|
|
117
122
|
metadata: node.metadata.reject { |key, _| key == :source_location }
|
|
118
123
|
}
|
|
119
124
|
end,
|
|
125
|
+
awaits: nodes.select { |node| node.kind == :await }.map do |node|
|
|
126
|
+
{
|
|
127
|
+
name: node.name,
|
|
128
|
+
event: node.event_name,
|
|
129
|
+
metadata: node.metadata.reject { |key, _| key == :source_location }
|
|
130
|
+
}
|
|
131
|
+
end,
|
|
120
132
|
branches: nodes.select { |node| node.kind == :branch }.map do |node|
|
|
121
133
|
{
|
|
122
134
|
name: node.name,
|
|
@@ -8,7 +8,9 @@ module Igniter
|
|
|
8
8
|
Validators::OutputsValidator,
|
|
9
9
|
Validators::DependenciesValidator,
|
|
10
10
|
Validators::TypeCompatibilityValidator,
|
|
11
|
-
Validators::CallableValidator
|
|
11
|
+
Validators::CallableValidator,
|
|
12
|
+
Validators::AwaitValidator,
|
|
13
|
+
Validators::RemoteValidator
|
|
12
14
|
].freeze
|
|
13
15
|
|
|
14
16
|
def self.call(context, validators: DEFAULT_VALIDATORS)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Compiler
|
|
5
|
+
module Validators
|
|
6
|
+
class AwaitValidator
|
|
7
|
+
def self.call(context)
|
|
8
|
+
new(context).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(context)
|
|
12
|
+
@context = context
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
await_nodes = @context.runtime_nodes.select { |n| n.kind == :await }
|
|
17
|
+
return if await_nodes.empty?
|
|
18
|
+
|
|
19
|
+
validate_correlation_keys_as_inputs!(await_nodes)
|
|
20
|
+
validate_unique_event_names!(await_nodes)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate_correlation_keys_as_inputs!(await_nodes) # rubocop:disable Metrics/AbcSize
|
|
26
|
+
correlation_keys = @context.graph.metadata[:correlation_keys] || []
|
|
27
|
+
return if correlation_keys.empty?
|
|
28
|
+
|
|
29
|
+
input_names = @context.runtime_nodes.select { |n| n.kind == :input }.map(&:name)
|
|
30
|
+
missing = correlation_keys.reject { |key| input_names.include?(key.to_sym) }
|
|
31
|
+
return if missing.empty?
|
|
32
|
+
|
|
33
|
+
raise @context.validation_error(
|
|
34
|
+
await_nodes.first,
|
|
35
|
+
"Correlation keys #{missing.inspect} must be declared as inputs"
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_unique_event_names!(await_nodes)
|
|
40
|
+
event_names = await_nodes.map(&:event_name)
|
|
41
|
+
duplicates = event_names.select { |e| event_names.count(e) > 1 }.uniq
|
|
42
|
+
return if duplicates.empty?
|
|
43
|
+
|
|
44
|
+
node = await_nodes.find { |n| duplicates.include?(n.event_name) }
|
|
45
|
+
raise @context.validation_error(
|
|
46
|
+
node,
|
|
47
|
+
"Duplicate await event names: #{duplicates.inspect}"
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -12,8 +12,10 @@ module Igniter
|
|
|
12
12
|
@context = context
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def call
|
|
15
|
+
def call # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
|
|
16
16
|
@context.runtime_nodes.each do |node|
|
|
17
|
+
next if node.kind == :await
|
|
18
|
+
|
|
17
19
|
validate_composition_node!(node) if node.kind == :composition
|
|
18
20
|
validate_branch_node!(node) if node.kind == :branch
|
|
19
21
|
validate_collection_node!(node) if node.kind == :collection
|
|
@@ -40,6 +42,7 @@ module Igniter
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
validate_composition_input_mapping!(node, contract_class.compiled_graph)
|
|
45
|
+
validate_composition_cycle!(node)
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
def validate_composition_input_mapping!(node, child_graph)
|
|
@@ -126,6 +129,43 @@ module Igniter
|
|
|
126
129
|
)
|
|
127
130
|
end
|
|
128
131
|
|
|
132
|
+
def validate_composition_cycle!(node)
|
|
133
|
+
child_contract = node.contract_class
|
|
134
|
+
return unless child_contract.respond_to?(:compiled_graph) && child_contract.compiled_graph
|
|
135
|
+
|
|
136
|
+
current_name = @context.graph.name
|
|
137
|
+
# Skip anonymous contracts to avoid false positives when multiple
|
|
138
|
+
# anonymous contracts share the same name "AnonymousContract"
|
|
139
|
+
return if current_name == "AnonymousContract"
|
|
140
|
+
|
|
141
|
+
validate_direct_cycle!(node, child_contract, current_name)
|
|
142
|
+
validate_grandchild_cycles!(node, child_contract, current_name)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate_direct_cycle!(node, child_contract, current_name)
|
|
146
|
+
return unless child_contract.compiled_graph.name == current_name
|
|
147
|
+
|
|
148
|
+
raise @context.validation_error(
|
|
149
|
+
node,
|
|
150
|
+
"Composition cycle: '#{node.name}' composes '#{child_contract.name}' " \
|
|
151
|
+
"which is the same contract ('#{current_name}')"
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def validate_grandchild_cycles!(node, child_contract, current_name) # rubocop:disable Metrics/AbcSize
|
|
156
|
+
child_contract.compiled_graph.nodes.select { |n| n.kind == :composition }.each do |grandchild|
|
|
157
|
+
next unless grandchild.contract_class.respond_to?(:compiled_graph)
|
|
158
|
+
next unless grandchild.contract_class.compiled_graph
|
|
159
|
+
next unless grandchild.contract_class.compiled_graph.name == current_name
|
|
160
|
+
|
|
161
|
+
raise @context.validation_error(
|
|
162
|
+
node,
|
|
163
|
+
"Composition cycle: '#{node.name}' -> '#{child_contract.name}' -> " \
|
|
164
|
+
"'#{grandchild.contract_class.name}' loops back to '#{current_name}'"
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
129
169
|
def validate_collection_node!(node)
|
|
130
170
|
unless node.contract_class.is_a?(Class) && node.contract_class <= Igniter::Contract
|
|
131
171
|
raise @context.validation_error(node, "Collection '#{node.name}' must reference an Igniter::Contract subclass")
|