사용 방법
- 설치 명령을 복사하고 자신의 에이전트 환경에 설치하기 전에 소스 패키지를 확인하세요.
- 필요한 도구, 인증 정보, 지원 환경은 원본 문서에서 확인하세요.
- 입력과 원하는 산출물을 설명하고 출처와 결과를 대조하세요.
npx skills add https://github.com/diagridio/catalyst-ai --skill catalyst-activity-idempotency시작 전 확인
- 설치에는 모델 구독, API 한도, 파일 접근 권한이 포함되지 않습니다.
- 별점 수와 설치 수는 활동 지표이며 독립적인 품질·보안 검증이 아닙니다.
원본 저장소의 제목, 예시, 코드, 표, 링크, 이미지를 유지해 표시합니다.
Activity Idempotency
Goal: for every activity you write or review, be able to answer one question with evidence from the code — if this body runs twice with the same input, is the state of the outside world the same as if it had run once?
If the answer is no, the activity is not finished, however well it works on the happy path.
The guarantee you are designing against
The engine guarantees each scheduled activity runs at least once. It does not guarantee exactly once, and no configuration makes it do so.
An activity runs a second time because:
- The worker crashed after the side effect committed but before the result was
persisted to history. The engine has no record of success, so it schedules again.
- The invocation exceeded its deadline and the retry policy fired.
- Infrastructure redelivered the work item.
Note where the difficulty is. In the common case the duplicate happens after the side effect already succeeded. Nothing inside the activity can detect this by looking at its own inputs — the inputs are identical by design. Only the outside world knows the work was already done, so only the outside world can refuse it.
The rule
Every side effect must carry a key that is stable across retries, and the write must be conditional on that key. A key alone does nothing; a conditional write with no stable key deduplicates nothing. You need both.
Hazard table
| Activity body does | A second run does | Fix |
|---|---|---|
INSERT a row | Two rows | Unique constraint on the natural key, then upsert or insert-ignore |
POST /payments with amount and card | Charges twice | Send the provider's idempotency key header |
| Send an email or SMS | Two messages | Dedupe key recorded in the same transaction as the send, or a provider-side key |
| Increment a counter or balance | Double count | Replace with an idempotent set, or record contributing event ids and sum |
| Publish an event | Duplicate downstream work | Deterministic message id, and make the consumer idempotent too |
DELETE by id | Second delete is a no-op | Already idempotent — nothing to do |
PUT a full computed document to a fixed key | Same document written twice | Already idempotent — nothing to do |
| Raise a business rejection, at a call site that carries a retry policy | Re-runs a rejection that cannot ever succeed | Return the rejection as data; reserve the retry policy for transient failures |
| Read-only lookup | Nothing observable | Not an idempotency concern |
Generate a key with uuid() inside the body | A new key per run, so the key deduplicates nothing | Derive the key from the activity input or the activity context |
| Append to a file or object | Content duplicated | Write to a key derived from the idempotency key instead of appending |
Choosing the key
A valid key is stable across every retry of one logical operation and distinct across different logical operations. Both halves matter: a key that changes per attempt permits duplicates; a key shared by two genuinely different operations suppresses work that should happen.
Sources, in order of preference:
- A natural key already in the input — order id, invoice number, transaction
reference. Best option: it is meaningful to the downstream system and survives a workflow rerun.
- The activity context's task execution id. In Catalyst, every retry attempt of one
activity invocation shares one stable task_execution_id; the retry backoff timer carries origin__activity_retry_task_execution_id pointing back to it. That stability is what makes it usable as a key.
- Workflow instance id plus something that identifies this call site — usable when
an activity is called once per workflow run.
Never use:
- A value generated inside the activity body — a fresh UUID, the current timestamp,
a random suffix. It differs on every attempt, which is precisely the property a key must not have. This is the most common idempotency bug in review.
- The per-attempt scheduling id. Each retry attempt is a distinct scheduled task
with its own task_scheduled_id. Keying on it gives every attempt a different key.
- A child workflow's own instance id, when that child may be retried. A retried
child workflow is a new instance with a new instance id — unlike an activity retry, which reuses one execution id. If the key must survive child-workflow retry, derive it from the parent, or pass it down in the child's input.
Why check-then-act is not enough
The pattern below is common, reads as careful, and does not make an activity idempotent:
# WRONG — two operations with a gap between them
existing = shipments.find_by_key(key)
if existing:
return existing.id # looks like it short-circuits the duplicate
return shipments.create(...) # ... but two attempts can both reach hereTwo failures:
- The gap. The read and the write are separate operations. Two attempts can both
read "absent", then both write.
- Retries are not necessarily serial. An attempt that exceeded its deadline has not
necessarily stopped running. The engine schedules the retry while the first attempt may still be in flight, so the two overlap — exactly the interleaving the check does not cover.
Make the write itself the thing that refuses the duplicate:
# CORRECT — one conditional operation; the store decides
shipments.insert(key=key, ...) ON CONFLICT (key) DO NOTHING
return shipments.find_by_key(key).idAny of these work, because in each the store adjudicates: a unique constraint, an upsert, a compare-and-swap, a conditional PUT with an etag or version precondition, or INSERT ... IF NOT EXISTS.
Check-then-act is still worth keeping as a latency optimization in front of a conditional write — it avoids the round trip in the common case. It is not the correctness mechanism, and it must never be the only mechanism.
Non-idempotent third-party calls
Some providers offer no idempotency key and no way to query by your own reference. For those, no in-activity trick makes the call safe. Record intent, then reconcile:
- Before the call, commit an intent record keyed by the idempotency key, with
status pending and whatever reference you will use to recognize the operation later.
- Make the call.
- After the call, update the record to
succeeded(storing the provider's own id)
or failed.
On a retry, read the intent record first:
| Intent record | Meaning | Do |
|---|---|---|
| Absent | The first attempt did not reach the provider | Proceed from step 1 |
pending | Outcome unknown — the call may or may not have landed | Reconcile, do not re-issue |
succeeded | Already done | Return the stored provider id |
failed | Terminal | Return the failure, or retry per policy if transient |
Reconcile by querying the provider for operations matching your reference — a search by your own order id, a list over the relevant window, a webhook already received.
If reconciliation is genuinely impossible, then the choice is between at-most-once (risk losing the operation) and at-least-once (risk duplicating it). That is a business decision with money or user trust attached. Surface it to the user and let them choose. Do not pick silently, and do not present a pending record as if it were resolved.
Retries belong at the call site
Configure the retry policy where the activity is called, in the orchestrator — not inside the activity body. Engine-driven retries use durable timers, so the backoff survives a worker restart and every attempt appears in history.
A retry loop written inside the activity body is invisible to history, does not survive a crash, and multiplies with the engine's own retries. Do not add one.
Retry transient failures — timeouts, connection resets, 429, 503, transient deadlocks. Do not retry input validation failures, business rejections, or permanent 4xx: they consume the retry budget and delay the real failure without any chance of succeeding.
Classify the failure, then choose where it is expressed. There are two routes, and they are not equally available:
- Return the rejection as a typed result the orchestrator branches on. Always
available, in every SDK, because it is just a return value. Reach for this first.
- Express it in the retry policy's error predicate. Only if your SDK's policy type
exposes one — and several do not. dapr-ext-workflow's Python RetryPolicy (verified on 1.18.3) takes first_retry_interval, max_number_of_attempts, backoff_coefficient, max_retry_interval and retry_timeout, and exposes no predicate. The engine underneath it does support one — durabletask's own retry policy carries non_retryable_error_types — but the wrapper never passes it through, so from your code there is nothing to set. Check your own SDK's policy type before designing around a predicate; where it exposes none, the typed result is not an alternative, it is the only route.
Concretely, for a business rejection: give the call site no retry policy at all, or give the activity a return type that carries the rejection — {"outcome": "rejected", "reason": ...} — and let the orchestrator decide. Raising an exception the engine can see is what puts the rejection into the retry budget.
A direct-call unit test cannot catch this. Calling the activity function yourself never involves the engine, so no retry happens and the test passes no matter how the call site is configured. A full suite proves nothing here. Assert the classification instead: every activity that can raise a permanent rejection is either called with no retry policy, or returns that rejection as a value.
Keep the payload small
Activity inputs and outputs are serialized into workflow history in the project's workflow store. Pass identifiers, not documents. Return a status and a reference, not a response body. Large payloads slow every subsequent replay of that instance and can exceed state store limits — a failure that surfaces late, under load, on the instances that matter most.
An idempotency key is small and belongs in the input. Put it there explicitly rather than reconstructing it in the body, so it is visible to a reviewer.
Catalyst notes
- One KV store per project.
number_of_kvstores_per_projectis 1 on every Catalyst
plan, and no plan upgrade raises it. These are plan values overlaid per organization, not constants in the code, so read the live quota rather than asserting the 1 — and do not promise a user it can be raised for them, which is a commercial question rather than one you can answer. A dedupe or intent table backed by the managed KV store therefore shares one component with all other state in the project: namespace your keys (dedupe:<workflow>:<key>) rather than assuming a dedicated store. Do not propose a second KV store as the fix.
- A missing output in run history may be withheld, not absent. At the default
metadata data-sharing level, workflow and activity input, output and customStatus are removed from the response, not returned empty. When you are checking history to find out whether a duplicate occurred, you cannot see payloads over MCP at that level. Say "the payload was not shared at this organization's data-sharing level" and name the level — never "the activity returned nothing", which the user has no way to detect as wrong. Only an organization administrator can raise the level, so never forge a data-sharing header and never ask for it so you can finish an answer. What remains available over MCP: event types and names, timestamps, task_scheduled_id, task_execution_id, the retry origin key, and failure messages with stack traces.
- Link the user to the run when deciding whether a duplicate happened depends on a
payload you cannot see. The route is /workflows/<appId>/<runId>. The project is a query parameter with two spellings and no cross-fallback — ?project= matches the name-like project id (default), ?projectId= matches the numeric uid (prj- prefix optional). A numeric uid passed as ?project= matches no name, so the console never switches project and the run does not resolve where it lands. Name goes in ?project=, number in ?projectId=; if you cannot tell which you hold, omit the parameter. The console for the production server is https://catalyst.diagrid.io. A link that 404s or opens the wrong project is worse than no link: fall back to the run id and app id in plain text.
Rules
- Use the Catalyst MCP tools for everything in Catalyst.
- Assume at least once. An activity that is correct only if it runs once is a bug,
not a risk to monitor.
- A key generated inside the activity body is not an idempotency key. Flag it every
time.
- Never let check-then-act stand as the only protection. Require a conditional write.
- One retry policy per failure class, not one per workflow. One policy object applied
to every activity call in an orchestrator is the finding: it retries permanent rejections on the same schedule as transient failures.
- Do not report a withheld payload as an empty one. Name the data-sharing level.
- Do not resolve an unknown outcome by guessing. A
pendingintent with no
reconciliation path is a question for the user.
- This is static work. Reviewing or writing an activity for idempotency needs the
source and, at most, reads of run history. It never needs to run the workflow, start an instance, or modify state to prove a point. Do not create a project — the organization's default project already has a managed workflow store.
- For hazards in the orchestrator body rather than the activity body, use the
catalyst-workflow-determinism skill. Non-determinism inside an activity is intended and is not a finding.

