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,384 @@
|
|
|
1
|
+
// Package temporal implements the Temporal durable executor. Temporal owns
|
|
2
|
+
// workflow history, signals, timers, and activity checkpoints; SQLite stores
|
|
3
|
+
// only the relay-owned derived projection.
|
|
4
|
+
package temporal
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"context"
|
|
8
|
+
"database/sql"
|
|
9
|
+
"errors"
|
|
10
|
+
"fmt"
|
|
11
|
+
"os"
|
|
12
|
+
"sync"
|
|
13
|
+
"time"
|
|
14
|
+
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/execution/projection"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
19
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
20
|
+
"go.temporal.io/api/serviceerror"
|
|
21
|
+
workflowservice "go.temporal.io/api/workflowservice/v1"
|
|
22
|
+
"go.temporal.io/sdk/client"
|
|
23
|
+
temporalworker "go.temporal.io/sdk/worker"
|
|
24
|
+
"go.temporal.io/sdk/workflow"
|
|
25
|
+
_ "modernc.org/sqlite"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const (
|
|
29
|
+
TaskQueue = "relay-flow"
|
|
30
|
+
TicketWorkflowName = "TicketWorkflow"
|
|
31
|
+
minRetention = 30 * 24 * time.Hour
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// Dependencies carries the replaceable boundaries used by Temporal
|
|
35
|
+
// activities. Recover is consumed only by Start and is never part of the
|
|
36
|
+
// workflow input snapshot.
|
|
37
|
+
type Dependencies struct {
|
|
38
|
+
Repos *repo.Registry
|
|
39
|
+
Runner runner.Runner
|
|
40
|
+
Harness harness.Harness
|
|
41
|
+
TaskSystem string
|
|
42
|
+
RetentionDays int
|
|
43
|
+
Runtime *run.RuntimePolicy
|
|
44
|
+
TemporalAddress string
|
|
45
|
+
TemporalNamespace string
|
|
46
|
+
Recover bool
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Engine implements both engine-neutral run boundaries.
|
|
50
|
+
type Engine struct {
|
|
51
|
+
db *sql.DB
|
|
52
|
+
runs *projection.RunProjection
|
|
53
|
+
deps Dependencies
|
|
54
|
+
runtime run.RuntimePolicy
|
|
55
|
+
retention time.Duration
|
|
56
|
+
namespaceRetention time.Duration
|
|
57
|
+
|
|
58
|
+
client client.Client
|
|
59
|
+
worker temporalworker.Worker
|
|
60
|
+
activities *Activities
|
|
61
|
+
|
|
62
|
+
mu sync.Mutex
|
|
63
|
+
lifecycleMu sync.Mutex
|
|
64
|
+
started bool
|
|
65
|
+
shutdownOnce sync.Once
|
|
66
|
+
fatal chan error
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// InitDatabase initializes only the shared relay projection. It does not
|
|
70
|
+
// contact Temporal or create an engine-owned history schema.
|
|
71
|
+
func InitDatabase(path string) error {
|
|
72
|
+
db, err := openDatabase(path)
|
|
73
|
+
if err != nil {
|
|
74
|
+
return err
|
|
75
|
+
}
|
|
76
|
+
return db.Close()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// HasNonterminalRuns inspects the shared projection without migrating it.
|
|
80
|
+
func HasNonterminalRuns(path string) (bool, error) { return projection.HasNonterminalRuns(path) }
|
|
81
|
+
|
|
82
|
+
func openDatabase(path string) (*sql.DB, error) {
|
|
83
|
+
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_txlock=immediate", path))
|
|
84
|
+
if err != nil {
|
|
85
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
86
|
+
}
|
|
87
|
+
db.SetMaxOpenConns(1)
|
|
88
|
+
if _, err := db.Exec(`PRAGMA schema_version`); err != nil {
|
|
89
|
+
db.Close()
|
|
90
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
91
|
+
}
|
|
92
|
+
if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
|
|
93
|
+
db.Close()
|
|
94
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
95
|
+
}
|
|
96
|
+
if _, err := db.Exec("PRAGMA busy_timeout = 5000;"); err != nil {
|
|
97
|
+
db.Close()
|
|
98
|
+
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
99
|
+
}
|
|
100
|
+
if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) {
|
|
101
|
+
db.Close()
|
|
102
|
+
return nil, fmt.Errorf("chmod %s: %w", path, err)
|
|
103
|
+
}
|
|
104
|
+
proj := &projection.RunProjection{DB: db}
|
|
105
|
+
if err := proj.Migrate(); err != nil {
|
|
106
|
+
db.Close()
|
|
107
|
+
return nil, fmt.Errorf("migrate relay projection: %w", err)
|
|
108
|
+
}
|
|
109
|
+
return db, nil
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func validateDependencies(deps Dependencies) error {
|
|
113
|
+
if deps.Repos == nil || deps.Runner == nil || deps.Harness == nil {
|
|
114
|
+
return errors.New("temporal: Repos, Runner, and Harness dependencies are required")
|
|
115
|
+
}
|
|
116
|
+
if deps.Runtime != nil && deps.Runtime.KeepTerminalsAlive && !deps.Runtime.KeepSessionsAlive {
|
|
117
|
+
return errors.New("temporal: keepTerminalsAlive requires keepSessionsAlive")
|
|
118
|
+
}
|
|
119
|
+
if deps.TemporalAddress == "" {
|
|
120
|
+
return errors.New("temporal: TemporalAddress is required")
|
|
121
|
+
}
|
|
122
|
+
if deps.TemporalNamespace == "" {
|
|
123
|
+
return errors.New("temporal: TemporalNamespace is required")
|
|
124
|
+
}
|
|
125
|
+
if deps.TemporalNamespace == client.DefaultNamespace {
|
|
126
|
+
return fmt.Errorf("temporal: TemporalNamespace must be a dedicated named namespace, not %q", client.DefaultNamespace)
|
|
127
|
+
}
|
|
128
|
+
return nil
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// New opens and migrates the relay projection, then verifies the immutable
|
|
132
|
+
// installation identity. A recovery construction creates the fresh marker
|
|
133
|
+
// after serve has moved the old projection aside; normal construction fails
|
|
134
|
+
// closed when the marker is missing or mismatched.
|
|
135
|
+
func New(path string, deps Dependencies) (*Engine, error) {
|
|
136
|
+
if err := validateDependencies(deps); err != nil {
|
|
137
|
+
return nil, err
|
|
138
|
+
}
|
|
139
|
+
db, err := openDatabase(path)
|
|
140
|
+
if err != nil {
|
|
141
|
+
return nil, err
|
|
142
|
+
}
|
|
143
|
+
proj := &projection.RunProjection{DB: db}
|
|
144
|
+
identity := projection.ExecutorIdentity{
|
|
145
|
+
ExecutorPlugin: "temporal", TemporalAddress: deps.TemporalAddress, TemporalNamespace: deps.TemporalNamespace,
|
|
146
|
+
}
|
|
147
|
+
identityErr := proj.VerifyIdentity(context.Background(), identity)
|
|
148
|
+
if deps.Recover {
|
|
149
|
+
identityErr = proj.InitializeIdentity(context.Background(), identity)
|
|
150
|
+
}
|
|
151
|
+
if identityErr != nil {
|
|
152
|
+
db.Close()
|
|
153
|
+
return nil, identityErr
|
|
154
|
+
}
|
|
155
|
+
retention := minRetention
|
|
156
|
+
if deps.RetentionDays > 0 {
|
|
157
|
+
retention = time.Duration(deps.RetentionDays) * 24 * time.Hour
|
|
158
|
+
}
|
|
159
|
+
namespaceRetention := minRetention
|
|
160
|
+
if retention > namespaceRetention {
|
|
161
|
+
namespaceRetention = retention
|
|
162
|
+
}
|
|
163
|
+
runtimePolicy := run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true}
|
|
164
|
+
if deps.Runtime != nil {
|
|
165
|
+
runtimePolicy = *deps.Runtime
|
|
166
|
+
}
|
|
167
|
+
return &Engine{
|
|
168
|
+
db: db, runs: proj, deps: deps, runtime: runtimePolicy, retention: retention,
|
|
169
|
+
namespaceRetention: namespaceRetention, fatal: make(chan error, 1),
|
|
170
|
+
}, nil
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func temporalClientOptions(deps Dependencies) client.Options {
|
|
174
|
+
return client.Options{HostPort: deps.TemporalAddress, Namespace: deps.TemporalNamespace}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func (e *Engine) dial(ctx context.Context) error {
|
|
178
|
+
if e.client != nil {
|
|
179
|
+
return nil
|
|
180
|
+
}
|
|
181
|
+
c, err := client.Dial(temporalClientOptions(e.deps))
|
|
182
|
+
if err != nil {
|
|
183
|
+
return fmt.Errorf("dial Temporal %s/%s: %w", e.deps.TemporalAddress, e.deps.TemporalNamespace, err)
|
|
184
|
+
}
|
|
185
|
+
e.client = c
|
|
186
|
+
return nil
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func (e *Engine) verifyNamespace(ctx context.Context) error {
|
|
190
|
+
description, err := e.client.WorkflowService().DescribeNamespace(ctx, &workflowservice.DescribeNamespaceRequest{Namespace: e.deps.TemporalNamespace})
|
|
191
|
+
if err != nil {
|
|
192
|
+
return fmt.Errorf("describe Temporal namespace %q: %w", e.deps.TemporalNamespace, err)
|
|
193
|
+
}
|
|
194
|
+
if description == nil || description.Config == nil || description.Config.WorkflowExecutionRetentionTtl == nil {
|
|
195
|
+
return fmt.Errorf("Temporal namespace %q has no workflow retention policy", e.deps.TemporalNamespace)
|
|
196
|
+
}
|
|
197
|
+
if description.Config.WorkflowExecutionRetentionTtl.AsDuration() < e.namespaceRetention {
|
|
198
|
+
return fmt.Errorf("Temporal namespace %q retention %s is below required %s", e.deps.TemporalNamespace, description.Config.WorkflowExecutionRetentionTtl.AsDuration(), e.namespaceRetention)
|
|
199
|
+
}
|
|
200
|
+
return nil
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// waitNamespaceReady handles the local server's short frontend-cache window
|
|
204
|
+
// after namespace registration. It probes only public Visibility APIs and
|
|
205
|
+
// never creates or mutates Temporal state.
|
|
206
|
+
func (e *Engine) waitNamespaceReady(ctx context.Context) error {
|
|
207
|
+
for {
|
|
208
|
+
_, err := e.client.ListWorkflow(ctx, &workflowservice.ListWorkflowExecutionsRequest{})
|
|
209
|
+
if err == nil {
|
|
210
|
+
return nil
|
|
211
|
+
}
|
|
212
|
+
var notFound *serviceerror.NamespaceNotFound
|
|
213
|
+
if !errors.As(err, ¬Found) {
|
|
214
|
+
return fmt.Errorf("probe Temporal namespace %q: %w", e.deps.TemporalNamespace, err)
|
|
215
|
+
}
|
|
216
|
+
timer := time.NewTimer(200 * time.Millisecond)
|
|
217
|
+
select {
|
|
218
|
+
case <-ctx.Done():
|
|
219
|
+
timer.Stop()
|
|
220
|
+
return ctx.Err()
|
|
221
|
+
case <-timer.C:
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func stopWorker(w temporalworker.Worker) {
|
|
227
|
+
if w == nil {
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
defer func() { _ = recover() }()
|
|
231
|
+
w.Stop()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func workerOptions(onFatal func(error), localOnly bool) temporalworker.Options {
|
|
235
|
+
return temporalworker.Options{
|
|
236
|
+
MaxConcurrentWorkflowTaskExecutionSize: 10,
|
|
237
|
+
MaxConcurrentActivityExecutionSize: 20,
|
|
238
|
+
MaxConcurrentWorkflowTaskPollers: 2,
|
|
239
|
+
MaxConcurrentActivityTaskPollers: 2,
|
|
240
|
+
WorkerStopTimeout: 30 * time.Second,
|
|
241
|
+
OnFatalError: onFatal,
|
|
242
|
+
LocalActivityWorkerOnly: localOnly,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
func (e *Engine) newWorker(localOnly bool) temporalworker.Worker {
|
|
247
|
+
w := temporalworker.New(e.client, TaskQueue, workerOptions(func(err error) {
|
|
248
|
+
select {
|
|
249
|
+
case e.fatal <- err:
|
|
250
|
+
default:
|
|
251
|
+
}
|
|
252
|
+
}, localOnly))
|
|
253
|
+
w.RegisterWorkflowWithOptions(TicketWorkflow, workflow.RegisterOptions{Name: TicketWorkflowName})
|
|
254
|
+
w.RegisterActivity(e.activities)
|
|
255
|
+
return w
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Start connects to the configured namespace, verifies retention, rebuilds a
|
|
259
|
+
// projection when explicitly requested, and starts one aggregate worker.
|
|
260
|
+
func (e *Engine) Start(ctx context.Context) error {
|
|
261
|
+
e.lifecycleMu.Lock()
|
|
262
|
+
defer e.lifecycleMu.Unlock()
|
|
263
|
+
e.mu.Lock()
|
|
264
|
+
if e.started {
|
|
265
|
+
e.mu.Unlock()
|
|
266
|
+
return nil
|
|
267
|
+
}
|
|
268
|
+
e.mu.Unlock()
|
|
269
|
+
if err := e.dial(ctx); err != nil {
|
|
270
|
+
return err
|
|
271
|
+
}
|
|
272
|
+
if err := e.verifyNamespace(ctx); err != nil {
|
|
273
|
+
e.client.Close()
|
|
274
|
+
e.client = nil
|
|
275
|
+
return err
|
|
276
|
+
}
|
|
277
|
+
if err := e.waitNamespaceReady(ctx); err != nil {
|
|
278
|
+
e.client.Close()
|
|
279
|
+
e.client = nil
|
|
280
|
+
return err
|
|
281
|
+
}
|
|
282
|
+
e.activities = &Activities{
|
|
283
|
+
Repos: e.deps.Repos, Runner: e.deps.Runner, Harness: e.deps.Harness,
|
|
284
|
+
TaskSystem: e.deps.TaskSystem, Runs: e.runs,
|
|
285
|
+
}
|
|
286
|
+
if e.deps.Recover {
|
|
287
|
+
if err := e.rebuildProjection(ctx); err != nil {
|
|
288
|
+
e.client.Close()
|
|
289
|
+
e.client = nil
|
|
290
|
+
return err
|
|
291
|
+
}
|
|
292
|
+
// Recovery retention is projection-only and must finish before the
|
|
293
|
+
// normal worker is allowed to resume pending workflow tasks.
|
|
294
|
+
if _, err := e.runs.SweepRetention(ctx, time.Now().UTC().Add(-e.retention)); err != nil {
|
|
295
|
+
e.client.Close()
|
|
296
|
+
e.client = nil
|
|
297
|
+
return fmt.Errorf("recovery retention sweep: %w", err)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
w := e.newWorker(false)
|
|
301
|
+
if err := w.Start(); err != nil {
|
|
302
|
+
stopWorker(w)
|
|
303
|
+
e.client.Close()
|
|
304
|
+
e.client = nil
|
|
305
|
+
return fmt.Errorf("start Temporal worker: %w", err)
|
|
306
|
+
}
|
|
307
|
+
e.worker = w
|
|
308
|
+
if !e.deps.Recover {
|
|
309
|
+
if _, err := e.runs.SweepRetention(ctx, time.Now().UTC().Add(-e.retention)); err != nil {
|
|
310
|
+
stopWorker(w)
|
|
311
|
+
e.client.Close()
|
|
312
|
+
e.client = nil
|
|
313
|
+
return fmt.Errorf("retention sweep: %w", err)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
e.mu.Lock()
|
|
317
|
+
e.started = true
|
|
318
|
+
e.mu.Unlock()
|
|
319
|
+
return nil
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Shutdown is idempotent and closes worker, Temporal client, and projection
|
|
323
|
+
// database in that order.
|
|
324
|
+
func (e *Engine) Shutdown(context.Context) error {
|
|
325
|
+
e.lifecycleMu.Lock()
|
|
326
|
+
defer e.lifecycleMu.Unlock()
|
|
327
|
+
var err error
|
|
328
|
+
e.shutdownOnce.Do(func() {
|
|
329
|
+
e.mu.Lock()
|
|
330
|
+
w := e.worker
|
|
331
|
+
c := e.client
|
|
332
|
+
e.worker = nil
|
|
333
|
+
e.client = nil
|
|
334
|
+
e.started = false
|
|
335
|
+
e.mu.Unlock()
|
|
336
|
+
if w != nil {
|
|
337
|
+
stopWorker(w)
|
|
338
|
+
}
|
|
339
|
+
if c != nil {
|
|
340
|
+
c.Close()
|
|
341
|
+
}
|
|
342
|
+
if e.db != nil {
|
|
343
|
+
err = e.db.Close()
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
return err
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// FatalErrors exposes worker-fatal errors to the composition root. It is
|
|
350
|
+
// intentionally advisory; the selected server lifecycle remains responsible
|
|
351
|
+
// for deciding when to stop pollers and the socket.
|
|
352
|
+
func (e *Engine) FatalErrors() <-chan error { return e.fatal }
|
|
353
|
+
|
|
354
|
+
// GetNodeRuntime returns one persisted runtime binding.
|
|
355
|
+
func (e *Engine) GetNodeRuntime(ctx context.Context, id run.ID, node string) (projection.NodeRuntime, error) {
|
|
356
|
+
return e.runs.GetNodeRuntime(ctx, id, node)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
func (e *Engine) GetRun(ctx context.Context, id run.ID) (run.Run, error) {
|
|
360
|
+
return e.runs.Get(ctx, id)
|
|
361
|
+
}
|
|
362
|
+
func (e *Engine) FindRunByTicket(ctx context.Context, ticket string) (run.Run, error) {
|
|
363
|
+
return e.runs.FindByTicket(ctx, ticket)
|
|
364
|
+
}
|
|
365
|
+
func (e *Engine) ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error) {
|
|
366
|
+
return e.runs.List(ctx, filter)
|
|
367
|
+
}
|
|
368
|
+
func (e *Engine) HasActiveWorkflow(ctx context.Context, name string) (bool, error) {
|
|
369
|
+
return e.runs.HasActiveWorkflow(ctx, name)
|
|
370
|
+
}
|
|
371
|
+
func (e *Engine) HasActiveRepo(ctx context.Context, name string) (bool, error) {
|
|
372
|
+
return e.runs.HasActiveRepo(ctx, name)
|
|
373
|
+
}
|
|
374
|
+
func (e *Engine) HasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error) {
|
|
375
|
+
return e.runs.HasProcessedReport(ctx, id, reportID)
|
|
376
|
+
}
|
|
377
|
+
func (e *Engine) RegisterNodeSession(ctx context.Context, r run.NodeRuntimeRegistration) (run.NodeRuntimeRegistrationAck, error) {
|
|
378
|
+
accepted, err := e.runs.RegisterNodeSession(ctx, r)
|
|
379
|
+
return run.NodeRuntimeRegistrationAck{Accepted: accepted}, err
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Compile-time contract checks.
|
|
383
|
+
var _ run.Executor = (*Engine)(nil)
|
|
384
|
+
var _ run.RunQueries = (*Engine)(nil)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
package temporal
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"reflect"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/execution/projection"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
19
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
20
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
21
|
+
workflowservice "go.temporal.io/api/workflowservice/v1"
|
|
22
|
+
"go.temporal.io/sdk/client"
|
|
23
|
+
temporalSDK "go.temporal.io/sdk/temporal"
|
|
24
|
+
"google.golang.org/protobuf/types/known/durationpb"
|
|
25
|
+
_ "modernc.org/sqlite"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
func TestTemporalNewRejectsIdentityMismatch(t *testing.T) {
|
|
29
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
30
|
+
if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "identity-test"}); err != nil {
|
|
31
|
+
t.Fatal(err)
|
|
32
|
+
}
|
|
33
|
+
_, err := New(path, Dependencies{Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{}, TemporalAddress: "other-host:7233", TemporalNamespace: "identity-test"})
|
|
34
|
+
if !errors.Is(err, projection.ErrIdentityMismatch) {
|
|
35
|
+
t.Fatalf("identity mismatch error = %v", err)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestTemporalNewRejectsLegacyMarkerlessProjection(t *testing.T) {
|
|
40
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
41
|
+
if err := projection.InitDatabase(path); err != nil {
|
|
42
|
+
t.Fatal(err)
|
|
43
|
+
}
|
|
44
|
+
_, err := New(path, Dependencies{Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{}, TemporalAddress: "localhost:7233", TemporalNamespace: "legacy-test"})
|
|
45
|
+
if !errors.Is(err, projection.ErrIdentityMissing) {
|
|
46
|
+
t.Fatalf("legacy markerless error = %v", err)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestTemporalWorkflowAndActivityRegistrationAreSeparate(t *testing.T) {
|
|
51
|
+
if _, ok := reflect.TypeOf(Activities{}).MethodByName("TicketWorkflow"); ok {
|
|
52
|
+
t.Fatal("Temporal activity struct exposes TicketWorkflow")
|
|
53
|
+
}
|
|
54
|
+
if reflect.TypeOf(TicketWorkflow).Kind() != reflect.Func {
|
|
55
|
+
t.Fatal("TicketWorkflow is not a package-level function")
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func TestTemporalWorkerAndActivityOptionsMatchMVP(t *testing.T) {
|
|
60
|
+
opts := workerOptions(nil, false)
|
|
61
|
+
if opts.MaxConcurrentWorkflowTaskExecutionSize != 10 || opts.MaxConcurrentActivityExecutionSize != 20 {
|
|
62
|
+
t.Fatalf("execution limits = workflow %d/activity %d", opts.MaxConcurrentWorkflowTaskExecutionSize, opts.MaxConcurrentActivityExecutionSize)
|
|
63
|
+
}
|
|
64
|
+
if opts.MaxConcurrentWorkflowTaskPollers != 2 || opts.MaxConcurrentActivityTaskPollers != 2 {
|
|
65
|
+
t.Fatalf("poller limits = workflow %d/activity %d", opts.MaxConcurrentWorkflowTaskPollers, opts.MaxConcurrentActivityTaskPollers)
|
|
66
|
+
}
|
|
67
|
+
if opts.WorkerStopTimeout != 30*time.Second || opts.LocalActivityWorkerOnly {
|
|
68
|
+
t.Fatalf("normal worker options = %+v", opts)
|
|
69
|
+
}
|
|
70
|
+
activity := temporalActivityOptions
|
|
71
|
+
if activity.StartToCloseTimeout != 5*time.Minute || !activity.WaitForCancellation || activity.RetryPolicy == nil || activity.RetryPolicy.MaximumAttempts != 1 {
|
|
72
|
+
t.Fatalf("activity options = %+v", activity)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestRuntimeBindingCleanupUpdatesTemporalSnapshot(t *testing.T) {
|
|
77
|
+
state := &workflowState{bindings: map[string]NodeRuntimeBinding{
|
|
78
|
+
"node": {Node: "node", TerminalID: "term", SessionID: "session", NodeVisitID: "visit"},
|
|
79
|
+
}}
|
|
80
|
+
applyRuntimePolicy(state, "node", run.RuntimePolicy{})
|
|
81
|
+
binding := state.bindings["node"]
|
|
82
|
+
if binding.TerminalID != "" || binding.SessionID != "" {
|
|
83
|
+
t.Fatalf("cleared runtime binding = %+v", binding)
|
|
84
|
+
}
|
|
85
|
+
state.bindings["node"] = NodeRuntimeBinding{Node: "node", TerminalID: "term", SessionID: "session", NodeVisitID: "visit"}
|
|
86
|
+
applyRuntimePolicy(state, "node", run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true})
|
|
87
|
+
if got := state.bindings["node"]; got.TerminalID != "term" || got.SessionID != "session" {
|
|
88
|
+
t.Fatalf("preserved runtime binding = %+v", got)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func TestTemporalClientOptionsUseConfiguredIdentity(t *testing.T) {
|
|
93
|
+
options := temporalClientOptions(Dependencies{TemporalAddress: "127.0.0.1:7233", TemporalNamespace: "relay-test"})
|
|
94
|
+
if options.HostPort != "127.0.0.1:7233" || options.Namespace != "relay-test" {
|
|
95
|
+
t.Fatalf("Temporal client options = %+v", options)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestClassifyTemporalActivityErrors(t *testing.T) {
|
|
100
|
+
conflict := temporalSDK.NewApplicationError("manual conflict", string(retry.Conflict))
|
|
101
|
+
if got := classifyTemporalError(conflict); got.Kind != retry.Conflict {
|
|
102
|
+
t.Fatalf("Temporal conflict classification = %+v", got)
|
|
103
|
+
}
|
|
104
|
+
transient := temporalSDK.NewApplicationError("temporary failure", string(retry.Transient))
|
|
105
|
+
if got := classifyTemporalError(transient); got.Kind != retry.Transient {
|
|
106
|
+
t.Fatalf("Temporal transient classification = %+v", got)
|
|
107
|
+
}
|
|
108
|
+
wrapped := retry.ConflictError(errors.New("wrapped conflict"))
|
|
109
|
+
if got := classifyTemporalError(wrapped); got.Kind != retry.Conflict {
|
|
110
|
+
t.Fatalf("wrapped conflict classification = %+v", got)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func TestTemporalRetentionSeparatesLocalAndNamespacePolicies(t *testing.T) {
|
|
115
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
116
|
+
if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "retention-separation"}); err != nil {
|
|
117
|
+
t.Fatal(err)
|
|
118
|
+
}
|
|
119
|
+
engine, err := New(path, Dependencies{Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{}, RetentionDays: 1, TemporalAddress: "localhost:7233", TemporalNamespace: "retention-separation"})
|
|
120
|
+
if err != nil {
|
|
121
|
+
t.Fatal(err)
|
|
122
|
+
}
|
|
123
|
+
if engine.retention != 24*time.Hour || engine.namespaceRetention != 30*24*time.Hour {
|
|
124
|
+
t.Fatalf("retention policies = local %s/namespace %s", engine.retention, engine.namespaceRetention)
|
|
125
|
+
}
|
|
126
|
+
if err := engine.Shutdown(context.Background()); err != nil {
|
|
127
|
+
t.Fatal(err)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func TestTemporalStartRejectsShortRetentionNamespace(t *testing.T) {
|
|
132
|
+
if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
|
|
133
|
+
t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run namespace retention startup coverage")
|
|
134
|
+
}
|
|
135
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
136
|
+
defer cancel()
|
|
137
|
+
namespace := "relay-flow-short-retention-" + string(identity.NewNodeVisitID())[:12]
|
|
138
|
+
manager, err := client.NewNamespaceClient(client.Options{HostPort: "localhost:7233"})
|
|
139
|
+
if err != nil {
|
|
140
|
+
t.Fatal(err)
|
|
141
|
+
}
|
|
142
|
+
defer manager.Close()
|
|
143
|
+
if err := manager.Register(ctx, &workflowservice.RegisterNamespaceRequest{
|
|
144
|
+
Namespace: namespace, Description: "short retention test", WorkflowExecutionRetentionPeriod: durationpb.New(24 * time.Hour),
|
|
145
|
+
}); err != nil {
|
|
146
|
+
t.Fatal(err)
|
|
147
|
+
}
|
|
148
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
149
|
+
if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: namespace}); err != nil {
|
|
150
|
+
t.Fatal(err)
|
|
151
|
+
}
|
|
152
|
+
engine, err := New(path, Dependencies{Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{}, TemporalAddress: "localhost:7233", TemporalNamespace: namespace})
|
|
153
|
+
if err != nil {
|
|
154
|
+
t.Fatal(err)
|
|
155
|
+
}
|
|
156
|
+
if err := engine.Start(ctx); err == nil || !strings.Contains(err.Error(), "below required") {
|
|
157
|
+
t.Fatalf("Start short-retention namespace error = %v", err)
|
|
158
|
+
}
|
|
159
|
+
if engine.worker != nil {
|
|
160
|
+
t.Fatal("short-retention startup created a worker")
|
|
161
|
+
}
|
|
162
|
+
if err := engine.Shutdown(context.Background()); err != nil {
|
|
163
|
+
t.Fatal(err)
|
|
164
|
+
}
|
|
165
|
+
if db, err := sql.Open("sqlite", path); err != nil {
|
|
166
|
+
t.Fatal(err)
|
|
167
|
+
} else {
|
|
168
|
+
_ = db.Close()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func TestTemporalUnavailableFailsWithoutEmbeddedFallback(t *testing.T) {
|
|
173
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
174
|
+
if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "127.0.0.1:1", TemporalNamespace: "unavailable-test"}); err != nil {
|
|
175
|
+
t.Fatal(err)
|
|
176
|
+
}
|
|
177
|
+
engine, err := New(path, Dependencies{Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{}, TemporalAddress: "127.0.0.1:1", TemporalNamespace: "unavailable-test"})
|
|
178
|
+
if err != nil {
|
|
179
|
+
t.Fatal(err)
|
|
180
|
+
}
|
|
181
|
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
182
|
+
defer cancel()
|
|
183
|
+
if err := engine.Start(ctx); err == nil {
|
|
184
|
+
t.Fatal("unavailable Temporal endpoint unexpectedly started")
|
|
185
|
+
}
|
|
186
|
+
if engine.worker != nil {
|
|
187
|
+
t.Fatal("unavailable Temporal endpoint started a worker")
|
|
188
|
+
}
|
|
189
|
+
if err := engine.Shutdown(context.Background()); err != nil {
|
|
190
|
+
t.Fatal(err)
|
|
191
|
+
}
|
|
192
|
+
db, err := sql.Open("sqlite", path)
|
|
193
|
+
if err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
defer db.Close()
|
|
197
|
+
var embedded int
|
|
198
|
+
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='instances'`).Scan(&embedded); err != nil {
|
|
199
|
+
t.Fatal(err)
|
|
200
|
+
}
|
|
201
|
+
if embedded != 0 {
|
|
202
|
+
t.Fatal("unavailable Temporal startup created embedded go-workflows tables")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func TestTemporalLocalRetentionKeepsActiveRows(t *testing.T) {
|
|
207
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
208
|
+
if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "retention-test"}); err != nil {
|
|
209
|
+
t.Fatal(err)
|
|
210
|
+
}
|
|
211
|
+
db, err := sql.Open("sqlite", path)
|
|
212
|
+
if err != nil {
|
|
213
|
+
t.Fatal(err)
|
|
214
|
+
}
|
|
215
|
+
defer db.Close()
|
|
216
|
+
proj := &projection.RunProjection{DB: db}
|
|
217
|
+
old := run.Start{ID: "repo/wf/old", Repo: "repo", Workflow: workflow.Workflow{Name: "wf"}, Ticket: task.TicketRef{ID: "old", Key: "OLD"}}
|
|
218
|
+
active := run.Start{ID: "repo/wf/active", Repo: "repo", Workflow: workflow.Workflow{Name: "wf"}, Ticket: task.TicketRef{ID: "active", Key: "ACTIVE"}}
|
|
219
|
+
if err := proj.InsertStart(context.Background(), old, time.Now().UTC().Add(-72*time.Hour)); err != nil {
|
|
220
|
+
t.Fatal(err)
|
|
221
|
+
}
|
|
222
|
+
if err := proj.InsertStart(context.Background(), active, time.Now().UTC()); err != nil {
|
|
223
|
+
t.Fatal(err)
|
|
224
|
+
}
|
|
225
|
+
finished := time.Now().UTC().Add(-48 * time.Hour)
|
|
226
|
+
if err := proj.UpdateState(context.Background(), old.ID, run.StateCompleted, "", &finished); err != nil {
|
|
227
|
+
t.Fatal(err)
|
|
228
|
+
}
|
|
229
|
+
if err := proj.UpdateState(context.Background(), active.ID, run.StateWaiting, "", nil); err != nil {
|
|
230
|
+
t.Fatal(err)
|
|
231
|
+
}
|
|
232
|
+
removed, err := proj.SweepRetention(context.Background(), time.Now().UTC().Add(-24*time.Hour))
|
|
233
|
+
if err != nil {
|
|
234
|
+
t.Fatal(err)
|
|
235
|
+
}
|
|
236
|
+
if len(removed) != 1 || removed[0] != string(old.ID) {
|
|
237
|
+
t.Fatalf("retention removed = %v", removed)
|
|
238
|
+
}
|
|
239
|
+
if _, err := proj.Get(context.Background(), active.ID); err != nil {
|
|
240
|
+
t.Fatalf("active row removed by retention: %v", err)
|
|
241
|
+
}
|
|
242
|
+
if _, err := proj.Get(context.Background(), old.ID); !projection.IsNotFound(err) {
|
|
243
|
+
t.Fatalf("old row remains after retention: %v", err)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
func TestTemporalEngineShutdownIsIdempotent(t *testing.T) {
|
|
248
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
249
|
+
if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{
|
|
250
|
+
ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "shutdown-test",
|
|
251
|
+
}); err != nil {
|
|
252
|
+
t.Fatal(err)
|
|
253
|
+
}
|
|
254
|
+
deps := Dependencies{
|
|
255
|
+
Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{},
|
|
256
|
+
TemporalAddress: "localhost:7233", TemporalNamespace: "shutdown-test",
|
|
257
|
+
}
|
|
258
|
+
engine, err := New(path, deps)
|
|
259
|
+
if err != nil {
|
|
260
|
+
t.Fatal(err)
|
|
261
|
+
}
|
|
262
|
+
if err := engine.Shutdown(context.Background()); err != nil {
|
|
263
|
+
t.Fatal(err)
|
|
264
|
+
}
|
|
265
|
+
if err := engine.Shutdown(context.Background()); err != nil {
|
|
266
|
+
t.Fatalf("second Shutdown = %v", err)
|
|
267
|
+
}
|
|
268
|
+
db, err := sql.Open("sqlite", path)
|
|
269
|
+
if err != nil {
|
|
270
|
+
t.Fatal(err)
|
|
271
|
+
}
|
|
272
|
+
defer db.Close()
|
|
273
|
+
var one int
|
|
274
|
+
if err := db.QueryRow(`SELECT 1`).Scan(&one); err != nil || one != 1 {
|
|
275
|
+
t.Fatalf("database after idempotent shutdown = %d, %v", one, err)
|
|
276
|
+
}
|
|
277
|
+
}
|