relay-flow 0.2.4-alpha → 0.2.5-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 +20 -15
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- 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/config-reference.yaml +2 -2
- 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/engine.go +13 -38
- 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 +567 -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/opencode/opencode.go +3 -1
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +49 -46
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/validation_test.go +27 -51
- 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/package.json +1 -1
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
package temporal
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync"
|
|
10
|
+
"sync/atomic"
|
|
11
|
+
"testing"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/google/uuid"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
18
|
+
domainworkflow "github.com/rajpopat27/relay-flow/internal/workflow"
|
|
19
|
+
enumspb "go.temporal.io/api/enums/v1"
|
|
20
|
+
historypb "go.temporal.io/api/history/v1"
|
|
21
|
+
"go.temporal.io/api/serviceerror"
|
|
22
|
+
workflowservice "go.temporal.io/api/workflowservice/v1"
|
|
23
|
+
"go.temporal.io/sdk/activity"
|
|
24
|
+
"go.temporal.io/sdk/client"
|
|
25
|
+
"go.temporal.io/sdk/converter"
|
|
26
|
+
temporalSDK "go.temporal.io/sdk/temporal"
|
|
27
|
+
"go.temporal.io/sdk/testsuite"
|
|
28
|
+
temporalworker "go.temporal.io/sdk/worker"
|
|
29
|
+
temporalworkflow "go.temporal.io/sdk/workflow"
|
|
30
|
+
"google.golang.org/protobuf/types/known/durationpb"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const (
|
|
34
|
+
spikeTaskQueue = "relay-flow"
|
|
35
|
+
spikeWorkflowType = "TemporalCompatibilityWorkflow"
|
|
36
|
+
spikeReportSignalName = "report"
|
|
37
|
+
spikeReconcileSignalName = "reconcile"
|
|
38
|
+
spikeFinishSignalName = "finish"
|
|
39
|
+
spikeRunStateQuery = "relay-flow/run-state-v1"
|
|
40
|
+
spikeReportStateQueryName = "relay-flow/report-state-v1"
|
|
41
|
+
spikeProgressQueryName = "compatibility/progress-v1"
|
|
42
|
+
spikeRetention = 30 * 24 * time.Hour
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
// spikeInput deliberately contains only the immutable run snapshot and scalar
|
|
46
|
+
// control values. It must remain safe to serialize into Temporal history.
|
|
47
|
+
type spikeInput struct {
|
|
48
|
+
Snapshot run.Start `json:"snapshot"`
|
|
49
|
+
Mode string `json:"mode"`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type spikeReportSignal struct {
|
|
53
|
+
ReportID string `json:"reportId"`
|
|
54
|
+
Node string `json:"node"`
|
|
55
|
+
NodeVisitID string `json:"nodeVisitId"`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type spikeReconcileSignal struct {
|
|
59
|
+
Reason string `json:"reason"`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type spikeNodeRuntimeBinding struct {
|
|
63
|
+
Node string `json:"node"`
|
|
64
|
+
TerminalID string `json:"terminalId"`
|
|
65
|
+
SessionID string `json:"sessionId"`
|
|
66
|
+
NodeVisitID string `json:"nodeVisitId"`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type spikeRunStateSnapshot struct {
|
|
70
|
+
Run run.Run `json:"run"`
|
|
71
|
+
RuntimeBindings []spikeNodeRuntimeBinding `json:"runtimeBindings"`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type spikeReportStateQuery struct {
|
|
75
|
+
ReportID string `json:"reportId"`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type spikeReportStateSnapshot struct {
|
|
79
|
+
CurrentNode string `json:"currentNode"`
|
|
80
|
+
CurrentNodeVisitID string `json:"currentNodeVisitId"`
|
|
81
|
+
State run.State `json:"state"`
|
|
82
|
+
Processed bool `json:"processed"`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// spikeProgressSnapshot is test-only observability. The two fixed query
|
|
86
|
+
// contracts above intentionally do not expose unbounded report history or
|
|
87
|
+
// implementation counters.
|
|
88
|
+
type spikeProgressSnapshot struct {
|
|
89
|
+
ReconcileCount int `json:"reconcileCount"`
|
|
90
|
+
DuplicateCount int `json:"duplicateCount"`
|
|
91
|
+
IgnoredCount int `json:"ignoredCount"`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type spikeResult struct {
|
|
95
|
+
WorkflowID string `json:"workflowId"`
|
|
96
|
+
RunID string `json:"runId"`
|
|
97
|
+
VisitID string `json:"visitId"`
|
|
98
|
+
ReportID string `json:"reportId"`
|
|
99
|
+
State run.State `json:"state"`
|
|
100
|
+
ReconcileCount int `json:"reconcileCount"`
|
|
101
|
+
DuplicateCount int `json:"duplicateCount"`
|
|
102
|
+
IgnoredCount int `json:"ignoredCount"`
|
|
103
|
+
ActivityResult string `json:"activityResult"`
|
|
104
|
+
ActivityErrType string `json:"activityErrType"`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type spikeActivityResult struct {
|
|
108
|
+
Value string `json:"value"`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type spikeActivities struct {
|
|
112
|
+
mu sync.Mutex
|
|
113
|
+
attempts map[string]int
|
|
114
|
+
cleanup atomic.Int32
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func (a *spikeActivities) Record(ctx context.Context, input spikeInput) (spikeActivityResult, error) {
|
|
118
|
+
a.mu.Lock()
|
|
119
|
+
if a.attempts == nil {
|
|
120
|
+
a.attempts = make(map[string]int)
|
|
121
|
+
}
|
|
122
|
+
a.attempts[input.Snapshot.Ticket.Key]++
|
|
123
|
+
a.mu.Unlock()
|
|
124
|
+
|
|
125
|
+
switch input.Mode {
|
|
126
|
+
case "failure":
|
|
127
|
+
failure := retry.Classify(errors.New("compatibility transient failure"))
|
|
128
|
+
return spikeActivityResult{}, temporalSDK.NewApplicationError("compatibility failure", string(failure.Kind))
|
|
129
|
+
case "conflict":
|
|
130
|
+
failure := retry.Classify(retry.ConflictError(errors.New("compatibility conflict")))
|
|
131
|
+
return spikeActivityResult{}, temporalSDK.NewApplicationError("compatibility conflict", string(failure.Kind))
|
|
132
|
+
case "cancel-activity":
|
|
133
|
+
ticker := time.NewTicker(50 * time.Millisecond)
|
|
134
|
+
defer ticker.Stop()
|
|
135
|
+
for {
|
|
136
|
+
select {
|
|
137
|
+
case <-ctx.Done():
|
|
138
|
+
return spikeActivityResult{}, ctx.Err()
|
|
139
|
+
case <-ticker.C:
|
|
140
|
+
activity.RecordHeartbeat(ctx, "compatibility activity is still running")
|
|
141
|
+
if err := ctx.Err(); err != nil {
|
|
142
|
+
return spikeActivityResult{}, err
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
default:
|
|
147
|
+
return spikeActivityResult{Value: "activity-ok"}, nil
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func (a *spikeActivities) Cleanup(context.Context, spikeInput) error {
|
|
152
|
+
a.cleanup.Add(1)
|
|
153
|
+
return nil
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func (a *spikeActivities) attemptsFor(ticket string) int {
|
|
157
|
+
a.mu.Lock()
|
|
158
|
+
defer a.mu.Unlock()
|
|
159
|
+
return a.attempts[ticket]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func spikeActivityOptions() temporalworkflow.ActivityOptions {
|
|
163
|
+
return temporalworkflow.ActivityOptions{
|
|
164
|
+
StartToCloseTimeout: 5 * time.Minute,
|
|
165
|
+
WaitForCancellation: true,
|
|
166
|
+
RetryPolicy: &temporalSDK.RetryPolicy{MaximumAttempts: 1},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// spikeWorkflow intentionally exercises the SDK primitives that the
|
|
171
|
+
// production interpreter will use: package-level workflow registration,
|
|
172
|
+
// replay-safe side effects, durable timers, selectors, signals, queries,
|
|
173
|
+
// activity futures, disconnected cleanup, and Temporal time.
|
|
174
|
+
func spikeWorkflow(ctx temporalworkflow.Context, input spikeInput) (spikeResult, error) {
|
|
175
|
+
info := temporalworkflow.GetInfo(ctx)
|
|
176
|
+
consumed := make(map[string]bool)
|
|
177
|
+
state := spikeRunStateSnapshot{
|
|
178
|
+
Run: run.Run{
|
|
179
|
+
ID: input.Snapshot.ID,
|
|
180
|
+
LogicalID: input.Snapshot.LogicalID,
|
|
181
|
+
AttemptID: input.Snapshot.AttemptID,
|
|
182
|
+
Repo: input.Snapshot.Repo,
|
|
183
|
+
Workflow: input.Snapshot.Workflow.Name,
|
|
184
|
+
Ticket: input.Snapshot.Ticket,
|
|
185
|
+
State: run.StateStarting,
|
|
186
|
+
StartedAt: info.WorkflowStartTime,
|
|
187
|
+
UpdatedAt: temporalworkflow.Now(ctx),
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
state.Run.CurrentNode = "compatibility"
|
|
191
|
+
state.Run.CurrentNodeVisitID = ""
|
|
192
|
+
state.RuntimeBindings = []spikeNodeRuntimeBinding{{
|
|
193
|
+
Node: "compatibility",
|
|
194
|
+
TerminalID: "terminal-1",
|
|
195
|
+
SessionID: "session-1",
|
|
196
|
+
NodeVisitID: "",
|
|
197
|
+
}}
|
|
198
|
+
|
|
199
|
+
if err := temporalworkflow.SetQueryHandler(ctx, spikeRunStateQuery, func() (spikeRunStateSnapshot, error) {
|
|
200
|
+
return state, nil
|
|
201
|
+
}); err != nil {
|
|
202
|
+
return spikeResult{}, err
|
|
203
|
+
}
|
|
204
|
+
reconcileCount := 0
|
|
205
|
+
duplicateCount := 0
|
|
206
|
+
ignoredCount := 0
|
|
207
|
+
if err := temporalworkflow.SetQueryHandler(ctx, spikeReportStateQueryName, func(query spikeReportStateQuery) (spikeReportStateSnapshot, error) {
|
|
208
|
+
return spikeReportStateSnapshot{
|
|
209
|
+
CurrentNode: state.Run.CurrentNode,
|
|
210
|
+
CurrentNodeVisitID: string(state.Run.CurrentNodeVisitID),
|
|
211
|
+
State: state.Run.State,
|
|
212
|
+
Processed: consumed[query.ReportID],
|
|
213
|
+
}, nil
|
|
214
|
+
}); err != nil {
|
|
215
|
+
return spikeResult{}, err
|
|
216
|
+
}
|
|
217
|
+
if err := temporalworkflow.SetQueryHandler(ctx, spikeProgressQueryName, func() (spikeProgressSnapshot, error) {
|
|
218
|
+
return spikeProgressSnapshot{
|
|
219
|
+
ReconcileCount: reconcileCount,
|
|
220
|
+
DuplicateCount: duplicateCount,
|
|
221
|
+
IgnoredCount: ignoredCount,
|
|
222
|
+
}, nil
|
|
223
|
+
}); err != nil {
|
|
224
|
+
return spikeResult{}, err
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
var visitID string
|
|
228
|
+
if err := temporalworkflow.SideEffect(ctx, func(temporalworkflow.Context) interface{} {
|
|
229
|
+
return "visit-" + uuid.NewString()
|
|
230
|
+
}).Get(&visitID); err != nil {
|
|
231
|
+
return spikeResult{}, err
|
|
232
|
+
}
|
|
233
|
+
state.Run.CurrentNodeVisitID = run.NodeVisitID(visitID)
|
|
234
|
+
state.RuntimeBindings[0].NodeVisitID = visitID
|
|
235
|
+
state.Run.State = run.StateRunning
|
|
236
|
+
state.Run.UpdatedAt = temporalworkflow.Now(ctx)
|
|
237
|
+
|
|
238
|
+
activityCtx := temporalworkflow.WithActivityOptions(ctx, spikeActivityOptions())
|
|
239
|
+
state.Run.State = run.StateRunning
|
|
240
|
+
var activityResult spikeActivityResult
|
|
241
|
+
if err := temporalworkflow.ExecuteActivity(activityCtx, "Record", input).Get(activityCtx, &activityResult); err != nil {
|
|
242
|
+
state.Run.LastError = err.Error()
|
|
243
|
+
var applicationErr *temporalSDK.ApplicationError
|
|
244
|
+
if errors.As(err, &applicationErr) {
|
|
245
|
+
stateResult := spikeResult{
|
|
246
|
+
WorkflowID: info.WorkflowExecution.ID,
|
|
247
|
+
RunID: info.WorkflowExecution.RunID,
|
|
248
|
+
VisitID: visitID,
|
|
249
|
+
State: run.State("failed"),
|
|
250
|
+
ActivityErrType: applicationErr.Type(),
|
|
251
|
+
}
|
|
252
|
+
state.Run.State = run.State("failed")
|
|
253
|
+
return stateResult, err
|
|
254
|
+
}
|
|
255
|
+
if temporalSDK.IsCanceledError(err) || temporalSDK.IsCanceledError(ctx.Err()) {
|
|
256
|
+
state.Run.State = run.StateCanceling
|
|
257
|
+
cleanupCtx, cancel := temporalworkflow.NewDisconnectedContext(ctx)
|
|
258
|
+
defer cancel()
|
|
259
|
+
state.Run.State = run.StateCanceling
|
|
260
|
+
if cleanupErr := temporalworkflow.ExecuteActivity(
|
|
261
|
+
temporalworkflow.WithActivityOptions(cleanupCtx, spikeActivityOptions()),
|
|
262
|
+
"Cleanup", input,
|
|
263
|
+
).Get(cleanupCtx, nil); cleanupErr != nil {
|
|
264
|
+
state.Run.LastError = cleanupErr.Error()
|
|
265
|
+
return spikeResult{}, cleanupErr
|
|
266
|
+
}
|
|
267
|
+
state.Run.State = run.StateCanceled
|
|
268
|
+
return spikeResult{
|
|
269
|
+
WorkflowID: info.WorkflowExecution.ID,
|
|
270
|
+
RunID: info.WorkflowExecution.RunID,
|
|
271
|
+
VisitID: visitID,
|
|
272
|
+
State: run.StateCanceled,
|
|
273
|
+
}, temporalSDK.NewCanceledError()
|
|
274
|
+
}
|
|
275
|
+
state.Run.State = run.State("failed")
|
|
276
|
+
return spikeResult{
|
|
277
|
+
WorkflowID: info.WorkflowExecution.ID,
|
|
278
|
+
RunID: info.WorkflowExecution.RunID,
|
|
279
|
+
VisitID: visitID,
|
|
280
|
+
State: run.State("failed"),
|
|
281
|
+
}, err
|
|
282
|
+
}
|
|
283
|
+
state.Run.UpdatedAt = temporalworkflow.Now(ctx)
|
|
284
|
+
|
|
285
|
+
if input.Mode == "complete" {
|
|
286
|
+
state.Run.State = run.StateCompleted
|
|
287
|
+
return spikeResult{
|
|
288
|
+
WorkflowID: info.WorkflowExecution.ID,
|
|
289
|
+
RunID: info.WorkflowExecution.RunID,
|
|
290
|
+
VisitID: visitID,
|
|
291
|
+
State: run.StateCompleted,
|
|
292
|
+
ActivityResult: activityResult.Value,
|
|
293
|
+
}, nil
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
state.Run.State = run.StateWaiting
|
|
297
|
+
timer := temporalworkflow.NewTimer(ctx, time.Hour)
|
|
298
|
+
reportCh := temporalworkflow.GetSignalChannel(ctx, spikeReportSignalName)
|
|
299
|
+
reconcileCh := temporalworkflow.GetSignalChannel(ctx, spikeReconcileSignalName)
|
|
300
|
+
finishCh := temporalworkflow.GetSignalChannel(ctx, spikeFinishSignalName)
|
|
301
|
+
var latestReport string
|
|
302
|
+
|
|
303
|
+
for {
|
|
304
|
+
selector := temporalworkflow.NewSelector(ctx)
|
|
305
|
+
selector.AddReceive(reportCh, func(channel temporalworkflow.ReceiveChannel, more bool) {
|
|
306
|
+
if !more {
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
var report spikeReportSignal
|
|
310
|
+
channel.Receive(ctx, &report)
|
|
311
|
+
if report.Node != state.Run.CurrentNode || report.NodeVisitID != visitID {
|
|
312
|
+
ignoredCount++
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
if consumed[report.ReportID] {
|
|
316
|
+
duplicateCount++
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
consumed[report.ReportID] = true
|
|
320
|
+
latestReport = report.ReportID
|
|
321
|
+
state.Run.State = run.State("reported")
|
|
322
|
+
})
|
|
323
|
+
selector.AddReceive(reconcileCh, func(channel temporalworkflow.ReceiveChannel, more bool) {
|
|
324
|
+
if !more {
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
var signal spikeReconcileSignal
|
|
328
|
+
channel.Receive(ctx, &signal)
|
|
329
|
+
reconcileCount++
|
|
330
|
+
})
|
|
331
|
+
selector.AddReceive(finishCh, func(channel temporalworkflow.ReceiveChannel, more bool) {
|
|
332
|
+
if !more {
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
channel.Receive(ctx, nil)
|
|
336
|
+
state.Run.State = run.StateCompleted
|
|
337
|
+
})
|
|
338
|
+
selector.AddReceive(ctx.Done(), func(channel temporalworkflow.ReceiveChannel, more bool) {
|
|
339
|
+
if more {
|
|
340
|
+
channel.Receive(ctx, nil)
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
selector.AddFuture(timer, func(temporalworkflow.Future) {
|
|
344
|
+
state.Run.State = run.State("timer-fired")
|
|
345
|
+
})
|
|
346
|
+
selector.Select(ctx)
|
|
347
|
+
|
|
348
|
+
if temporalSDK.IsCanceledError(ctx.Err()) {
|
|
349
|
+
cleanupCtx, cancel := temporalworkflow.NewDisconnectedContext(ctx)
|
|
350
|
+
defer cancel()
|
|
351
|
+
state.Run.State = run.StateCanceling
|
|
352
|
+
if cleanupErr := temporalworkflow.ExecuteActivity(
|
|
353
|
+
temporalworkflow.WithActivityOptions(cleanupCtx, spikeActivityOptions()),
|
|
354
|
+
"Cleanup", input,
|
|
355
|
+
).Get(cleanupCtx, nil); cleanupErr != nil {
|
|
356
|
+
return spikeResult{}, cleanupErr
|
|
357
|
+
}
|
|
358
|
+
state.Run.State = run.StateCanceled
|
|
359
|
+
return spikeResult{
|
|
360
|
+
WorkflowID: info.WorkflowExecution.ID,
|
|
361
|
+
RunID: info.WorkflowExecution.RunID,
|
|
362
|
+
VisitID: visitID,
|
|
363
|
+
ReportID: latestReport,
|
|
364
|
+
State: run.StateCanceled,
|
|
365
|
+
ReconcileCount: reconcileCount,
|
|
366
|
+
DuplicateCount: duplicateCount,
|
|
367
|
+
IgnoredCount: ignoredCount,
|
|
368
|
+
}, temporalSDK.NewCanceledError()
|
|
369
|
+
}
|
|
370
|
+
if state.Run.State == run.StateCompleted {
|
|
371
|
+
return spikeResult{
|
|
372
|
+
WorkflowID: info.WorkflowExecution.ID,
|
|
373
|
+
RunID: info.WorkflowExecution.RunID,
|
|
374
|
+
VisitID: visitID,
|
|
375
|
+
ReportID: latestReport,
|
|
376
|
+
State: run.StateCompleted,
|
|
377
|
+
ReconcileCount: reconcileCount,
|
|
378
|
+
DuplicateCount: duplicateCount,
|
|
379
|
+
IgnoredCount: ignoredCount,
|
|
380
|
+
ActivityResult: activityResult.Value,
|
|
381
|
+
}, nil
|
|
382
|
+
}
|
|
383
|
+
if state.Run.State == run.State("timer-fired") {
|
|
384
|
+
return spikeResult{
|
|
385
|
+
WorkflowID: info.WorkflowExecution.ID,
|
|
386
|
+
RunID: info.WorkflowExecution.RunID,
|
|
387
|
+
VisitID: visitID,
|
|
388
|
+
State: run.State("timer-fired"),
|
|
389
|
+
ReconcileCount: reconcileCount,
|
|
390
|
+
DuplicateCount: duplicateCount,
|
|
391
|
+
IgnoredCount: ignoredCount,
|
|
392
|
+
}, nil
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
func newSpikeStart(ticket string) run.Start {
|
|
398
|
+
return run.Start{
|
|
399
|
+
ID: run.ID("repo/compatibility/" + ticket),
|
|
400
|
+
Repo: "compatibility-repo",
|
|
401
|
+
RepoPath: "/tmp/compatibility-repo",
|
|
402
|
+
Workflow: domainworkflow.Workflow{
|
|
403
|
+
Name: "compatibility",
|
|
404
|
+
Repos: []string{"compatibility-repo"},
|
|
405
|
+
Nodes: map[string]domainworkflow.Node{
|
|
406
|
+
"start": {OnSuccess: []domainworkflow.Route{{Target: "compatibility"}}},
|
|
407
|
+
"compatibility": {Type: domainworkflow.NodeAgent, Agent: "build", Description: "compatibility"},
|
|
408
|
+
"end": {},
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
Ticket: task.TicketRef{ID: ticket, Key: ticket, Title: "Compatibility ticket"},
|
|
412
|
+
Runtime: run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true},
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
func spikeWorkerOptions() temporalworker.Options {
|
|
417
|
+
return temporalworker.Options{
|
|
418
|
+
MaxConcurrentWorkflowTaskExecutionSize: 10,
|
|
419
|
+
MaxConcurrentActivityExecutionSize: 20,
|
|
420
|
+
MaxConcurrentWorkflowTaskPollers: 2,
|
|
421
|
+
MaxConcurrentActivityTaskPollers: 2,
|
|
422
|
+
WorkerStopTimeout: 30 * time.Second,
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
func startSpikeWorker(t *testing.T, temporalClient client.Client, activities *spikeActivities) temporalworker.Worker {
|
|
427
|
+
t.Helper()
|
|
428
|
+
w := temporalworker.New(temporalClient, spikeTaskQueue, spikeWorkerOptions())
|
|
429
|
+
w.RegisterWorkflowWithOptions(spikeWorkflow, temporalworkflow.RegisterOptions{Name: spikeWorkflowType})
|
|
430
|
+
w.RegisterActivity(activities)
|
|
431
|
+
if err := w.Start(); err != nil {
|
|
432
|
+
t.Fatalf("start Temporal worker: %v", err)
|
|
433
|
+
}
|
|
434
|
+
return w
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
func TestTemporalCompatibilitySerializableContracts(t *testing.T) {
|
|
438
|
+
start := newSpikeStart("COMPAT-1")
|
|
439
|
+
state := spikeRunStateSnapshot{
|
|
440
|
+
Run: run.Run{
|
|
441
|
+
ID: start.ID, LogicalID: start.ID, AttemptID: 1, Repo: start.Repo,
|
|
442
|
+
Workflow: start.Workflow.Name, Ticket: start.Ticket, State: run.StateWaiting,
|
|
443
|
+
StartedAt: time.Unix(100, 0).UTC(), UpdatedAt: time.Unix(200, 0).UTC(),
|
|
444
|
+
},
|
|
445
|
+
RuntimeBindings: []spikeNodeRuntimeBinding{{
|
|
446
|
+
Node: "coding", TerminalID: "term-1", SessionID: "session-1", NodeVisitID: "visit-1",
|
|
447
|
+
}},
|
|
448
|
+
}
|
|
449
|
+
query := spikeReportStateQuery{ReportID: "report-1"}
|
|
450
|
+
values, err := converter.GetDefaultDataConverter().ToPayloads(start, state, query)
|
|
451
|
+
if err != nil {
|
|
452
|
+
t.Fatalf("encode serializable Temporal values: %v", err)
|
|
453
|
+
}
|
|
454
|
+
var gotStart run.Start
|
|
455
|
+
var gotState spikeRunStateSnapshot
|
|
456
|
+
var gotQuery spikeReportStateQuery
|
|
457
|
+
if err := converter.GetDefaultDataConverter().FromPayloads(values, &gotStart, &gotState, &gotQuery); err != nil {
|
|
458
|
+
t.Fatalf("decode serializable Temporal values: %v", err)
|
|
459
|
+
}
|
|
460
|
+
if gotStart.ID != start.ID || gotStart.Workflow.Name != start.Workflow.Name || gotStart.Ticket.Key != start.Ticket.Key {
|
|
461
|
+
t.Fatalf("run.Start snapshot changed across serialization: %#v", gotStart)
|
|
462
|
+
}
|
|
463
|
+
if len(gotState.RuntimeBindings) != 1 || gotState.RuntimeBindings[0].NodeVisitID != "visit-1" {
|
|
464
|
+
t.Fatalf("runtime binding changed across serialization: %#v", gotState.RuntimeBindings)
|
|
465
|
+
}
|
|
466
|
+
if gotQuery != query {
|
|
467
|
+
t.Fatalf("report-state query changed across serialization: %#v", gotQuery)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
func TestTemporalCompatibilityWorkflowTestSuite(t *testing.T) {
|
|
472
|
+
var suite testsuite.WorkflowTestSuite
|
|
473
|
+
env := suite.NewTestWorkflowEnvironment()
|
|
474
|
+
activities := &spikeActivities{}
|
|
475
|
+
env.RegisterWorkflowWithOptions(spikeWorkflow, temporalworkflow.RegisterOptions{Name: spikeWorkflowType})
|
|
476
|
+
env.RegisterActivity(activities)
|
|
477
|
+
env.SetStartWorkflowOptions(client.StartWorkflowOptions{ID: "unit-compatibility"})
|
|
478
|
+
env.ExecuteWorkflow(spikeWorkflow, spikeInput{Snapshot: newSpikeStart("UNIT-1"), Mode: "complete"})
|
|
479
|
+
if err := env.GetWorkflowError(); err != nil {
|
|
480
|
+
t.Fatalf("workflow test environment: %v", err)
|
|
481
|
+
}
|
|
482
|
+
var result spikeResult
|
|
483
|
+
if err := env.GetWorkflowResult(&result); err != nil {
|
|
484
|
+
t.Fatalf("workflow result: %v", err)
|
|
485
|
+
}
|
|
486
|
+
if result.State != run.StateCompleted || result.ActivityResult != "activity-ok" {
|
|
487
|
+
t.Fatalf("workflow result = %#v", result)
|
|
488
|
+
}
|
|
489
|
+
if result.VisitID == "" || result.WorkflowID != "unit-compatibility" {
|
|
490
|
+
t.Fatalf("workflow identity/result = %#v", result)
|
|
491
|
+
}
|
|
492
|
+
if got := activities.attemptsFor("UNIT-1"); got != 1 {
|
|
493
|
+
t.Fatalf("activity calls = %d, want one", got)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
func TestTemporalCompatibilityWorkerOptions(t *testing.T) {
|
|
498
|
+
opts := spikeWorkerOptions()
|
|
499
|
+
if opts.MaxConcurrentWorkflowTaskExecutionSize != 10 || opts.MaxConcurrentActivityExecutionSize != 20 {
|
|
500
|
+
t.Fatalf("execution limits = workflow %d/activity %d, want 10/20", opts.MaxConcurrentWorkflowTaskExecutionSize, opts.MaxConcurrentActivityExecutionSize)
|
|
501
|
+
}
|
|
502
|
+
if opts.MaxConcurrentWorkflowTaskPollers != 2 || opts.MaxConcurrentActivityTaskPollers != 2 {
|
|
503
|
+
t.Fatalf("poller limits = workflow %d/activity %d, want 2/2", opts.MaxConcurrentWorkflowTaskPollers, opts.MaxConcurrentActivityTaskPollers)
|
|
504
|
+
}
|
|
505
|
+
if opts.WorkerStopTimeout != 30*time.Second {
|
|
506
|
+
t.Fatalf("worker stop timeout = %s, want 30s", opts.WorkerStopTimeout)
|
|
507
|
+
}
|
|
508
|
+
activityOpts := spikeActivityOptions()
|
|
509
|
+
if activityOpts.StartToCloseTimeout != 5*time.Minute || !activityOpts.WaitForCancellation || activityOpts.RetryPolicy == nil || activityOpts.RetryPolicy.MaximumAttempts != 1 {
|
|
510
|
+
t.Fatalf("activity options = %#v, want 5m, wait-for-cancellation, max attempts 1", activityOpts)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
func TestTemporalCompatibilityLive(t *testing.T) {
|
|
515
|
+
if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
|
|
516
|
+
t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run against the local Temporal Server")
|
|
517
|
+
}
|
|
518
|
+
address := os.Getenv("RELAY_FLOW_TEMPORAL_ADDRESS")
|
|
519
|
+
if address == "" {
|
|
520
|
+
address = "localhost:7233"
|
|
521
|
+
}
|
|
522
|
+
namespace := os.Getenv("RELAY_FLOW_TEMPORAL_NAMESPACE")
|
|
523
|
+
if namespace == "" {
|
|
524
|
+
namespace = fmt.Sprintf("relay-flow-compat-%d", os.Getpid())
|
|
525
|
+
}
|
|
526
|
+
if namespace == client.DefaultNamespace || strings.TrimSpace(namespace) == "" {
|
|
527
|
+
t.Fatalf("live compatibility test requires a dedicated named namespace, got %q", namespace)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
531
|
+
defer cancel()
|
|
532
|
+
if err := ensureSpikeNamespace(ctx, address, namespace); err != nil {
|
|
533
|
+
t.Fatalf("ensure dedicated Temporal namespace: %v", err)
|
|
534
|
+
}
|
|
535
|
+
temporalClient, err := client.Dial(client.Options{HostPort: address, Namespace: namespace})
|
|
536
|
+
if err != nil {
|
|
537
|
+
t.Fatalf("dial Temporal at %s namespace %s: %v", address, namespace, err)
|
|
538
|
+
}
|
|
539
|
+
defer temporalClient.Close()
|
|
540
|
+
activities := &spikeActivities{}
|
|
541
|
+
w := startSpikeWorker(t, temporalClient, activities)
|
|
542
|
+
|
|
543
|
+
// Explicit ID and running duplicate handling.
|
|
544
|
+
waitID := fmt.Sprintf("compat-wait-%d", time.Now().UnixNano())
|
|
545
|
+
waitRun, err := startSpikeWorkflow(ctx, temporalClient, waitID, spikeInput{Snapshot: newSpikeStart("WAIT-1"), Mode: "wait"})
|
|
546
|
+
if err != nil {
|
|
547
|
+
t.Fatalf("start waiting workflow: %v", err)
|
|
548
|
+
}
|
|
549
|
+
if waitRun.GetID() != waitID || waitRun.GetRunID() == "" {
|
|
550
|
+
t.Fatalf("workflow identity = id %q/run %q, want explicit ID and server run ID", waitRun.GetID(), waitRun.GetRunID())
|
|
551
|
+
}
|
|
552
|
+
if _, err := startSpikeWorkflow(ctx, temporalClient, waitID, spikeInput{Snapshot: newSpikeStart("WAIT-DUP"), Mode: "wait"}); err == nil {
|
|
553
|
+
t.Fatal("starting a running workflow ID unexpectedly succeeded")
|
|
554
|
+
} else {
|
|
555
|
+
var alreadyStarted *serviceerror.WorkflowExecutionAlreadyStarted
|
|
556
|
+
if !errors.As(err, &alreadyStarted) {
|
|
557
|
+
t.Fatalf("running duplicate error = %T %v, want WorkflowExecutionAlreadyStarted", err, err)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
state := waitForSpikeState(t, ctx, temporalClient, waitID, waitRun.GetRunID(), string(run.StateWaiting))
|
|
561
|
+
if state.Run.ID != run.ID(newSpikeStart("WAIT-1").ID) || state.Run.CurrentNodeVisitID == "" || state.Run.StartedAt.IsZero() {
|
|
562
|
+
t.Fatalf("run-state snapshot = %#v", state)
|
|
563
|
+
}
|
|
564
|
+
visitID := string(state.Run.CurrentNodeVisitID)
|
|
565
|
+
assertSpikeActivityHistory(t, ctx, temporalClient, waitID, waitRun.GetRunID(), waitID)
|
|
566
|
+
|
|
567
|
+
// Query the exact report-state shape before and after stale/current signals.
|
|
568
|
+
reportState := querySpikeReportState(t, ctx, temporalClient, waitID, waitRun.GetRunID(), "report-1")
|
|
569
|
+
if reportState.Processed || reportState.CurrentNodeVisitID != visitID || reportState.State != run.StateWaiting {
|
|
570
|
+
t.Fatalf("initial report-state snapshot = %#v", reportState)
|
|
571
|
+
}
|
|
572
|
+
if err := temporalClient.SignalWorkflow(ctx, waitID, waitRun.GetRunID(), spikeReconcileSignalName, spikeReconcileSignal{Reason: "terminal missing"}); err != nil {
|
|
573
|
+
t.Fatalf("reconcile signal: %v", err)
|
|
574
|
+
}
|
|
575
|
+
waitForReconcileCount(t, ctx, temporalClient, waitID, waitRun.GetRunID(), 1)
|
|
576
|
+
if err := temporalClient.SignalWorkflow(ctx, waitID, waitRun.GetRunID(), spikeReportSignalName, spikeReportSignal{ReportID: "stale", Node: "other", NodeVisitID: visitID}); err != nil {
|
|
577
|
+
t.Fatalf("stale report signal: %v", err)
|
|
578
|
+
}
|
|
579
|
+
waitForProgress(t, ctx, temporalClient, waitID, waitRun.GetRunID(), func(progress spikeProgressSnapshot) bool {
|
|
580
|
+
return progress.IgnoredCount >= 1
|
|
581
|
+
}, "stale report")
|
|
582
|
+
if err := temporalClient.SignalWorkflow(ctx, waitID, waitRun.GetRunID(), spikeReportSignalName, spikeReportSignal{ReportID: "report-1", Node: "compatibility", NodeVisitID: visitID}); err != nil {
|
|
583
|
+
t.Fatalf("current report signal: %v", err)
|
|
584
|
+
}
|
|
585
|
+
waitForSpikeState(t, ctx, temporalClient, waitID, waitRun.GetRunID(), "reported")
|
|
586
|
+
if err := temporalClient.SignalWorkflow(ctx, waitID, waitRun.GetRunID(), spikeReportSignalName, spikeReportSignal{ReportID: "report-1", Node: "compatibility", NodeVisitID: visitID}); err != nil {
|
|
587
|
+
t.Fatalf("duplicate report signal: %v", err)
|
|
588
|
+
}
|
|
589
|
+
reportState = querySpikeReportState(t, ctx, temporalClient, waitID, waitRun.GetRunID(), "report-1")
|
|
590
|
+
if !reportState.Processed || reportState.CurrentNodeVisitID != visitID {
|
|
591
|
+
t.Fatalf("consumed report-state snapshot = %#v", reportState)
|
|
592
|
+
}
|
|
593
|
+
waitForDuplicateCount(t, ctx, temporalClient, waitID, waitRun.GetRunID(), 1)
|
|
594
|
+
|
|
595
|
+
// Stop and reconnect the worker while the workflow is still waiting.
|
|
596
|
+
w.Stop()
|
|
597
|
+
w = startSpikeWorker(t, temporalClient, activities)
|
|
598
|
+
waitForSpikeState(t, ctx, temporalClient, waitID, waitRun.GetRunID(), "reported")
|
|
599
|
+
if err := temporalClient.SignalWorkflow(ctx, waitID, waitRun.GetRunID(), spikeFinishSignalName, struct{}{}); err != nil {
|
|
600
|
+
t.Fatalf("finish signal after worker restart: %v", err)
|
|
601
|
+
}
|
|
602
|
+
var completed spikeResult
|
|
603
|
+
if err := waitRun.Get(ctx, &completed); err != nil {
|
|
604
|
+
t.Fatalf("wait workflow result after worker restart: %v", err)
|
|
605
|
+
}
|
|
606
|
+
if completed.State != run.StateCompleted || completed.ReportID != "report-1" || completed.ReconcileCount != 1 || completed.DuplicateCount != 1 || completed.IgnoredCount != 1 {
|
|
607
|
+
t.Fatalf("completed workflow result = %#v", completed)
|
|
608
|
+
}
|
|
609
|
+
assertSpikeHistorySnapshot(t, ctx, temporalClient, waitID, waitRun.GetRunID(), newSpikeStart("WAIT-1"))
|
|
610
|
+
|
|
611
|
+
// Failed executions may be reused with ALLOW_DUPLICATE_FAILED_ONLY.
|
|
612
|
+
failedID := fmt.Sprintf("compat-failed-%d", time.Now().UnixNano())
|
|
613
|
+
failedRun, err := startSpikeWorkflow(ctx, temporalClient, failedID, spikeInput{Snapshot: newSpikeStart("FAIL-1"), Mode: "failure"})
|
|
614
|
+
if err != nil {
|
|
615
|
+
t.Fatalf("start failed workflow: %v", err)
|
|
616
|
+
}
|
|
617
|
+
failedErr := failedRun.Get(ctx, nil)
|
|
618
|
+
if failedErr == nil {
|
|
619
|
+
t.Fatal("failed workflow unexpectedly completed")
|
|
620
|
+
}
|
|
621
|
+
var transientErr *temporalSDK.ApplicationError
|
|
622
|
+
if !errors.As(failedErr, &transientErr) || transientErr.Type() != string(retry.Transient) {
|
|
623
|
+
t.Fatalf("failed workflow error = %T %v, want Temporal ApplicationError type %q", failedErr, failedErr, retry.Transient)
|
|
624
|
+
}
|
|
625
|
+
if activities.attemptsFor("FAIL-1") != 1 {
|
|
626
|
+
t.Fatalf("failed activity attempts = %d, want native retry maximum one", activities.attemptsFor("FAIL-1"))
|
|
627
|
+
}
|
|
628
|
+
restarted, err := startSpikeWorkflow(ctx, temporalClient, failedID, spikeInput{Snapshot: newSpikeStart("FAIL-2"), Mode: "complete"})
|
|
629
|
+
if err != nil {
|
|
630
|
+
t.Fatalf("restart failed workflow with same ID: %v", err)
|
|
631
|
+
}
|
|
632
|
+
var restartResult spikeResult
|
|
633
|
+
if err := restarted.Get(ctx, &restartResult); err != nil || restartResult.State != run.StateCompleted {
|
|
634
|
+
t.Fatalf("restarted failed workflow = %#v, err %v", restartResult, err)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
conflictID := fmt.Sprintf("compat-conflict-%d", time.Now().UnixNano())
|
|
638
|
+
conflictRun, err := startSpikeWorkflow(ctx, temporalClient, conflictID, spikeInput{Snapshot: newSpikeStart("CONFLICT-1"), Mode: "conflict"})
|
|
639
|
+
if err != nil {
|
|
640
|
+
t.Fatalf("start conflict workflow: %v", err)
|
|
641
|
+
}
|
|
642
|
+
conflictErr := conflictRun.Get(ctx, nil)
|
|
643
|
+
if conflictErr == nil {
|
|
644
|
+
t.Fatal("conflict workflow unexpectedly completed")
|
|
645
|
+
}
|
|
646
|
+
var conflictAppErr *temporalSDK.ApplicationError
|
|
647
|
+
if !errors.As(conflictErr, &conflictAppErr) || conflictAppErr.Type() != string(retry.Conflict) {
|
|
648
|
+
t.Fatalf("conflict workflow error = %T %v, want Temporal ApplicationError type %q", conflictErr, conflictErr, retry.Conflict)
|
|
649
|
+
}
|
|
650
|
+
if activities.attemptsFor("CONFLICT-1") != 1 {
|
|
651
|
+
t.Fatalf("conflict activity attempts = %d, want native retry maximum one", activities.attemptsFor("CONFLICT-1"))
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Cancellation during an activity runs disconnected cleanup and ends with
|
|
655
|
+
// Temporal's canceled status rather than completed.
|
|
656
|
+
cancelID := fmt.Sprintf("compat-cancel-%d", time.Now().UnixNano())
|
|
657
|
+
cancelRunHandle, err := startSpikeWorkflow(ctx, temporalClient, cancelID, spikeInput{Snapshot: newSpikeStart("CANCEL-1"), Mode: "cancel-activity"})
|
|
658
|
+
if err != nil {
|
|
659
|
+
t.Fatalf("start cancel workflow: %v", err)
|
|
660
|
+
}
|
|
661
|
+
waitForSpikeState(t, ctx, temporalClient, cancelID, cancelRunHandle.GetRunID(), string(run.StateRunning))
|
|
662
|
+
if err := temporalClient.CancelWorkflowWithOptions(ctx, client.CancelWorkflowOptions{WorkflowID: cancelID, RunID: cancelRunHandle.GetRunID(), Reason: "compatibility cancellation"}); err != nil {
|
|
663
|
+
t.Fatalf("cancel workflow: %v", err)
|
|
664
|
+
}
|
|
665
|
+
if err := cancelRunHandle.Get(ctx, nil); err == nil {
|
|
666
|
+
t.Fatal("canceled workflow unexpectedly completed")
|
|
667
|
+
}
|
|
668
|
+
waitForCleanup(t, activities)
|
|
669
|
+
cancelDescription, err := temporalClient.DescribeWorkflowExecution(ctx, cancelID, cancelRunHandle.GetRunID())
|
|
670
|
+
if err != nil {
|
|
671
|
+
t.Fatalf("describe canceled workflow: %v", err)
|
|
672
|
+
}
|
|
673
|
+
if cancelDescription.WorkflowExecutionInfo.GetStatus() != enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED {
|
|
674
|
+
t.Fatalf("canceled workflow status = %s", cancelDescription.WorkflowExecutionInfo.GetStatus())
|
|
675
|
+
}
|
|
676
|
+
canceledRestart, err := startSpikeWorkflow(ctx, temporalClient, cancelID, spikeInput{Snapshot: newSpikeStart("CANCEL-2"), Mode: "complete"})
|
|
677
|
+
if err != nil {
|
|
678
|
+
t.Fatalf("restart canceled workflow with same ID: %v", err)
|
|
679
|
+
}
|
|
680
|
+
var canceledRestartResult spikeResult
|
|
681
|
+
if err := canceledRestart.Get(ctx, &canceledRestartResult); err != nil || canceledRestartResult.State != run.StateCompleted {
|
|
682
|
+
t.Fatalf("restarted canceled workflow = %#v, err %v", canceledRestartResult, err)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// A completed execution cannot be reused under ALLOW_DUPLICATE_FAILED_ONLY.
|
|
686
|
+
completedID := fmt.Sprintf("compat-completed-%d", time.Now().UnixNano())
|
|
687
|
+
firstCompleted, err := startSpikeWorkflow(ctx, temporalClient, completedID, spikeInput{Snapshot: newSpikeStart("DONE-1"), Mode: "complete"})
|
|
688
|
+
if err != nil {
|
|
689
|
+
t.Fatalf("start completed workflow: %v", err)
|
|
690
|
+
}
|
|
691
|
+
if err := firstCompleted.Get(ctx, nil); err != nil {
|
|
692
|
+
t.Fatalf("completed workflow result: %v", err)
|
|
693
|
+
}
|
|
694
|
+
if _, err := startSpikeWorkflow(ctx, temporalClient, completedID, spikeInput{Snapshot: newSpikeStart("DONE-2"), Mode: "complete"}); err == nil {
|
|
695
|
+
t.Fatal("reusing a completed workflow ID unexpectedly succeeded")
|
|
696
|
+
} else {
|
|
697
|
+
var alreadyStarted *serviceerror.WorkflowExecutionAlreadyStarted
|
|
698
|
+
if !errors.As(err, &alreadyStarted) {
|
|
699
|
+
t.Fatalf("completed duplicate error = %T %v, want WorkflowExecutionAlreadyStarted", err, err)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
w.Stop()
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
func startSpikeWorkflow(ctx context.Context, c client.Client, id string, input spikeInput) (client.WorkflowRun, error) {
|
|
707
|
+
return c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
|
708
|
+
ID: id,
|
|
709
|
+
TaskQueue: spikeTaskQueue,
|
|
710
|
+
WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY,
|
|
711
|
+
WorkflowExecutionErrorWhenAlreadyStarted: true,
|
|
712
|
+
}, spikeWorkflowType, input)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
func ensureSpikeNamespace(ctx context.Context, address, namespace string) error {
|
|
716
|
+
ns, err := client.NewNamespaceClient(client.Options{HostPort: address})
|
|
717
|
+
if err != nil {
|
|
718
|
+
return err
|
|
719
|
+
}
|
|
720
|
+
defer ns.Close()
|
|
721
|
+
description, err := ns.Describe(ctx, namespace)
|
|
722
|
+
if err != nil {
|
|
723
|
+
var notFound *serviceerror.NamespaceNotFound
|
|
724
|
+
if !errors.As(err, ¬Found) {
|
|
725
|
+
return err
|
|
726
|
+
}
|
|
727
|
+
if err := ns.Register(ctx, &workflowservice.RegisterNamespaceRequest{
|
|
728
|
+
Namespace: namespace,
|
|
729
|
+
Description: "relay-flow compatibility spike",
|
|
730
|
+
WorkflowExecutionRetentionPeriod: durationpb.New(spikeRetention),
|
|
731
|
+
}); err != nil {
|
|
732
|
+
var alreadyExists *serviceerror.NamespaceAlreadyExists
|
|
733
|
+
if !errors.As(err, &alreadyExists) {
|
|
734
|
+
return fmt.Errorf("register namespace: %w", err)
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
description, err = ns.Describe(ctx, namespace)
|
|
738
|
+
if err != nil {
|
|
739
|
+
return err
|
|
740
|
+
}
|
|
741
|
+
// Namespace registration is acknowledged before every frontend worker
|
|
742
|
+
// cache necessarily observes it. Leave a small propagation window so a
|
|
743
|
+
// first live spike run does not race namespace visibility.
|
|
744
|
+
time.Sleep(5 * time.Second)
|
|
745
|
+
}
|
|
746
|
+
if description.Config == nil || description.Config.WorkflowExecutionRetentionTtl == nil {
|
|
747
|
+
return fmt.Errorf("namespace %q has no workflow retention configuration", namespace)
|
|
748
|
+
}
|
|
749
|
+
if description.Config.WorkflowExecutionRetentionTtl.AsDuration() < spikeRetention {
|
|
750
|
+
return fmt.Errorf("namespace %q retention is %s, need at least %s", namespace, description.Config.WorkflowExecutionRetentionTtl.AsDuration(), spikeRetention)
|
|
751
|
+
}
|
|
752
|
+
return nil
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
func querySpikeState(ctx context.Context, c client.Client, workflowID, runID string) (spikeRunStateSnapshot, error) {
|
|
756
|
+
encoded, err := c.QueryWorkflow(ctx, workflowID, runID, spikeRunStateQuery)
|
|
757
|
+
if err != nil {
|
|
758
|
+
return spikeRunStateSnapshot{}, err
|
|
759
|
+
}
|
|
760
|
+
var state spikeRunStateSnapshot
|
|
761
|
+
if err := encoded.Get(&state); err != nil {
|
|
762
|
+
return spikeRunStateSnapshot{}, err
|
|
763
|
+
}
|
|
764
|
+
return state, nil
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
func querySpikeReportState(t *testing.T, ctx context.Context, c client.Client, workflowID, runID, reportID string) spikeReportStateSnapshot {
|
|
768
|
+
t.Helper()
|
|
769
|
+
encoded, err := c.QueryWorkflow(ctx, workflowID, runID, spikeReportStateQueryName, spikeReportStateQuery{ReportID: reportID})
|
|
770
|
+
if err != nil {
|
|
771
|
+
t.Fatalf("query report state %s: %v", reportID, err)
|
|
772
|
+
}
|
|
773
|
+
var state spikeReportStateSnapshot
|
|
774
|
+
if err := encoded.Get(&state); err != nil {
|
|
775
|
+
t.Fatalf("decode report state %s: %v", reportID, err)
|
|
776
|
+
}
|
|
777
|
+
return state
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
func waitForSpikeState(t *testing.T, ctx context.Context, c client.Client, workflowID, runID, want string) spikeRunStateSnapshot {
|
|
781
|
+
t.Helper()
|
|
782
|
+
for {
|
|
783
|
+
state, err := querySpikeState(ctx, c, workflowID, runID)
|
|
784
|
+
if err == nil && string(state.Run.State) == want {
|
|
785
|
+
return state
|
|
786
|
+
}
|
|
787
|
+
select {
|
|
788
|
+
case <-ctx.Done():
|
|
789
|
+
t.Fatalf("waiting for workflow %s state %q: %v (last state %#v)", workflowID, want, err, state)
|
|
790
|
+
case <-time.After(100 * time.Millisecond):
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
func waitForReconcileCount(t *testing.T, ctx context.Context, c client.Client, workflowID, runID string, want int) {
|
|
796
|
+
t.Helper()
|
|
797
|
+
waitForProgress(t, ctx, c, workflowID, runID, func(progress spikeProgressSnapshot) bool {
|
|
798
|
+
return progress.ReconcileCount >= want
|
|
799
|
+
}, "reconcile signal")
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
func waitForDuplicateCount(t *testing.T, ctx context.Context, c client.Client, workflowID, runID string, want int) {
|
|
803
|
+
t.Helper()
|
|
804
|
+
waitForProgress(t, ctx, c, workflowID, runID, func(progress spikeProgressSnapshot) bool {
|
|
805
|
+
return progress.DuplicateCount >= want
|
|
806
|
+
}, "duplicate report")
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
func waitForProgress(t *testing.T, ctx context.Context, c client.Client, workflowID, runID string, done func(spikeProgressSnapshot) bool, label string) {
|
|
810
|
+
t.Helper()
|
|
811
|
+
deadline := time.NewTimer(5 * time.Second)
|
|
812
|
+
defer deadline.Stop()
|
|
813
|
+
for {
|
|
814
|
+
progress, err := querySpikeProgressNoFatal(ctx, c, workflowID, runID)
|
|
815
|
+
if err == nil && done(progress) {
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
select {
|
|
819
|
+
case <-deadline.C:
|
|
820
|
+
t.Fatalf("waiting for %s (progress %#v, err %v)", label, progress, err)
|
|
821
|
+
case <-ctx.Done():
|
|
822
|
+
t.Fatalf("waiting for %s: %v", label, ctx.Err())
|
|
823
|
+
case <-time.After(100 * time.Millisecond):
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
func querySpikeReportStateNoFatal(ctx context.Context, c client.Client, workflowID, runID, reportID string) (spikeReportStateSnapshot, error) {
|
|
829
|
+
encoded, err := c.QueryWorkflow(ctx, workflowID, runID, spikeReportStateQueryName, spikeReportStateQuery{ReportID: reportID})
|
|
830
|
+
if err != nil {
|
|
831
|
+
return spikeReportStateSnapshot{}, err
|
|
832
|
+
}
|
|
833
|
+
var state spikeReportStateSnapshot
|
|
834
|
+
if err := encoded.Get(&state); err != nil {
|
|
835
|
+
return spikeReportStateSnapshot{}, err
|
|
836
|
+
}
|
|
837
|
+
return state, nil
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
func querySpikeProgressNoFatal(ctx context.Context, c client.Client, workflowID, runID string) (spikeProgressSnapshot, error) {
|
|
841
|
+
encoded, err := c.QueryWorkflow(ctx, workflowID, runID, spikeProgressQueryName)
|
|
842
|
+
if err != nil {
|
|
843
|
+
return spikeProgressSnapshot{}, err
|
|
844
|
+
}
|
|
845
|
+
var progress spikeProgressSnapshot
|
|
846
|
+
if err := encoded.Get(&progress); err != nil {
|
|
847
|
+
return spikeProgressSnapshot{}, err
|
|
848
|
+
}
|
|
849
|
+
return progress, nil
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
func waitForCleanup(t *testing.T, activities *spikeActivities) {
|
|
853
|
+
t.Helper()
|
|
854
|
+
deadline := time.NewTimer(30 * time.Second)
|
|
855
|
+
defer deadline.Stop()
|
|
856
|
+
for activities.cleanup.Load() == 0 {
|
|
857
|
+
select {
|
|
858
|
+
case <-deadline.C:
|
|
859
|
+
t.Fatal("cancellation cleanup activity did not execute")
|
|
860
|
+
case <-time.After(100 * time.Millisecond):
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
func historyForSpike(ctx context.Context, c client.Client, workflowID, runID string) ([]*historypb.HistoryEvent, error) {
|
|
866
|
+
iterator := c.GetWorkflowHistory(ctx, workflowID, runID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
|
|
867
|
+
var events []*historypb.HistoryEvent
|
|
868
|
+
for iterator.HasNext() {
|
|
869
|
+
event, err := iterator.Next()
|
|
870
|
+
if err != nil {
|
|
871
|
+
return nil, err
|
|
872
|
+
}
|
|
873
|
+
events = append(events, event)
|
|
874
|
+
}
|
|
875
|
+
return events, nil
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
func assertSpikeActivityHistory(t *testing.T, ctx context.Context, c client.Client, workflowID, runID, expectedWorkflowID string) {
|
|
879
|
+
t.Helper()
|
|
880
|
+
events, err := historyForSpike(ctx, c, workflowID, runID)
|
|
881
|
+
if err != nil {
|
|
882
|
+
t.Fatalf("get workflow history: %v", err)
|
|
883
|
+
}
|
|
884
|
+
var started, scheduled, timerStarted, marker bool
|
|
885
|
+
for _, event := range events {
|
|
886
|
+
switch event.GetEventType() {
|
|
887
|
+
case enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED:
|
|
888
|
+
started = true
|
|
889
|
+
attrs := event.GetWorkflowExecutionStartedEventAttributes()
|
|
890
|
+
if attrs == nil || attrs.GetWorkflowId() != expectedWorkflowID || attrs.GetTaskQueue().GetName() != spikeTaskQueue || attrs.GetRetryPolicy() != nil {
|
|
891
|
+
t.Fatalf("workflow-start attributes = %#v", attrs)
|
|
892
|
+
}
|
|
893
|
+
case enumspb.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED:
|
|
894
|
+
scheduled = true
|
|
895
|
+
attrs := event.GetActivityTaskScheduledEventAttributes()
|
|
896
|
+
if attrs.GetStartToCloseTimeout().AsDuration() != 5*time.Minute || attrs.GetRetryPolicy().GetMaximumAttempts() != 1 {
|
|
897
|
+
t.Fatalf("activity schedule options = %#v", attrs)
|
|
898
|
+
}
|
|
899
|
+
case enumspb.EVENT_TYPE_TIMER_STARTED:
|
|
900
|
+
timerStarted = true
|
|
901
|
+
case enumspb.EVENT_TYPE_MARKER_RECORDED:
|
|
902
|
+
marker = true
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if !started || !scheduled || !timerStarted || !marker {
|
|
906
|
+
t.Fatalf("history markers started=%v activity=%v timer=%v sideEffect=%v", started, scheduled, timerStarted, marker)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
func assertSpikeHistorySnapshot(t *testing.T, ctx context.Context, c client.Client, workflowID, runID string, expected run.Start) {
|
|
911
|
+
t.Helper()
|
|
912
|
+
events, err := historyForSpike(ctx, c, workflowID, runID)
|
|
913
|
+
if err != nil {
|
|
914
|
+
t.Fatalf("get snapshot history: %v", err)
|
|
915
|
+
}
|
|
916
|
+
for _, event := range events {
|
|
917
|
+
if event.GetEventType() != enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED {
|
|
918
|
+
continue
|
|
919
|
+
}
|
|
920
|
+
attrs := event.GetWorkflowExecutionStartedEventAttributes()
|
|
921
|
+
if attrs == nil || attrs.Input == nil {
|
|
922
|
+
t.Fatal("workflow-start event has no input snapshot")
|
|
923
|
+
}
|
|
924
|
+
var got spikeInput
|
|
925
|
+
if err := converter.GetDefaultDataConverter().FromPayloads(attrs.Input, &got); err != nil {
|
|
926
|
+
t.Fatalf("decode workflow-start snapshot: %v", err)
|
|
927
|
+
}
|
|
928
|
+
if got.Snapshot.ID != expected.ID || got.Snapshot.Workflow.Name != expected.Workflow.Name || got.Snapshot.Ticket.Key != expected.Ticket.Key {
|
|
929
|
+
t.Fatalf("history snapshot = %#v, want %#v", got.Snapshot, expected)
|
|
930
|
+
}
|
|
931
|
+
return
|
|
932
|
+
}
|
|
933
|
+
t.Fatal("workflow-start event not found")
|
|
934
|
+
}
|