Skip to content

Commit 7bb04af

Browse files
committed
fix: keep public idempotent triggers atomic
1 parent 5cf68f6 commit 7bb04af

4 files changed

Lines changed: 103 additions & 14 deletions

File tree

internal/services/controllers/task/controller.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1071,7 +1071,12 @@ func (tc *TasksControllerImpl) handleProcessInternalEvents(ctx context.Context,
10711071

10721072
// handleProcessEventTrigger is responsible for inserting tasks into the database based on event triggers.
10731073
func (tc *TasksControllerImpl) handleProcessTaskTrigger(ctx context.Context, tenantId uuid.UUID, payloads [][]byte) error {
1074-
err := tc.tw.TriggerFromWorkflowNames(ctx, tenantId, msgqueue.JSONConvert[v1.WorkflowNameTriggerOpts](payloads))
1074+
opts := msgqueue.JSONConvert[v1.WorkflowNameTriggerOpts](payloads)
1075+
for _, opt := range opts {
1076+
opt.AllowPartialIdempotency = true
1077+
}
1078+
1079+
err := tc.tw.TriggerFromWorkflowNames(ctx, tenantId, opts)
10751080

10761081
if errors.Is(err, v1.ErrIdempotencyKeyAlreadyClaimed) {
10771082
tc.l.Debug().Err(err).Msg("skipping workflow trigger with duplicate idempotency key")

pkg/repository/idempotency_trigger_test.go

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,8 +510,8 @@ func TestTriggerWorkflowIdempotency_ReclaimDoesNotUpdateLastDeniedAt(t *testing.
510510
assert.False(t, lastDeniedAt.Valid)
511511
}
512512

513-
func TestTriggerWorkflowIdempotency_MixedKeysAllowsPartial(t *testing.T) {
514-
// Mixed batch: one reclaimable, one running. We should allow the reclaimable run and skip the duplicate.
513+
func TestTriggerWorkflowIdempotency_MixedKeysRejectsPartialByDefault(t *testing.T) {
514+
// Public trigger paths should remain atomic: if any key is still duplicate, reject the whole batch.
515515
pool, cleanup := setupPostgresWithMigration(t)
516516
defer cleanup()
517517

@@ -562,6 +562,77 @@ func TestTriggerWorkflowIdempotency_MixedKeysAllowsPartial(t *testing.T) {
562562
IdempotencyKey: &keyRunning,
563563
}
564564

565+
_, _, err = repo.Triggers().TriggerFromWorkflowNames(ctx, tenantId, []*WorkflowNameTriggerOpts{terminalSecond, runningSecond})
566+
require.Error(t, err)
567+
assert.ErrorIs(t, err, ErrIdempotencyKeyAlreadyClaimed)
568+
569+
claimedTerminal := getClaimedByExternalId(t, pool, tenantId, keyTerminal)
570+
require.NotNil(t, claimedTerminal)
571+
assert.Equal(t, terminalFirst.ExternalId, *claimedTerminal)
572+
573+
claimedRunning := getClaimedByExternalId(t, pool, tenantId, keyRunning)
574+
require.NotNil(t, claimedRunning)
575+
assert.Equal(t, runningFirst.ExternalId, *claimedRunning)
576+
577+
lastDeniedAt := getLastDeniedAt(t, pool, tenantId, keyRunning)
578+
assert.True(t, lastDeniedAt.Valid)
579+
assert.WithinDuration(t, time.Now(), lastDeniedAt.Time, 2*time.Minute)
580+
}
581+
582+
func TestTriggerWorkflowIdempotency_MixedKeysAllowsPartialWhenEnabled(t *testing.T) {
583+
// Internal queued trigger processing may skip duplicates while still creating non-duplicate runs.
584+
pool, cleanup := setupPostgresWithMigration(t)
585+
defer cleanup()
586+
587+
repo, cleanupRepo := setupRepositoryWithTTL(t, pool, 30*time.Minute, 1*time.Minute)
588+
defer cleanupRepo()
589+
590+
tenantId := setupTenant(t, repo, pool)
591+
wf := createMinimalWorkflow(t, pool, tenantId, "test-workflow")
592+
593+
ctx := context.Background()
594+
keyTerminal := IdempotencyKey("idem-key-terminal-allow-partial")
595+
keyRunning := IdempotencyKey("idem-key-running-allow-partial")
596+
597+
terminalFirst := &WorkflowNameTriggerOpts{
598+
TriggerTaskData: &TriggerTaskData{
599+
WorkflowName: "test-workflow",
600+
},
601+
ExternalId: uuid.New(),
602+
IdempotencyKey: &keyTerminal,
603+
}
604+
_, _, err := repo.Triggers().TriggerFromWorkflowNames(ctx, tenantId, []*WorkflowNameTriggerOpts{terminalFirst})
605+
require.NoError(t, err)
606+
insertRunStatus(t, pool, tenantId, wf, terminalFirst.ExternalId, sqlcv1.V1ReadableStatusOlapFAILED)
607+
608+
runningFirst := &WorkflowNameTriggerOpts{
609+
TriggerTaskData: &TriggerTaskData{
610+
WorkflowName: "test-workflow",
611+
},
612+
ExternalId: uuid.New(),
613+
IdempotencyKey: &keyRunning,
614+
}
615+
_, _, err = repo.Triggers().TriggerFromWorkflowNames(ctx, tenantId, []*WorkflowNameTriggerOpts{runningFirst})
616+
require.NoError(t, err)
617+
insertRunStatus(t, pool, tenantId, wf, runningFirst.ExternalId, sqlcv1.V1ReadableStatusOlapRUNNING)
618+
619+
terminalSecond := &WorkflowNameTriggerOpts{
620+
TriggerTaskData: &TriggerTaskData{
621+
WorkflowName: "test-workflow",
622+
},
623+
ExternalId: uuid.New(),
624+
IdempotencyKey: &keyTerminal,
625+
AllowPartialIdempotency: true,
626+
}
627+
runningSecond := &WorkflowNameTriggerOpts{
628+
TriggerTaskData: &TriggerTaskData{
629+
WorkflowName: "test-workflow",
630+
},
631+
ExternalId: uuid.New(),
632+
IdempotencyKey: &keyRunning,
633+
AllowPartialIdempotency: true,
634+
}
635+
565636
_, _, err = repo.Triggers().TriggerFromWorkflowNames(ctx, tenantId, []*WorkflowNameTriggerOpts{terminalSecond, runningSecond})
566637
require.NoError(t, err)
567638

pkg/repository/ids.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ type WorkflowNameTriggerOpts struct {
3030
// (optional) The idempotency key to use for debouncing this task
3131
IdempotencyKey *IdempotencyKey
3232

33+
// AllowPartialIdempotency permits duplicate keys in the same batch to be skipped while
34+
// still creating any non-duplicate runs. Public trigger APIs should keep this disabled
35+
// so bulk requests remain atomic.
36+
AllowPartialIdempotency bool
37+
3338
// Whether to skip the creation of the child workflow
3439
ShouldSkip bool
3540
}

pkg/repository/trigger.go

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -371,17 +371,7 @@ func (s *sharedRepository) triggerFromWorkflowNames(ctx context.Context, tx *Opt
371371
triggerOpts, denyUpdateKeys, duplicateKeys, err := s.prepareTriggerFromWorkflowNames(ctx, tx.tx, tenantId, opts)
372372

373373
if err != nil {
374-
if errors.Is(err, ErrIdempotencyKeyAlreadyClaimed) && len(denyUpdateKeys) > 0 {
375-
updateErr := s.queries.UpdateIdempotencyKeysLastDeniedAt(ctx, s.pool, sqlcv1.UpdateIdempotencyKeysLastDeniedAtParams{
376-
Tenantid: tenantId,
377-
Keys: denyUpdateKeys,
378-
})
379-
if updateErr != nil {
380-
err = errors.Join(err, fmt.Errorf("failed to update idempotency key deny timestamps: %w", updateErr))
381-
}
382-
}
383-
384-
return nil, nil, nil, fmt.Errorf("failed to prepare trigger from workflow names: %w", err)
374+
return nil, nil, denyUpdateKeys, fmt.Errorf("failed to prepare trigger from workflow names: %w", err)
385375
}
386376

387377
if len(duplicateKeys) > 0 && len(triggerOpts) > 0 {
@@ -410,6 +400,17 @@ func (r *TriggerRepositoryImpl) TriggerFromWorkflowNames(ctx context.Context, te
410400

411401
tasks, dags, denyUpdateKeys, err := r.triggerFromWorkflowNames(ctx, tx, tenantId, opts)
412402
if err != nil {
403+
if errors.Is(err, ErrIdempotencyKeyAlreadyClaimed) && len(denyUpdateKeys) > 0 {
404+
tx.Rollback()
405+
updateErr := r.queries.UpdateIdempotencyKeysLastDeniedAt(ctx, r.pool, sqlcv1.UpdateIdempotencyKeysLastDeniedAtParams{
406+
Tenantid: tenantId,
407+
Keys: denyUpdateKeys,
408+
})
409+
if updateErr != nil {
410+
err = errors.Join(err, fmt.Errorf("failed to update idempotency key deny timestamps: %w", updateErr))
411+
}
412+
}
413+
413414
return nil, nil, err
414415
}
415416

@@ -2325,6 +2326,7 @@ func (r *sharedRepository) prepareTriggerFromWorkflowNames(ctx context.Context,
23252326

23262327
// each (workflowVersionId, opt) is a separate workflow that we need to create
23272328
triggerOpts := make([]triggerTuple, 0, len(opts))
2329+
allowPartialIdempotency := len(opts) > 0
23282330

23292331
for _, workflowVersion := range workflowVersionsByNames {
23302332
opts, ok := namesToOpts[workflowVersion.WorkflowName]
@@ -2334,6 +2336,8 @@ func (r *sharedRepository) prepareTriggerFromWorkflowNames(ctx context.Context,
23342336
}
23352337

23362338
for _, opt := range opts {
2339+
allowPartialIdempotency = allowPartialIdempotency && opt.AllowPartialIdempotency
2340+
23372341
if opt.IdempotencyKey != nil {
23382342
keyClaimantPair := KeyClaimantPair{
23392343
IdempotencyKey: *opt.IdempotencyKey,
@@ -2367,6 +2371,10 @@ func (r *sharedRepository) prepareTriggerFromWorkflowNames(ctx context.Context,
23672371
}
23682372
}
23692373

2374+
if len(triggerOpts) > 0 && len(duplicateKeys) > 0 && !allowPartialIdempotency {
2375+
return nil, denyUpdateKeys, duplicateKeys, &IdempotencyKeyAlreadyClaimedError{Keys: duplicateKeys}
2376+
}
2377+
23702378
if len(triggerOpts) == 0 && len(duplicateKeys) > 0 {
23712379
return nil, denyUpdateKeys, duplicateKeys, &IdempotencyKeyAlreadyClaimedError{Keys: duplicateKeys}
23722380
}

0 commit comments

Comments
 (0)