relay-flow 0.2.4-alpha → 0.2.6-alpha
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.
- package/README.md +29 -18
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/beads_composition_test.go +3 -3
- package/cmd/relay-flow/main.go +94 -13
- package/cmd/relay-flow/scenario_test.go +19 -2
- package/cmd/relay-flow/serve.go +98 -19
- package/cmd/relay-flow/serve_recovery_test.go +100 -0
- package/cmd/relay-flow/temporal_init.go +170 -0
- package/cmd/relay-flow/temporal_init_test.go +217 -0
- package/cmd/relay-flow/temporal_report_test.go +733 -0
- package/examples/beads-workflow.yaml +3 -0
- package/examples/config-reference.yaml +7 -3
- package/examples/minimal-beads-task-workflow.yaml +2 -1
- package/examples/workflow-reference.yaml +2 -1
- package/go.mod +37 -16
- package/go.sum +129 -61
- package/internal/config/machine.go +33 -1
- package/internal/config/machine_test.go +76 -0
- package/internal/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/node_runtime_test.go +113 -4
- package/internal/execution/goworkflows/projection.go +47 -464
- package/internal/execution/projection/projection.go +867 -0
- package/internal/execution/projection/projection_test.go +347 -0
- package/internal/execution/temporal/activities.go +586 -0
- package/internal/execution/temporal/engine.go +384 -0
- package/internal/execution/temporal/engine_test.go +277 -0
- package/internal/execution/temporal/interpreter.go +736 -0
- package/internal/execution/temporal/operations.go +455 -0
- package/internal/execution/temporal/operations_test.go +101 -0
- package/internal/execution/temporal/recovery.go +194 -0
- package/internal/execution/temporal/recovery_runtime.go +41 -0
- package/internal/execution/temporal/recovery_test.go +102 -0
- package/internal/execution/temporal/snapshot_restart_test.go +72 -0
- package/internal/execution/temporal/spike_test.go +934 -0
- package/internal/execution/temporal/visibility_lag_test.go +415 -0
- package/internal/harness/harness.go +5 -0
- package/internal/harness/opencode/opencode.go +14 -3
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +57 -0
- package/internal/harness/pi/pi.go +58 -47
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/task_env_test.go +51 -0
- package/internal/harness/pi/validation_test.go +27 -51
- package/internal/repo/service.go +18 -8
- package/internal/runner/herdr/herdr.go +14 -0
- package/internal/runner/herdr/herdr_test.go +20 -0
- package/internal/runner/orca/orca.go +33 -0
- package/internal/runner/orca/orca_test.go +33 -4
- package/internal/runner/runner.go +8 -0
- package/internal/task/beads/agent_env_test.go +52 -0
- package/internal/task/beads/beads.go +87 -7
- package/internal/task/beads/beads_test.go +78 -9
- package/internal/task/beads/repo_composition_test.go +47 -3
- package/internal/task/factory.go +31 -2
- package/internal/task/task.go +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
package temporal
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/execution/projection"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
13
|
+
domainworkflow "github.com/rajpopat27/relay-flow/internal/workflow"
|
|
14
|
+
enumspb "go.temporal.io/api/enums/v1"
|
|
15
|
+
"go.temporal.io/api/serviceerror"
|
|
16
|
+
workflowpb "go.temporal.io/api/workflow/v1"
|
|
17
|
+
"go.temporal.io/sdk/client"
|
|
18
|
+
"go.temporal.io/sdk/converter"
|
|
19
|
+
"google.golang.org/protobuf/types/known/timestamppb"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
func (e *Engine) ready() (client.Client, error) {
|
|
23
|
+
e.mu.Lock()
|
|
24
|
+
defer e.mu.Unlock()
|
|
25
|
+
if e.client == nil {
|
|
26
|
+
return nil, errors.New("temporal engine is not connected")
|
|
27
|
+
}
|
|
28
|
+
return e.client, nil
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func isWorkflowNotFound(err error) bool {
|
|
32
|
+
var notFound *serviceerror.NotFound
|
|
33
|
+
return errors.As(err, ¬Found)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func isAlreadyStarted(err error) bool {
|
|
37
|
+
var alreadyStarted *serviceerror.WorkflowExecutionAlreadyStarted
|
|
38
|
+
return errors.As(err, &alreadyStarted)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func validateTemporalExecutionInfo(info *workflowpb.WorkflowExecutionInfo, expectedID run.ID) error {
|
|
42
|
+
if info == nil || info.Execution == nil || info.Type == nil {
|
|
43
|
+
return errors.New("Temporal execution identity is incomplete")
|
|
44
|
+
}
|
|
45
|
+
if expectedID != "" && info.Execution.WorkflowId != string(expectedID) {
|
|
46
|
+
return fmt.Errorf("Temporal execution Workflow ID %q does not match %q", info.Execution.WorkflowId, expectedID)
|
|
47
|
+
}
|
|
48
|
+
if info.Type.Name != TicketWorkflowName || info.TaskQueue != TaskQueue {
|
|
49
|
+
return fmt.Errorf("Temporal execution %q has unexpected type/task queue (%q/%q)", info.Execution.WorkflowId, info.Type.Name, info.TaskQueue)
|
|
50
|
+
}
|
|
51
|
+
return nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func (e *Engine) describe(ctx context.Context, id run.ID) (*client.WorkflowExecutionDescription, error) {
|
|
55
|
+
c, err := e.ready()
|
|
56
|
+
if err != nil {
|
|
57
|
+
return nil, err
|
|
58
|
+
}
|
|
59
|
+
return c.DescribeWorkflow(ctx, string(id), "")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func (e *Engine) describeInfo(ctx context.Context, id run.ID) (*workflowpb.WorkflowExecutionInfo, error) {
|
|
63
|
+
c, err := e.ready()
|
|
64
|
+
if err != nil {
|
|
65
|
+
return nil, err
|
|
66
|
+
}
|
|
67
|
+
response, err := c.DescribeWorkflowExecution(ctx, string(id), "")
|
|
68
|
+
if err != nil {
|
|
69
|
+
return nil, err
|
|
70
|
+
}
|
|
71
|
+
if response == nil || response.WorkflowExecutionInfo == nil {
|
|
72
|
+
return nil, fmt.Errorf("Temporal returned no execution info for %s", id)
|
|
73
|
+
}
|
|
74
|
+
return response.WorkflowExecutionInfo, nil
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// EnsureRun uses the relay run ID as the Temporal Workflow ID. Describe is
|
|
78
|
+
// performed before a new start so an ambiguous projection write or process
|
|
79
|
+
// crash cannot create a second execution.
|
|
80
|
+
func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
|
|
81
|
+
c, err := e.ready()
|
|
82
|
+
if err != nil {
|
|
83
|
+
return false, err
|
|
84
|
+
}
|
|
85
|
+
if start.LogicalID == "" {
|
|
86
|
+
start.LogicalID = run.ID(identity.LogicalRunID(start.ID))
|
|
87
|
+
}
|
|
88
|
+
if start.AttemptID == 0 {
|
|
89
|
+
start.AttemptID = 1
|
|
90
|
+
}
|
|
91
|
+
start.Runtime = e.runtime
|
|
92
|
+
|
|
93
|
+
local, localErr := e.runs.Get(ctx, start.ID)
|
|
94
|
+
if localErr != nil && !projection.IsNotFound(localErr) {
|
|
95
|
+
return false, localErr
|
|
96
|
+
}
|
|
97
|
+
info, describeErr := c.DescribeWorkflowExecution(ctx, string(start.ID), "")
|
|
98
|
+
if describeErr == nil && info != nil && info.WorkflowExecutionInfo != nil {
|
|
99
|
+
if err := validateTemporalExecutionInfo(info.WorkflowExecutionInfo, start.ID); err != nil {
|
|
100
|
+
return false, err
|
|
101
|
+
}
|
|
102
|
+
if projection.IsNotFound(localErr) {
|
|
103
|
+
if err := e.restoreProjection(ctx, info.WorkflowExecutionInfo); err != nil {
|
|
104
|
+
return false, fmt.Errorf("restore Temporal run %s projection: %w", start.ID, err)
|
|
105
|
+
}
|
|
106
|
+
local, localErr = e.runs.Get(ctx, start.ID)
|
|
107
|
+
}
|
|
108
|
+
status := info.WorkflowExecutionInfo.Status
|
|
109
|
+
if status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
|
|
110
|
+
if err := e.reconcileRunningTerminal(ctx, start, info.WorkflowExecutionInfo.Execution.RunId); err != nil {
|
|
111
|
+
return false, err
|
|
112
|
+
}
|
|
113
|
+
return false, nil
|
|
114
|
+
}
|
|
115
|
+
if localErr == nil && local.State != run.StateCompleted && local.State != run.StateCanceled {
|
|
116
|
+
state, finished := stateForTemporalStatus(status, info.WorkflowExecutionInfo.CloseTime)
|
|
117
|
+
if err := e.runs.UpdateState(ctx, local.ID, state, "", finished); err != nil {
|
|
118
|
+
return false, err
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false, nil
|
|
122
|
+
}
|
|
123
|
+
if describeErr != nil && !isWorkflowNotFound(describeErr) {
|
|
124
|
+
return false, fmt.Errorf("describe Temporal workflow %s: %w", start.ID, describeErr)
|
|
125
|
+
}
|
|
126
|
+
if localErr == nil && (local.State == run.StateCompleted || local.State == run.StateCanceled) {
|
|
127
|
+
return false, nil
|
|
128
|
+
}
|
|
129
|
+
if localErr == nil && local.State != run.StateStarting {
|
|
130
|
+
return false, fmt.Errorf("Temporal workflow %s is missing while projection state is %q; refusing replacement execution", start.ID, local.State)
|
|
131
|
+
}
|
|
132
|
+
if projection.IsNotFound(localErr) {
|
|
133
|
+
if err := e.runs.InsertStart(ctx, start, time.Now().UTC()); err != nil {
|
|
134
|
+
return false, fmt.Errorf("insert Temporal run %s: %w", start.ID, err)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
_, err = c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
|
138
|
+
ID: string(start.ID),
|
|
139
|
+
TaskQueue: TaskQueue,
|
|
140
|
+
WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY,
|
|
141
|
+
WorkflowExecutionErrorWhenAlreadyStarted: true,
|
|
142
|
+
}, TicketWorkflow, start)
|
|
143
|
+
if err != nil {
|
|
144
|
+
if isAlreadyStarted(err) {
|
|
145
|
+
return false, nil
|
|
146
|
+
}
|
|
147
|
+
return false, fmt.Errorf("start Temporal workflow %s: %w", start.ID, err)
|
|
148
|
+
}
|
|
149
|
+
return true, nil
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func (e *Engine) reconcileRunningTerminal(ctx context.Context, start run.Start, temporalRunID string) error {
|
|
153
|
+
state, err := e.queryRunState(ctx, start.ID, temporalRunID)
|
|
154
|
+
if err != nil {
|
|
155
|
+
return fmt.Errorf("query Temporal run state for terminal reconciliation %s: %w", start.ID, err)
|
|
156
|
+
}
|
|
157
|
+
if state.Run.State == run.StateCompleted || state.Run.State == run.StateCanceled || state.Run.State == run.StateCanceling || state.Run.CurrentNode == "" || state.Run.CurrentNode == "end" || state.Run.CurrentNodeVisitID == "" {
|
|
158
|
+
return nil
|
|
159
|
+
}
|
|
160
|
+
var binding NodeRuntimeBinding
|
|
161
|
+
for _, candidate := range state.RuntimeBindings {
|
|
162
|
+
if candidate.Node == state.Run.CurrentNode {
|
|
163
|
+
binding = candidate
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if binding.TerminalID != "" {
|
|
168
|
+
terminal, live, err := e.deps.Runner.FindTerminal(ctx, runner.Terminal{
|
|
169
|
+
ID: binding.TerminalID, Title: start.Ticket.Key + ":" + state.Run.CurrentNode,
|
|
170
|
+
})
|
|
171
|
+
if err != nil {
|
|
172
|
+
return fmt.Errorf("find current Temporal run terminal %s: %w", start.ID, err)
|
|
173
|
+
}
|
|
174
|
+
if live && terminal.ID != "" {
|
|
175
|
+
return nil
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
c, err := e.ready()
|
|
179
|
+
if err != nil {
|
|
180
|
+
return err
|
|
181
|
+
}
|
|
182
|
+
if err := c.SignalWorkflow(ctx, string(start.ID), temporalRunID, reconcileSignalName, struct{}{}); err != nil {
|
|
183
|
+
return fmt.Errorf("signal terminal reconciliation for %s: %w", start.ID, err)
|
|
184
|
+
}
|
|
185
|
+
return nil
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// SubmitReport validates against the workflow's current Temporal query state,
|
|
189
|
+
// then acknowledges only after the signal RPC has persisted the report in
|
|
190
|
+
// Temporal history. The SQLite receipt is only a fast-path cache.
|
|
191
|
+
func (e *Engine) SubmitReport(ctx context.Context, req run.ReportRequest) (run.ReportAck, error) {
|
|
192
|
+
c, err := e.ready()
|
|
193
|
+
if err != nil {
|
|
194
|
+
return run.ReportAck{}, err
|
|
195
|
+
}
|
|
196
|
+
state, err := e.queryReportState(ctx, req.RunID, req.ReportID)
|
|
197
|
+
if err != nil {
|
|
198
|
+
if info, describeErr := e.describeInfo(ctx, req.RunID); describeErr == nil && info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
|
|
199
|
+
return run.ReportAck{Accepted: true, Duplicate: true}, nil
|
|
200
|
+
}
|
|
201
|
+
return run.ReportAck{}, fmt.Errorf("query Temporal report state for %s: %w", req.RunID, err)
|
|
202
|
+
}
|
|
203
|
+
if state.Processed || state.State == run.StateCompleted || state.State == run.StateCanceled || state.CurrentNode != req.Node || state.CurrentNodeVisitID == "" {
|
|
204
|
+
return run.ReportAck{Accepted: true, Duplicate: true}, nil
|
|
205
|
+
}
|
|
206
|
+
wf, err := e.workflowFromHistory(ctx, req.RunID, "")
|
|
207
|
+
if err != nil {
|
|
208
|
+
return run.ReportAck{}, err
|
|
209
|
+
}
|
|
210
|
+
if err := wf.ValidateReport(req.Node, req.Report); err != nil {
|
|
211
|
+
return run.ReportAck{Accepted: false}, err
|
|
212
|
+
}
|
|
213
|
+
if err := c.SignalWorkflow(ctx, string(req.RunID), "", reportSignalName, reportSignal{
|
|
214
|
+
ReportID: req.ReportID, Node: req.Node, NodeVisitID: state.CurrentNodeVisitID, Report: req.Report,
|
|
215
|
+
}); err != nil {
|
|
216
|
+
if isWorkflowNotFound(err) {
|
|
217
|
+
if info, describeErr := e.describeInfo(ctx, req.RunID); describeErr == nil && info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
|
|
218
|
+
return run.ReportAck{Accepted: true, Duplicate: true}, nil
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return run.ReportAck{}, fmt.Errorf("signal Temporal report %s for %s: %w", req.ReportID, req.RunID, err)
|
|
222
|
+
}
|
|
223
|
+
return run.ReportAck{Accepted: true}, nil
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// CancelRun requests cancellation of the exact app Workflow ID. Cleanup and
|
|
227
|
+
// the final canceled projection state are performed by the workflow.
|
|
228
|
+
func (e *Engine) CancelRun(ctx context.Context, id run.ID, reason string) error {
|
|
229
|
+
current, err := e.runs.Get(ctx, id)
|
|
230
|
+
if err != nil {
|
|
231
|
+
if projection.IsNotFound(err) {
|
|
232
|
+
// A request for an older attempt must never be redirected to the
|
|
233
|
+
// current attempt. It is a stale no-op when a newer logical run is
|
|
234
|
+
// present, otherwise preserve the missing-run error.
|
|
235
|
+
logicalID := run.ID(identity.LogicalRunID(id))
|
|
236
|
+
if latest, lookupErr := e.runs.FindByLogicalID(ctx, logicalID); lookupErr == nil && latest.ID != id {
|
|
237
|
+
return nil
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return fmt.Errorf("resolve run %s: %w", id, err)
|
|
241
|
+
}
|
|
242
|
+
// Terminal and canceling attempts are fenced. In particular, explicit
|
|
243
|
+
// restart creates a distinct attempt ID and stale cancellation must not
|
|
244
|
+
// reach that newer Temporal execution.
|
|
245
|
+
if current.State == run.StateCompleted || current.State == run.StateCanceled || current.State == run.StateCanceling {
|
|
246
|
+
return nil
|
|
247
|
+
}
|
|
248
|
+
if err := e.runs.UpdateState(ctx, id, run.StateCanceling, reason, nil); err != nil {
|
|
249
|
+
return err
|
|
250
|
+
}
|
|
251
|
+
c, err := e.ready()
|
|
252
|
+
if err != nil {
|
|
253
|
+
return err
|
|
254
|
+
}
|
|
255
|
+
// Persist the operator's reason before requesting cancellation. The
|
|
256
|
+
// workflow consumes this internal signal during its disconnected cleanup;
|
|
257
|
+
// the public report/plugin wire remains unchanged.
|
|
258
|
+
if err := c.SignalWorkflow(ctx, string(id), "", cancelReasonSignalName, cancelReasonSignal{Reason: reason}); err != nil {
|
|
259
|
+
if isWorkflowNotFound(err) {
|
|
260
|
+
if info, describeErr := e.describeInfo(ctx, id); describeErr == nil && info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
|
|
261
|
+
state, finished := stateForTemporalStatus(info.Status, info.CloseTime)
|
|
262
|
+
return e.runs.UpdateState(ctx, id, state, reason, finished)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return fmt.Errorf("signal cancellation reason for Temporal workflow %s: %w", id, err)
|
|
266
|
+
}
|
|
267
|
+
if err := c.CancelWorkflowWithOptions(ctx, client.CancelWorkflowOptions{WorkflowID: string(id), Reason: reason}); err != nil {
|
|
268
|
+
if isWorkflowNotFound(err) {
|
|
269
|
+
if info, describeErr := e.describeInfo(ctx, id); describeErr == nil && info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
|
|
270
|
+
state, finished := stateForTemporalStatus(info.Status, info.CloseTime)
|
|
271
|
+
return e.runs.UpdateState(ctx, id, state, reason, finished)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return fmt.Errorf("cancel Temporal workflow %s: %w", id, err)
|
|
275
|
+
}
|
|
276
|
+
return nil
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func (e *Engine) queryReportState(ctx context.Context, id run.ID, reportID string) (ReportStateSnapshot, error) {
|
|
280
|
+
c, err := e.ready()
|
|
281
|
+
if err != nil {
|
|
282
|
+
return ReportStateSnapshot{}, err
|
|
283
|
+
}
|
|
284
|
+
encoded, err := c.QueryWorkflow(ctx, string(id), "", reportStateQuery, ReportStateQuery{ReportID: reportID})
|
|
285
|
+
if err != nil {
|
|
286
|
+
return ReportStateSnapshot{}, err
|
|
287
|
+
}
|
|
288
|
+
var state ReportStateSnapshot
|
|
289
|
+
if err := encoded.Get(&state); err != nil {
|
|
290
|
+
return ReportStateSnapshot{}, err
|
|
291
|
+
}
|
|
292
|
+
return state, nil
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func (e *Engine) queryRunState(ctx context.Context, id run.ID, temporalRunID string) (RunStateSnapshot, error) {
|
|
296
|
+
c, err := e.ready()
|
|
297
|
+
if err != nil {
|
|
298
|
+
return RunStateSnapshot{}, err
|
|
299
|
+
}
|
|
300
|
+
encoded, err := c.QueryWorkflow(ctx, string(id), temporalRunID, runStateQuery)
|
|
301
|
+
if err != nil {
|
|
302
|
+
return RunStateSnapshot{}, err
|
|
303
|
+
}
|
|
304
|
+
var state RunStateSnapshot
|
|
305
|
+
if err := encoded.Get(&state); err != nil {
|
|
306
|
+
return RunStateSnapshot{}, err
|
|
307
|
+
}
|
|
308
|
+
return state, nil
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
func (e *Engine) workflowFromHistory(ctx context.Context, id run.ID, temporalRunID string) (*domainworkflow.Workflow, error) {
|
|
312
|
+
c, err := e.ready()
|
|
313
|
+
if err != nil {
|
|
314
|
+
return nil, err
|
|
315
|
+
}
|
|
316
|
+
iterator := c.GetWorkflowHistory(ctx, string(id), temporalRunID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
|
|
317
|
+
for iterator.HasNext() {
|
|
318
|
+
event, err := iterator.Next()
|
|
319
|
+
if err != nil {
|
|
320
|
+
return nil, fmt.Errorf("read Temporal history for %s: %w", id, err)
|
|
321
|
+
}
|
|
322
|
+
if event.GetEventType() != enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED {
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
attrs := event.GetWorkflowExecutionStartedEventAttributes()
|
|
326
|
+
if attrs == nil || attrs.Input == nil {
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
var start run.Start
|
|
330
|
+
if err := converter.GetDefaultDataConverter().FromPayloads(attrs.Input, &start); err != nil {
|
|
331
|
+
return nil, fmt.Errorf("decode Temporal snapshot for %s: %w", id, err)
|
|
332
|
+
}
|
|
333
|
+
wf := start.Workflow
|
|
334
|
+
return &wf, nil
|
|
335
|
+
}
|
|
336
|
+
return nil, fmt.Errorf("Temporal workflow %s has no run.Start snapshot", id)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
func (e *Engine) workflowStart(ctx context.Context, id, temporalRunID string) (run.Start, error) {
|
|
340
|
+
c, err := e.ready()
|
|
341
|
+
if err != nil {
|
|
342
|
+
return run.Start{}, err
|
|
343
|
+
}
|
|
344
|
+
iterator := c.GetWorkflowHistory(ctx, id, temporalRunID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
|
|
345
|
+
for iterator.HasNext() {
|
|
346
|
+
event, err := iterator.Next()
|
|
347
|
+
if err != nil {
|
|
348
|
+
return run.Start{}, err
|
|
349
|
+
}
|
|
350
|
+
if event.GetEventType() != enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED {
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
attrs := event.GetWorkflowExecutionStartedEventAttributes()
|
|
354
|
+
if attrs == nil || attrs.Input == nil {
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
var start run.Start
|
|
358
|
+
if err := converter.GetDefaultDataConverter().FromPayloads(attrs.Input, &start); err != nil {
|
|
359
|
+
return run.Start{}, fmt.Errorf("decode Temporal run.Start %s: %w", id, err)
|
|
360
|
+
}
|
|
361
|
+
if start.ID == "" {
|
|
362
|
+
start.ID = run.ID(id)
|
|
363
|
+
}
|
|
364
|
+
if start.ID != run.ID(id) {
|
|
365
|
+
return run.Start{}, fmt.Errorf("Temporal snapshot ID %s does not match Workflow ID %s", start.ID, id)
|
|
366
|
+
}
|
|
367
|
+
if start.LogicalID == "" {
|
|
368
|
+
start.LogicalID = run.ID(identity.LogicalRunID(start.ID))
|
|
369
|
+
}
|
|
370
|
+
if start.AttemptID == 0 {
|
|
371
|
+
start.AttemptID = 1
|
|
372
|
+
}
|
|
373
|
+
return start, nil
|
|
374
|
+
}
|
|
375
|
+
return run.Start{}, fmt.Errorf("Temporal workflow %s has no WorkflowExecutionStarted snapshot", id)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func stateForTemporalStatus(status enumspb.WorkflowExecutionStatus, closeTime *timestamppb.Timestamp) (run.State, *time.Time) {
|
|
379
|
+
var finished *time.Time
|
|
380
|
+
if closeTime != nil {
|
|
381
|
+
t := closeTime.AsTime()
|
|
382
|
+
finished = &t
|
|
383
|
+
}
|
|
384
|
+
if status == enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED || status == enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED {
|
|
385
|
+
return run.StateCanceled, finished
|
|
386
|
+
}
|
|
387
|
+
return run.StateCompleted, finished
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// restoreProjection reconstructs the derived row and runtime bindings from
|
|
391
|
+
// Temporal state. It never calls a task-system mutation or starts a workflow.
|
|
392
|
+
func (e *Engine) restoreProjection(ctx context.Context, info *workflowpb.WorkflowExecutionInfo) error {
|
|
393
|
+
if err := validateTemporalExecutionInfo(info, ""); err != nil {
|
|
394
|
+
return err
|
|
395
|
+
}
|
|
396
|
+
id := run.ID(info.Execution.WorkflowId)
|
|
397
|
+
start, err := e.workflowStart(ctx, string(id), info.Execution.RunId)
|
|
398
|
+
if err != nil {
|
|
399
|
+
return err
|
|
400
|
+
}
|
|
401
|
+
started := time.Now().UTC()
|
|
402
|
+
if info.StartTime != nil {
|
|
403
|
+
started = info.StartTime.AsTime()
|
|
404
|
+
}
|
|
405
|
+
if err := e.runs.InsertStart(ctx, start, started); err != nil {
|
|
406
|
+
return err
|
|
407
|
+
}
|
|
408
|
+
state, queryErr := e.queryRunState(ctx, id, info.Execution.RunId)
|
|
409
|
+
if queryErr == nil {
|
|
410
|
+
if state.Run.CurrentNode != "" && state.Run.CurrentNodeVisitID != "" {
|
|
411
|
+
if err := e.runs.UpdateNode(ctx, id, state.Run.State, state.Run.CurrentNode, state.Run.CurrentNodeVisitID); err != nil {
|
|
412
|
+
return err
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
for _, binding := range state.RuntimeBindings {
|
|
416
|
+
if binding.Node == "" || binding.NodeVisitID == "" {
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
if err := e.runs.UpdateNodeRuntime(ctx, projection.NodeRuntime{
|
|
420
|
+
RunID: id, Node: binding.Node, TerminalID: binding.TerminalID,
|
|
421
|
+
SessionID: binding.SessionID, NodeVisitID: binding.NodeVisitID,
|
|
422
|
+
UpdatedAt: time.Now().UTC(),
|
|
423
|
+
}); err != nil {
|
|
424
|
+
return err
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if state.Run.State == run.StateCompleted || state.Run.State == run.StateCanceled {
|
|
428
|
+
finished := state.Run.FinishedAt
|
|
429
|
+
if finished == nil && info.CloseTime != nil {
|
|
430
|
+
closeTime := info.CloseTime.AsTime()
|
|
431
|
+
finished = &closeTime
|
|
432
|
+
}
|
|
433
|
+
return e.runs.UpdateState(ctx, id, state.Run.State, state.Run.LastError, finished)
|
|
434
|
+
}
|
|
435
|
+
if info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
|
|
436
|
+
stateValue, finished := stateForTemporalStatus(info.Status, info.CloseTime)
|
|
437
|
+
return e.runs.UpdateState(ctx, id, stateValue, state.Run.LastError, finished)
|
|
438
|
+
}
|
|
439
|
+
if err := e.runs.UpdateState(ctx, id, state.Run.State, state.Run.LastError, nil); err != nil {
|
|
440
|
+
return err
|
|
441
|
+
}
|
|
442
|
+
if err := e.runs.UpdateRetry(ctx, id, state.Run.Retry); err != nil {
|
|
443
|
+
return err
|
|
444
|
+
}
|
|
445
|
+
if err := e.rediscoverMissingTerminals(ctx, start, state); err != nil {
|
|
446
|
+
return err
|
|
447
|
+
}
|
|
448
|
+
return nil
|
|
449
|
+
}
|
|
450
|
+
stateValue, finished := stateForTemporalStatus(info.Status, info.CloseTime)
|
|
451
|
+
if info.Status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
|
|
452
|
+
return fmt.Errorf("query Temporal run-state for active workflow %s: %w", id, queryErr)
|
|
453
|
+
}
|
|
454
|
+
return e.runs.UpdateState(ctx, id, stateValue, "", finished)
|
|
455
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
package temporal
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/execution/projection"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
13
|
+
commonpb "go.temporal.io/api/common/v1"
|
|
14
|
+
workflowpb "go.temporal.io/api/workflow/v1"
|
|
15
|
+
_ "modernc.org/sqlite"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func TestValidateTemporalExecutionIdentity(t *testing.T) {
|
|
19
|
+
valid := func(workflowID, workflowType, taskQueue string) *workflowpb.WorkflowExecutionInfo {
|
|
20
|
+
return &workflowpb.WorkflowExecutionInfo{
|
|
21
|
+
Execution: &commonpb.WorkflowExecution{WorkflowId: workflowID},
|
|
22
|
+
Type: &commonpb.WorkflowType{Name: workflowType}, TaskQueue: taskQueue,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if err := validateTemporalExecutionInfo(valid("repo/wf/TICKET", TicketWorkflowName, TaskQueue), run.ID("repo/wf/TICKET")); err != nil {
|
|
26
|
+
t.Fatalf("valid execution identity: %v", err)
|
|
27
|
+
}
|
|
28
|
+
if err := validateTemporalExecutionInfo(valid("repo/wf/TICKET", "OtherWorkflow", TaskQueue), run.ID("repo/wf/TICKET")); err == nil {
|
|
29
|
+
t.Fatal("wrong workflow type was accepted")
|
|
30
|
+
}
|
|
31
|
+
if err := validateTemporalExecutionInfo(valid("repo/wf/TICKET", TicketWorkflowName, "other-queue"), run.ID("repo/wf/TICKET")); err == nil {
|
|
32
|
+
t.Fatal("wrong task queue was accepted")
|
|
33
|
+
}
|
|
34
|
+
if err := validateTemporalExecutionInfo(valid("other/wf/TICKET", TicketWorkflowName, TaskQueue), run.ID("repo/wf/TICKET")); err == nil {
|
|
35
|
+
t.Fatal("wrong Workflow ID was accepted")
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestProjectionBookkeepingErrorsPropagateToTemporalActivities(t *testing.T) {
|
|
40
|
+
path := t.TempDir() + "/state.db"
|
|
41
|
+
db, err := sql.Open("sqlite", path)
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
proj := &projection.RunProjection{DB: db}
|
|
46
|
+
if err := proj.Migrate(); err != nil {
|
|
47
|
+
t.Fatal(err)
|
|
48
|
+
}
|
|
49
|
+
if err := db.Close(); err != nil {
|
|
50
|
+
t.Fatal(err)
|
|
51
|
+
}
|
|
52
|
+
activities := &Activities{Runs: proj}
|
|
53
|
+
id := run.ID("repo/wf/TICKET")
|
|
54
|
+
if err := activities.ProjectionUpdateNode(context.Background(), id, run.StateWaiting, "node", "visit"); err == nil {
|
|
55
|
+
t.Fatal("ProjectionUpdateNode swallowed a closed-database error")
|
|
56
|
+
}
|
|
57
|
+
if err := activities.ProjectionUpdateNodeRuntimeVisit(context.Background(), id, "node", "visit"); err == nil {
|
|
58
|
+
t.Fatal("ProjectionUpdateNodeRuntimeVisit swallowed a closed-database error")
|
|
59
|
+
}
|
|
60
|
+
if err := activities.ProjectionRecordProcessedReport(context.Background(), id, "visit", "report"); err == nil {
|
|
61
|
+
t.Fatal("ProjectionRecordProcessedReport swallowed a closed-database error")
|
|
62
|
+
}
|
|
63
|
+
if err := activities.ProjectionUpdateState(context.Background(), id, run.StateWaiting, "", nil); err == nil {
|
|
64
|
+
t.Fatal("ProjectionUpdateState swallowed a closed-database error")
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func TestCancelRunFencesTerminalAttempt(t *testing.T) {
|
|
69
|
+
path := t.TempDir() + "/state.db"
|
|
70
|
+
db, err := sql.Open("sqlite", path)
|
|
71
|
+
if err != nil {
|
|
72
|
+
t.Fatal(err)
|
|
73
|
+
}
|
|
74
|
+
defer db.Close()
|
|
75
|
+
proj := &projection.RunProjection{DB: db}
|
|
76
|
+
if err := proj.Migrate(); err != nil {
|
|
77
|
+
t.Fatal(err)
|
|
78
|
+
}
|
|
79
|
+
start := run.Start{
|
|
80
|
+
ID: "repo/workflow/TICKET", Repo: "repo", Workflow: workflow.Workflow{Name: "workflow"},
|
|
81
|
+
Ticket: task.TicketRef{ID: "TICKET", Key: "TICKET"},
|
|
82
|
+
}
|
|
83
|
+
if err := proj.InsertStart(context.Background(), start, time.Now().UTC()); err != nil {
|
|
84
|
+
t.Fatal(err)
|
|
85
|
+
}
|
|
86
|
+
finished := time.Now().UTC()
|
|
87
|
+
if err := proj.UpdateState(context.Background(), start.ID, run.StateCanceled, "canceled", &finished); err != nil {
|
|
88
|
+
t.Fatal(err)
|
|
89
|
+
}
|
|
90
|
+
engine := &Engine{runs: proj}
|
|
91
|
+
if err := engine.CancelRun(context.Background(), start.ID, "stale cancellation"); err != nil {
|
|
92
|
+
t.Fatalf("CancelRun terminal attempt: %v", err)
|
|
93
|
+
}
|
|
94
|
+
got, err := proj.Get(context.Background(), start.ID)
|
|
95
|
+
if err != nil {
|
|
96
|
+
t.Fatal(err)
|
|
97
|
+
}
|
|
98
|
+
if got.State != run.StateCanceled || got.LastError != "canceled" {
|
|
99
|
+
t.Fatalf("terminal attempt changed: %+v", got)
|
|
100
|
+
}
|
|
101
|
+
}
|