relay-flow 0.3.7-alpha → 0.3.9-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 +5 -5
- package/cmd/relay-flow/observability_test.go +28 -0
- package/cmd/relay-flow/render.go +20 -4
- package/cmd/relay-flow/scenario_test.go +15 -13
- package/cmd/relay-flow/serve.go +116 -103
- package/internal/execution/goworkflows/activities.go +17 -0
- package/internal/execution/goworkflows/cancellation_test.go +50 -0
- package/internal/execution/goworkflows/engine.go +278 -9
- package/internal/execution/goworkflows/fakes_test.go +10 -1
- package/internal/execution/goworkflows/interpreter.go +17 -1
- package/internal/execution/goworkflows/projection.go +8 -0
- package/internal/execution/goworkflows/recovery_test.go +482 -0
- package/internal/execution/projection/detail_test.go +38 -0
- package/internal/execution/projection/projection.go +80 -15
- package/internal/execution/temporal/activities.go +6 -0
- package/internal/execution/temporal/recovery.go +4 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/recover/recover.go +4 -0
- package/internal/repo/binding_test.go +92 -0
- package/internal/repo/poller.go +3 -0
- package/internal/repo/repo.go +82 -6
- package/internal/run/manager.go +41 -0
- package/internal/run/run_manager_test.go +56 -0
- package/internal/server/observability.go +1 -1
- package/internal/task/beads/beads.go +42 -4
- package/internal/task/beads/beads_test.go +15 -0
- package/internal/task/factory.go +41 -2
- package/internal/task/jira/jira.go +52 -28
- package/internal/workflow/service.go +53 -0
- package/internal/workflow/store.go +413 -19
- package/internal/workflow/store_test.go +235 -0
- package/internal/workflow/workflow.go +71 -0
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ import (
|
|
|
9
9
|
"testing"
|
|
10
10
|
"time"
|
|
11
11
|
|
|
12
|
+
"github.com/cschleiden/go-workflows/backend/history"
|
|
12
13
|
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
13
14
|
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
14
15
|
recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
|
|
@@ -200,14 +201,19 @@ func TestCancelRun(t *testing.T) {
|
|
|
200
201
|
// Exactly one parent cancellation comment with the stable marker.
|
|
201
202
|
wantMarker := string(rid) + ":cancellation"
|
|
202
203
|
var cancelComments int
|
|
204
|
+
var cancellationBody string
|
|
203
205
|
for _, c := range sys.commentBodies("PAY-101") {
|
|
204
206
|
if c.Marker == wantMarker {
|
|
205
207
|
cancelComments++
|
|
208
|
+
cancellationBody = c.Body
|
|
206
209
|
}
|
|
207
210
|
}
|
|
208
211
|
if cancelComments != 1 {
|
|
209
212
|
t.Fatalf("cancellation comments = %d, want 1 with marker %q", cancelComments, wantMarker)
|
|
210
213
|
}
|
|
214
|
+
if !strings.Contains(cancellationBody, "Run canceled: no longer needed") {
|
|
215
|
+
t.Fatalf("cancellation comment = %q, want persisted operator reason", cancellationBody)
|
|
216
|
+
}
|
|
211
217
|
|
|
212
218
|
// Mailbox statuses/history unchanged: the in-flight coding mailbox was
|
|
213
219
|
// not completed by cancellation.
|
|
@@ -220,6 +226,482 @@ func TestCancelRun(t *testing.T) {
|
|
|
220
226
|
}
|
|
221
227
|
}
|
|
222
228
|
|
|
229
|
+
func TestCancelRunFinalizesWhenWorkflowInstanceIsMissing(t *testing.T) {
|
|
230
|
+
log := newEventLog()
|
|
231
|
+
sys := newFakeTaskSystem(log)
|
|
232
|
+
fr := newFakeRunner(log)
|
|
233
|
+
deps := goworkflows.Dependencies{
|
|
234
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
235
|
+
Runtime: &run.RuntimePolicy{},
|
|
236
|
+
}
|
|
237
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
238
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
239
|
+
if err != nil {
|
|
240
|
+
t.Fatal(err)
|
|
241
|
+
}
|
|
242
|
+
if err := first.Start(context.Background()); err != nil {
|
|
243
|
+
t.Fatal(err)
|
|
244
|
+
}
|
|
245
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
246
|
+
if err != nil {
|
|
247
|
+
t.Fatal(err)
|
|
248
|
+
}
|
|
249
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
250
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
251
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
252
|
+
})
|
|
253
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
254
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
255
|
+
t.Fatal(err)
|
|
256
|
+
}
|
|
257
|
+
cancel()
|
|
258
|
+
|
|
259
|
+
// Simulate the cancellation crash boundary: the relay projection has a
|
|
260
|
+
// run, but the active go-workflows instance is gone.
|
|
261
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
262
|
+
if err != nil {
|
|
263
|
+
t.Fatal(err)
|
|
264
|
+
}
|
|
265
|
+
if _, err := db.Exec(`DELETE FROM instances WHERE id = ?`, string(rid)); err != nil {
|
|
266
|
+
db.Close()
|
|
267
|
+
t.Fatal(err)
|
|
268
|
+
}
|
|
269
|
+
if err := db.Close(); err != nil {
|
|
270
|
+
t.Fatal(err)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
274
|
+
if err != nil {
|
|
275
|
+
t.Fatal(err)
|
|
276
|
+
}
|
|
277
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
278
|
+
sys.failComments = true
|
|
279
|
+
if err := second.CancelRun(context.Background(), rid, "operator canceled"); err == nil {
|
|
280
|
+
t.Fatal("CancelRun succeeded despite a cancellation-comment failure")
|
|
281
|
+
}
|
|
282
|
+
failed, err := second.GetRun(context.Background(), rid)
|
|
283
|
+
if err != nil {
|
|
284
|
+
t.Fatal(err)
|
|
285
|
+
}
|
|
286
|
+
if failed.State != run.StateCanceling || failed.LastError != "operator canceled" {
|
|
287
|
+
t.Fatalf("failed cancellation projection = %+v, want canceling with original reason", failed)
|
|
288
|
+
}
|
|
289
|
+
sys.failComments = false
|
|
290
|
+
if err := second.CancelRun(context.Background(), rid, "replacement reason"); err != nil {
|
|
291
|
+
t.Fatalf("CancelRun with missing workflow instance: %v", err)
|
|
292
|
+
}
|
|
293
|
+
got, err := second.GetRun(context.Background(), rid)
|
|
294
|
+
if err != nil {
|
|
295
|
+
t.Fatal(err)
|
|
296
|
+
}
|
|
297
|
+
if got.State != run.StateCanceled {
|
|
298
|
+
t.Fatalf("state = %q, want canceled", got.State)
|
|
299
|
+
}
|
|
300
|
+
active, err := second.HasActiveWorkflow(context.Background(), "basicFlow")
|
|
301
|
+
if err != nil {
|
|
302
|
+
t.Fatal(err)
|
|
303
|
+
}
|
|
304
|
+
if active {
|
|
305
|
+
t.Fatal("canceled run is still counted as an active workflow")
|
|
306
|
+
}
|
|
307
|
+
if fr.liveTerminals() != 0 {
|
|
308
|
+
t.Fatalf("missing-instance cancellation left %d live terminals", fr.liveTerminals())
|
|
309
|
+
}
|
|
310
|
+
comments := sys.commentBodies("PAY-101")
|
|
311
|
+
if len(comments) != 1 || comments[0].Marker != string(rid)+":cancellation" {
|
|
312
|
+
t.Fatalf("cancellation comments = %#v, want one stable cancellation marker", comments)
|
|
313
|
+
}
|
|
314
|
+
if err := second.CancelRun(context.Background(), rid, "repeated cancellation"); err != nil {
|
|
315
|
+
t.Fatalf("repeated cancellation: %v", err)
|
|
316
|
+
}
|
|
317
|
+
if got, err := second.GetRun(context.Background(), rid); err != nil || got.State != run.StateCanceled {
|
|
318
|
+
t.Fatalf("repeated cancellation changed state: run=%+v err=%v", got, err)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
func TestStartReconcilesCancelingRunWithoutWorkflowInstance(t *testing.T) {
|
|
323
|
+
log := newEventLog()
|
|
324
|
+
sys := newFakeTaskSystem(log)
|
|
325
|
+
fr := newFakeRunner(log)
|
|
326
|
+
deps := goworkflows.Dependencies{
|
|
327
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
328
|
+
Runtime: &run.RuntimePolicy{},
|
|
329
|
+
}
|
|
330
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
331
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
332
|
+
if err != nil {
|
|
333
|
+
t.Fatal(err)
|
|
334
|
+
}
|
|
335
|
+
if err := first.Start(context.Background()); err != nil {
|
|
336
|
+
t.Fatal(err)
|
|
337
|
+
}
|
|
338
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
339
|
+
if err != nil {
|
|
340
|
+
t.Fatal(err)
|
|
341
|
+
}
|
|
342
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
343
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
344
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
345
|
+
})
|
|
346
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
347
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
348
|
+
t.Fatal(err)
|
|
349
|
+
}
|
|
350
|
+
cancel()
|
|
351
|
+
|
|
352
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
353
|
+
if err != nil {
|
|
354
|
+
t.Fatal(err)
|
|
355
|
+
}
|
|
356
|
+
if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ? WHERE id = ?`,
|
|
357
|
+
string(run.StateCanceling), "operator canceled", string(rid)); err != nil {
|
|
358
|
+
db.Close()
|
|
359
|
+
t.Fatal(err)
|
|
360
|
+
}
|
|
361
|
+
if _, err := db.Exec(`DELETE FROM instances WHERE id = ?`, string(rid)); err != nil {
|
|
362
|
+
db.Close()
|
|
363
|
+
t.Fatal(err)
|
|
364
|
+
}
|
|
365
|
+
if err := db.Close(); err != nil {
|
|
366
|
+
t.Fatal(err)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
370
|
+
if err != nil {
|
|
371
|
+
t.Fatal(err)
|
|
372
|
+
}
|
|
373
|
+
if err := second.Start(context.Background()); err != nil {
|
|
374
|
+
t.Fatal(err)
|
|
375
|
+
}
|
|
376
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
377
|
+
got, err := second.GetRun(context.Background(), rid)
|
|
378
|
+
if err != nil {
|
|
379
|
+
t.Fatal(err)
|
|
380
|
+
}
|
|
381
|
+
if got.State != run.StateCanceled {
|
|
382
|
+
t.Fatalf("startup reconciliation state = %q, want canceled", got.State)
|
|
383
|
+
}
|
|
384
|
+
active, err := second.HasActiveWorkflow(context.Background(), "basicFlow")
|
|
385
|
+
if err != nil {
|
|
386
|
+
t.Fatal(err)
|
|
387
|
+
}
|
|
388
|
+
if active {
|
|
389
|
+
t.Fatal("startup-reconciled run is still counted as active")
|
|
390
|
+
}
|
|
391
|
+
if len(sys.commentBodies("PAY-101")) != 1 {
|
|
392
|
+
t.Fatalf("startup reconciliation comments = %d, want one", len(sys.commentBodies("PAY-101")))
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
func TestStartRetriesCancellationForExistingWorkflowInstance(t *testing.T) {
|
|
397
|
+
log := newEventLog()
|
|
398
|
+
sys := newFakeTaskSystem(log)
|
|
399
|
+
fr := newFakeRunner(log)
|
|
400
|
+
deps := goworkflows.Dependencies{
|
|
401
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
402
|
+
Runtime: &run.RuntimePolicy{},
|
|
403
|
+
}
|
|
404
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
405
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
406
|
+
if err != nil {
|
|
407
|
+
t.Fatal(err)
|
|
408
|
+
}
|
|
409
|
+
if err := first.Start(context.Background()); err != nil {
|
|
410
|
+
t.Fatal(err)
|
|
411
|
+
}
|
|
412
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
413
|
+
if err != nil {
|
|
414
|
+
t.Fatal(err)
|
|
415
|
+
}
|
|
416
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
417
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
418
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
419
|
+
})
|
|
420
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
421
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
422
|
+
t.Fatal(err)
|
|
423
|
+
}
|
|
424
|
+
cancel()
|
|
425
|
+
|
|
426
|
+
// Leave the engine instance active but persist the cancellation request as
|
|
427
|
+
// if the process died after the projection write and before the cancel RPC.
|
|
428
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
429
|
+
if err != nil {
|
|
430
|
+
t.Fatal(err)
|
|
431
|
+
}
|
|
432
|
+
if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ? WHERE id = ?`,
|
|
433
|
+
string(run.StateCanceling), "startup cancellation", string(rid)); err != nil {
|
|
434
|
+
db.Close()
|
|
435
|
+
t.Fatal(err)
|
|
436
|
+
}
|
|
437
|
+
if err := db.Close(); err != nil {
|
|
438
|
+
t.Fatal(err)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
442
|
+
if err != nil {
|
|
443
|
+
t.Fatal(err)
|
|
444
|
+
}
|
|
445
|
+
if err := second.Start(context.Background()); err != nil {
|
|
446
|
+
t.Fatal(err)
|
|
447
|
+
}
|
|
448
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
449
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
450
|
+
r, _ := second.GetRun(context.Background(), rid)
|
|
451
|
+
return r.State == run.StateCanceled
|
|
452
|
+
})
|
|
453
|
+
if len(sys.commentBodies("PAY-101")) != 1 {
|
|
454
|
+
t.Fatalf("startup cancellation comments = %d, want one", len(sys.commentBodies("PAY-101")))
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
func TestStartDistinguishesFinishedWorkflowFromMissingWorkflow(t *testing.T) {
|
|
459
|
+
log := newEventLog()
|
|
460
|
+
sys := newFakeTaskSystem(log)
|
|
461
|
+
fr := newFakeRunner(log)
|
|
462
|
+
deps := goworkflows.Dependencies{
|
|
463
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
464
|
+
Runtime: &run.RuntimePolicy{},
|
|
465
|
+
}
|
|
466
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
467
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
468
|
+
if err != nil {
|
|
469
|
+
t.Fatal(err)
|
|
470
|
+
}
|
|
471
|
+
if err := first.Start(context.Background()); err != nil {
|
|
472
|
+
t.Fatal(err)
|
|
473
|
+
}
|
|
474
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
475
|
+
if err != nil {
|
|
476
|
+
t.Fatal(err)
|
|
477
|
+
}
|
|
478
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
479
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
480
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
481
|
+
})
|
|
482
|
+
if _, err := first.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
483
|
+
t.Fatal(err)
|
|
484
|
+
}
|
|
485
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
486
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
487
|
+
return r.State == run.StateCompleted
|
|
488
|
+
})
|
|
489
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
490
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
491
|
+
if err != nil {
|
|
492
|
+
return false
|
|
493
|
+
}
|
|
494
|
+
defer db.Close()
|
|
495
|
+
var state int
|
|
496
|
+
if err := db.QueryRow(`SELECT state FROM instances WHERE id = ?`, string(rid)).Scan(&state); err != nil {
|
|
497
|
+
return false
|
|
498
|
+
}
|
|
499
|
+
return state != 0
|
|
500
|
+
})
|
|
501
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
502
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
503
|
+
t.Fatal(err)
|
|
504
|
+
}
|
|
505
|
+
cancel()
|
|
506
|
+
|
|
507
|
+
// Corrupt only the relay projection into canceling. The finished engine
|
|
508
|
+
// row/history must win; this must not create a cancellation comment.
|
|
509
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
510
|
+
if err != nil {
|
|
511
|
+
t.Fatal(err)
|
|
512
|
+
}
|
|
513
|
+
if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ?, finished_at = NULL WHERE id = ?`,
|
|
514
|
+
string(run.StateCanceling), "late cancellation", string(rid)); err != nil {
|
|
515
|
+
db.Close()
|
|
516
|
+
t.Fatal(err)
|
|
517
|
+
}
|
|
518
|
+
// Make the engine fixture unambiguously finished while retaining its
|
|
519
|
+
// history, so startup must inspect the terminal event rather than treating
|
|
520
|
+
// the row as a missing active execution.
|
|
521
|
+
if _, err := db.Exec(`UPDATE instances SET state = 2, completed_at = CURRENT_TIMESTAMP WHERE id = ?`, string(rid)); err != nil {
|
|
522
|
+
db.Close()
|
|
523
|
+
t.Fatal(err)
|
|
524
|
+
}
|
|
525
|
+
if err := db.Close(); err != nil {
|
|
526
|
+
t.Fatal(err)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
530
|
+
if err != nil {
|
|
531
|
+
t.Fatal(err)
|
|
532
|
+
}
|
|
533
|
+
if err := second.Start(context.Background()); err != nil {
|
|
534
|
+
t.Fatal(err)
|
|
535
|
+
}
|
|
536
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
537
|
+
got, err := second.GetRun(context.Background(), rid)
|
|
538
|
+
if err != nil {
|
|
539
|
+
t.Fatal(err)
|
|
540
|
+
}
|
|
541
|
+
if got.State != run.StateCompleted {
|
|
542
|
+
t.Fatalf("finished workflow was reconciled to %q, want completed", got.State)
|
|
543
|
+
}
|
|
544
|
+
if comments := sys.commentBodies("PAY-101"); len(comments) != 0 {
|
|
545
|
+
t.Fatalf("finished workflow received cancellation comments: %#v", comments)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
func TestStartReconcilesCanceledWorkflowHistoryAsCanceled(t *testing.T) {
|
|
550
|
+
log := newEventLog()
|
|
551
|
+
sys := newFakeTaskSystem(log)
|
|
552
|
+
fr := newFakeRunner(log)
|
|
553
|
+
deps := goworkflows.Dependencies{
|
|
554
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
555
|
+
Runtime: &run.RuntimePolicy{},
|
|
556
|
+
}
|
|
557
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
558
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
559
|
+
if err != nil {
|
|
560
|
+
t.Fatal(err)
|
|
561
|
+
}
|
|
562
|
+
if err := first.Start(context.Background()); err != nil {
|
|
563
|
+
t.Fatal(err)
|
|
564
|
+
}
|
|
565
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
566
|
+
if err != nil {
|
|
567
|
+
t.Fatal(err)
|
|
568
|
+
}
|
|
569
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
570
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
571
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
572
|
+
})
|
|
573
|
+
if err := first.CancelRun(context.Background(), rid, "history cancellation"); err != nil {
|
|
574
|
+
t.Fatal(err)
|
|
575
|
+
}
|
|
576
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
577
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
578
|
+
return r.State == run.StateCanceled
|
|
579
|
+
})
|
|
580
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
581
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
582
|
+
t.Fatal(err)
|
|
583
|
+
}
|
|
584
|
+
cancel()
|
|
585
|
+
|
|
586
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
587
|
+
if err != nil {
|
|
588
|
+
t.Fatal(err)
|
|
589
|
+
}
|
|
590
|
+
if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ?, finished_at = NULL WHERE id = ?`,
|
|
591
|
+
string(run.StateCanceling), "history cancellation", string(rid)); err != nil {
|
|
592
|
+
db.Close()
|
|
593
|
+
t.Fatal(err)
|
|
594
|
+
}
|
|
595
|
+
if _, err := db.Exec(`UPDATE instances SET state = 2, completed_at = CURRENT_TIMESTAMP WHERE id = ?`, string(rid)); err != nil {
|
|
596
|
+
db.Close()
|
|
597
|
+
t.Fatal(err)
|
|
598
|
+
}
|
|
599
|
+
if err := db.Close(); err != nil {
|
|
600
|
+
t.Fatal(err)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
604
|
+
if err != nil {
|
|
605
|
+
t.Fatal(err)
|
|
606
|
+
}
|
|
607
|
+
if err := second.Start(context.Background()); err != nil {
|
|
608
|
+
t.Fatal(err)
|
|
609
|
+
}
|
|
610
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
611
|
+
got, err := second.GetRun(context.Background(), rid)
|
|
612
|
+
if err != nil {
|
|
613
|
+
t.Fatal(err)
|
|
614
|
+
}
|
|
615
|
+
if got.State != run.StateCanceled {
|
|
616
|
+
t.Fatalf("canceled workflow history was reconciled to %q, want canceled", got.State)
|
|
617
|
+
}
|
|
618
|
+
if comments := sys.commentBodies("PAY-101"); len(comments) != 1 {
|
|
619
|
+
t.Fatalf("cancellation comments = %d, want one idempotent comment", len(comments))
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
func TestRepeatedCancellationDoesNotAppendCancellationEvents(t *testing.T) {
|
|
624
|
+
log := newEventLog()
|
|
625
|
+
sys := newFakeTaskSystem(log)
|
|
626
|
+
fr := newFakeRunner(log)
|
|
627
|
+
deps := goworkflows.Dependencies{
|
|
628
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
629
|
+
Runtime: &run.RuntimePolicy{},
|
|
630
|
+
}
|
|
631
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
632
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
633
|
+
if err != nil {
|
|
634
|
+
t.Fatal(err)
|
|
635
|
+
}
|
|
636
|
+
if err := first.Start(context.Background()); err != nil {
|
|
637
|
+
t.Fatal(err)
|
|
638
|
+
}
|
|
639
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
640
|
+
if err != nil {
|
|
641
|
+
t.Fatal(err)
|
|
642
|
+
}
|
|
643
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
644
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
645
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
646
|
+
})
|
|
647
|
+
sys.failComments = true
|
|
648
|
+
if err := first.CancelRun(context.Background(), rid, "first reason"); err != nil {
|
|
649
|
+
t.Fatal(err)
|
|
650
|
+
}
|
|
651
|
+
waitFor(t, 15*time.Second, func() bool {
|
|
652
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
653
|
+
return r.State == run.StateCanceling
|
|
654
|
+
})
|
|
655
|
+
if err := first.CancelRun(context.Background(), rid, "second reason"); err != nil {
|
|
656
|
+
t.Fatal(err)
|
|
657
|
+
}
|
|
658
|
+
if got := countWorkflowEvents(t, dbPath, rid, history.EventType_WorkflowExecutionCanceled); got != 1 {
|
|
659
|
+
t.Fatalf("cancellation events after repeated cancel = %d, want 1", got)
|
|
660
|
+
}
|
|
661
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
662
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
663
|
+
t.Fatal(err)
|
|
664
|
+
}
|
|
665
|
+
cancel()
|
|
666
|
+
|
|
667
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
668
|
+
if err != nil {
|
|
669
|
+
t.Fatal(err)
|
|
670
|
+
}
|
|
671
|
+
if err := second.Start(context.Background()); err != nil {
|
|
672
|
+
t.Fatal(err)
|
|
673
|
+
}
|
|
674
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
675
|
+
// Let the already-persisted cancellation event drive cleanup after the
|
|
676
|
+
// restart; startup must not append another event.
|
|
677
|
+
sys.failComments = false
|
|
678
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
679
|
+
r, _ := second.GetRun(context.Background(), rid)
|
|
680
|
+
return r.State == run.StateCanceled
|
|
681
|
+
})
|
|
682
|
+
if got := countWorkflowEvents(t, dbPath, rid, history.EventType_WorkflowExecutionCanceled); got != 1 {
|
|
683
|
+
t.Fatalf("cancellation events after restart = %d, want 1", got)
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
func countWorkflowEvents(t *testing.T, dbPath string, id run.ID, eventType history.EventType) int {
|
|
688
|
+
t.Helper()
|
|
689
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
690
|
+
if err != nil {
|
|
691
|
+
t.Fatal(err)
|
|
692
|
+
}
|
|
693
|
+
defer db.Close()
|
|
694
|
+
var count int
|
|
695
|
+
if err := db.QueryRow(`
|
|
696
|
+
SELECT
|
|
697
|
+
(SELECT COUNT(*) FROM history WHERE instance_id = ? AND event_type = ?)
|
|
698
|
+
+ (SELECT COUNT(*) FROM pending_events WHERE instance_id = ? AND event_type = ?)`,
|
|
699
|
+
string(id), int(eventType), string(id), int(eventType)).Scan(&count); err != nil {
|
|
700
|
+
t.Fatal(err)
|
|
701
|
+
}
|
|
702
|
+
return count
|
|
703
|
+
}
|
|
704
|
+
|
|
223
705
|
func TestExplicitRestartCreatesFreshAttemptFromStart(t *testing.T) {
|
|
224
706
|
log := newEventLog()
|
|
225
707
|
sys := newFakeTaskSystem(log)
|
|
@@ -206,6 +206,44 @@ func TestCompletedRunFinalizesActiveStepWhenFinalUpsertIsMissing(t *testing.T) {
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
func TestCancellationFencePreservesReasonAndRejectsLateCompletion(t *testing.T) {
|
|
210
|
+
ctx := context.Background()
|
|
211
|
+
p, _ := openProjection(t)
|
|
212
|
+
start := projectionStart("cancel-fence", "PAY-CANCEL-FENCE")
|
|
213
|
+
if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
|
|
214
|
+
t.Fatal(err)
|
|
215
|
+
}
|
|
216
|
+
first, err := p.BeginCancellation(ctx, start.ID, "first reason")
|
|
217
|
+
if err != nil {
|
|
218
|
+
t.Fatal(err)
|
|
219
|
+
}
|
|
220
|
+
if first.State != run.StateCanceling || first.LastError != "first reason" {
|
|
221
|
+
t.Fatalf("first cancellation = %+v", first)
|
|
222
|
+
}
|
|
223
|
+
second, err := p.BeginCancellation(ctx, start.ID, "replacement reason")
|
|
224
|
+
if err != nil {
|
|
225
|
+
t.Fatal(err)
|
|
226
|
+
}
|
|
227
|
+
if second.State != run.StateCanceling || second.LastError != "first reason" {
|
|
228
|
+
t.Fatalf("repeated cancellation overwrote reason: %+v", second)
|
|
229
|
+
}
|
|
230
|
+
if err := p.UpdateState(ctx, start.ID, run.StateCompleted, "", nil); err != nil {
|
|
231
|
+
t.Fatal(err)
|
|
232
|
+
}
|
|
233
|
+
stillCanceling, err := p.Get(ctx, start.ID)
|
|
234
|
+
if err != nil {
|
|
235
|
+
t.Fatal(err)
|
|
236
|
+
}
|
|
237
|
+
if stillCanceling.State != run.StateCanceling || stillCanceling.LastError != "first reason" {
|
|
238
|
+
t.Fatalf("late completion overwrote cancellation: %+v", stillCanceling)
|
|
239
|
+
}
|
|
240
|
+
finished := time.Now().UTC()
|
|
241
|
+
updated, err := p.UpdateStateIf(ctx, start.ID, run.StateCanceling, run.StateCanceled, "", &finished)
|
|
242
|
+
if err != nil || !updated {
|
|
243
|
+
t.Fatalf("canceling finalization updated=%v err=%v", updated, err)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
209
247
|
func TestCanceledRunFinalizesCurrentStepTiming(t *testing.T) {
|
|
210
248
|
ctx := context.Background()
|
|
211
249
|
p, _ := openProjection(t)
|