Files
activity-core/docs/idempotency.md
tegwick 6f9132314f Add project scaffold: contracts, schemas, docker-compose, workplans
Phase 0 contracts (event envelope, ActivityDefinition, idempotency doc,
naming conventions) and Phase 1 Temporal cluster setup (docker-compose.dev.yml,
Temporal dynamic config) are complete. Includes Pydantic models, JSON schemas,
wiki architecture docs, and ADR-001 workplan files for both workstreams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 22:45:40 +01:00

73 lines
2.7 KiB
Markdown

# Idempotency Contract
## Workflow ID strategy
Every `RunActivityWorkflow` execution has a deterministic, stable workflow ID.
This is the primary idempotency mechanism — Temporal rejects duplicate starts
with the same workflow ID in the same namespace.
| Trigger type | Workflow ID format |
|---|---|
| Cron / scheduled | `activity-{activity_id}:{scheduled_for.isoformat()}` |
| External event | `activity-{activity_id}:{event.event_id}` |
**Example (cron):** `activity-550e8400-e29b-41d4-a716-446655440000:2026-03-01T09:00:00+00:00`
**Example (event):** `activity-550e8400-e29b-41d4-a716-446655440000:evt_abc123`
Both formats guarantee that re-delivering a trigger (broker retry, duplicate schedule fire)
produces at most one workflow execution.
---
## Misfire policy
A *misfire* occurs when a scheduled trigger fires after its nominal time because the
worker was down, overloaded, or the schedule was paused.
| Policy | `dedupe_key_strategy` value | Behaviour |
|---|---|---|
| Skip | `"skip"` | Missed runs are discarded. Temporal `ScheduleOverlapPolicy.SKIP`. |
| Catch up | `"catchup"` | Missed runs are replayed up to 10 times (configurable). Uses `schedule.handle.backfill()`. |
| Compress | `"compress"` | One run is executed with a widened context window covering all missed intervals. |
The `dedupe_key_strategy` on `ActivityDefinition` must match the `misfire_policy` on
`CronTriggerConfig`. They are separate fields to allow the schedule configuration
(how Temporal fires) to be decoupled from the workflow logic (how missed runs are handled).
---
## Event deduplication
For event-driven activities:
1. The Event Router computes the workflow ID as `activity-{activity_id}:{event.event_id}`.
2. It calls `client.start_workflow(..., id=workflow_id, id_reuse_policy=REJECT_DUPLICATE)`.
3. If the workflow ID already exists (event was already processed), Temporal returns
`WorkflowAlreadyStartedError` — the router logs it and moves on.
**Prerequisite:** every inbound event must have a stable `event_id`. Events without a
stable ID must be assigned one by the ingress boundary before entering the system.
---
## Task instance idempotency
Each `TaskInstance` spawned by `RunActivityWorkflow` gets its own unique workflow ID:
```
task-{run_id}:{task_type}:{index}
```
This ensures that if `RunActivityWorkflow` is replayed by Temporal (e.g. after a worker
restart), it does not re-spawn task instances that were already started.
---
## Database idempotency
`activity_runs` uses `run_id` as the primary key (UUID). The `log_run` activity
uses an upsert (`INSERT ... ON CONFLICT DO NOTHING`) so that Temporal activity retries
do not produce duplicate run records.
`task_instances` similarly uses an upsert on `id`.