fix(triggers): make trigger occurrence insert idempotent (EN-1221) - #210
Quentin-David-24 wants to merge 1 commit into
Conversation
InsertTriggerOccurrence did a plain INSERT with an unlimited retry policy. The occurrence id is part of the recorded activity input, so once the row is committed but the result never reaches the Temporal server (StartToClose timeout fired while the commit was in flight, or the worker was killed between commit and ack), every retry fails on triggers_occurrences_pkey and ExecuteTrigger is wedged forever -- so no SUCCEEDED_TRIGGER/FAILED_TRIGGER event is ever published, and for SAVED_PAYMENT/SAVED_ACCOUNT the wedged run holds its deterministic workflow id and swallows redeliveries for that object. - ON CONFLICT (id) DO NOTHING on the insert, mirroring what InsertNewStage already does for the same failure mode. - Occurrence id is now the workflow execution id instead of a uuid minted in workflow code, so it is replay-stable. A random uuid there yielded a different id on every replay, and the id published in the termination event could reference a row that does not exist. The occurrence id is not exposed by the v1/v2 API (only date, triggerID, workflowInstanceID, event and error are), so the id format change is not API-visible.
✅ Approve — automated reviewThe insert is now idempotent for replayed activity inputs, and the occurrence ID is stable across workflow replays. No blocking correctness issues were found. No findings. |
|
This PR makes the trigger-occurrence insert idempotent: the occurrence id is now the (deterministic, replay-stable) Temporal workflow execution id instead of a uuid minted in workflow code, and the insert gained StandardsNo confirmed material finding. The repo documents no coding standards applicable to Spec
Everything else in the PR body checks out: the Reviewed independently by GLM (glm-5.3-flash) and DeepSeek (deepseek-v4-pro-0813) via Shipfox; verified and synthesized by GLM. |
| // The workflow execution id is deterministic and replay-stable, unlike a uuid | ||
| // generated here in workflow code. | ||
| occurrence := NewTriggerOccurrence( | ||
| temporalworkflow.GetInfo(ctx).WorkflowExecution.ID, |
There was a problem hiding this comment.
Will the workflow ID be reused on subsequent executions (see the docs here?
I'm not super familiar with the context of this workflow but the name suggests we might want a new row in the DB every time the workflow runs, so I'm a bit worried we might skip inserting rows when Temporal starts recycling workflow IDs.
Can we generate some truly unique identifiers based on maybe workflow.ID & workflow.RunID like elsewhere?
Jira: EN-1221 (component: Flows, epic EN-1217)
Problem
Seen in production:
ExecuteTriggerstuck with theInsertTriggerOccurrenceactivity failing on a loop withInsertTriggerOccurrence(internal/triggers/activities.go) does a plainINSERT, and its activity options set onlyStartToCloseTimeout: 10swith no retry policy, so the SDK default applies: unlimited attempts, no schedule-to-close cap.The occurrence id is part of the recorded activity input, so it is frozen in the workflow history. Once the row is committed but the result never reaches the server — the 10s timeout fires while the commit is in flight (slow DB, lock contention, connection-pool wait), or the worker is killed/rolled out between commit and ack — every subsequent retry replays the same id, can only ever fail on the primary key, and retries forever.
Knock-on effects:
SendEventForTriggerTerminationruns after the insert, so noSUCCEEDED_TRIGGER/FAILED_TRIGGERevent is ever published for those runs.SAVED_PAYMENT/SAVED_ACCOUNTthe workflow id is deterministic withREJECT_DUPLICATE(listener.go), so a wedged run holds that id and silently swallows redeliveries for the same object.This is the same failure mode
InsertNewStagealready guards against — it carries the comment "when a workflow times out this might already be present on retry" and anON CONFLICT DO NOTHING(internal/workflow/activities.go). Triggers never got it.Fix
ON CONFLICT (id) DO NOTHINGon the occurrence insert, so a retry after a lost ack is a no-op instead of a permanent failure.uuid.NewString()minted in workflow code. That makes it replay-stable, which is what makesDO NOTHINGcorrect rather than silently lossy — a random uuid in workflow code also meant the id published in the termination event could reference a row that does not exist.The occurrence id is not part of the public API surface (the v1/v2
TriggerOccurrenceschemas expose onlydate,triggerID,workflowInstanceID,workflowInstance,event,error), so the id format change is not API-visible. It does appear asidin theSUCCEEDED_TRIGGER/FAILED_TRIGGERevent payloads, which are unconstrained strings.Tests
New
TestInsertTriggerOccurrenceIsIdempotent: calls the activity twice with the same occurrence and asserts no error plus a single row. Verified it reproduces the production error without the fix:go test ./internal/...is green.Scope
Deliberately narrow — only the wedging. EN-1221 also covers the broader at-least-once redelivery problem (deterministic workflow id for all event types, so a redelivery cannot replay side-effecting
sendstages); that part stays in #199, along with the equivalentON CONFLICTforInsertNewInstance. This is the subset that unblocks stuck production workflows and is small enough to review on its own. Supersedes the occurrence-idempotency part of the closed #184.Backport to a release branch on request — no released version (latest tag
v2.6.3) has this fix.