Idempotency
In Causet, projection workers and other event consumers may process the same event more than once. Exactly-once delivery is not guaranteed by Kafka or by the projection worker. Your handlers must be idempotent — processing the same event twice must produce the same result as processing it once.
Causet’s design makes this straightforward in most cases. Understanding where idempotency is built-in and where it requires explicit design is important for production correctness.
Where idempotency is built-in
Projection UPSERTs. The default projection mutation is upsert — insert on new primary key, update on conflict. If the same event is processed twice, the UPSERT produces the same row. No duplicates.
Relationship edges with unique: true. If relationship_create is called twice for the same pair of entity IDs, the second call is a no-op. No duplicate edge.
Entity snapshot updates. The runtime tracks the last-applied ledger event version in the entity snapshot. Applying the same event twice produces the same snapshot state via the same rule evaluation.
Where you must be explicit
Delete mutations. A delete mutation on a row that has already been deleted is a no-op in PostgreSQL (DELETE WHERE ... IF EXISTS). This is safe. But if your projection applies a delete before an upsert (out-of-order event processing), the row is removed and not re-created until the next upsert event is processed. Order-dependent projections are fragile.
Aggregate projections. A counter projection using op: add, by: 1 increments on every event processing. If the same event is processed twice, the counter is incremented twice — wrong. Aggregate projections are inherently at-risk for double-processing. Mitigate by ensuring the Kafka consumer group offset is committed only after successful UPSERT, and by using a deduplication table keyed on event ID.
External side effects. If your application sends emails, calls webhooks, or charges payment methods in response to events, these are not automatically idempotent. The at-least-once delivery guarantee means the same event may trigger the external call more than once.
Deterministic IDs
For entities and events where the primary key must be stable and predictable, use deterministic ID construction rather than random UUIDs.
# Deterministic ticket ID: platform-show-seat
actions:
RESERVE_TICKET:
state: show
entity_id_expr: intent.show_id
input:
show_id: { type: string, required: true }
user_id: { type: string, required: true }
seat: { type: string, required: true }
side_effects:
rules:
- name: emit_reserved
when: {}
then:
- op: emit
event_type: TICKET_RESERVED
payload:
ticket_id: "concat('ticket_', intent.show_id, '_', intent.seat)"
show_id: intent.show_id
user_id: intent.user_id
seat: intent.seatUsing concat('ticket_', intent.show_id, '_', intent.seat) produces the same ID regardless of how many times the intent is submitted (assuming the same inputs). This means:
- If the intent is retried by the caller, the second attempt produces the same
ticket_id. - The projection UPSERT on
ticket_idis idempotent — the second attempt writes the same row. - There is no duplicate ticket.
Warning: Never use
uuid(),random(), ornow()in ID construction. These are non-deterministic and will produce different IDs on each run, breaking idempotency and replay.
Primary key collision risk
If your deterministic ID formula can produce the same key for two logically distinct intents, you have a collision problem. For example:
ticket_id = concat('ticket_', show_id, '_', seat)If two users both try to reserve the same seat, they would produce the same ticket_id. This should be caught by a preflight rule (seat availability check), not relied on via UPSERT semantics.
Design your primary key formula to be unique per logical entity, and pair it with preflight validation that prevents the same key from being legitimately claimed by two different callers.
At-least-once patterns for external side effects
When a side_effect triggers an external service (email, webhook, payment), treat it as at-least-once:
- Include an idempotency key in every external call. Use a deterministic value derived from the event (e.g.
concat('email_', event.ticket_id, '_', event.type)). - The external service must deduplicate on this key. Most email providers and payment processors support idempotency keys.
- Do not assume the side_effect fired exactly once. The projection worker may re-process events. The external service is your idempotency boundary.
side_effects:
rules:
- name: send_confirmation
when: {}
then:
- op: submit
action: SEND_CONFIRMATION_EMAIL
entity_id: intent.user_id
payload:
idempotency_key: "concat('confirm_', intent.ticket_id)"
ticket_id: intent.ticket_id
user_id: intent.user_idThe SEND_CONFIRMATION_EMAIL action handler should pass idempotency_key to the email service.
Projection deduplication for aggregates
To make aggregate projections idempotent, track which event IDs have been applied:
projections:
ticket_sale_dedup:
source_events: [TICKET_PURCHASED]
target:
table: ticket_sale_dedup
primary_key: [event_id]
fields:
event_id: TEXT
show_id: TEXT
derive:
event_id: event.entity_id
show_id: event.payload.show_id
mutations:
TICKET_PURCHASED: { op: upsert }Then aggregate via a join query against the dedup table rather than an incremental counter. This is more complex but eliminates double-counting.
Alternatively, rely on the fact that the projection worker commits Kafka offsets only after successful UPSERT. If the process crashes between UPSERT and offset commit, it re-processes the event — but the UPSERT is a no-op. For simple counters this double-processing risk is an operational trade-off you accept.
Common mistakes
Relying on exactly-once delivery. Neither Kafka nor the projection worker guarantees exactly-once. Design for at-least-once.
Using non-deterministic IDs. uuid(), random(), and now() in ID expressions break idempotency and replay. Use deterministic expressions built from stable input fields.
Treating aggregate projections as inherently safe. Counter projections (op: add, by: 1) are at-risk for double-counting under re-processing. Acknowledge this risk explicitly and decide whether the operational exposure is acceptable for your use case.
Not passing idempotency keys to external services. If a side_effect calls an external service, the idempotency guarantee is the external service’s responsibility. You must pass a stable idempotency key.
See also
- Best Practices — intent design guidance
- Expressions — deterministic ID construction
- Replay — rebuilding state from the ledger
- Projections — UPSERT-based read models