/furkantopaloglu
Articles

· 2 min read

Designing idempotent consumers for at-least-once delivery

Exactly-once is a property you build at the consumer, not something the broker hands you.

#architecture#kafka#golang

Every broker worth using gives you at-least-once delivery. That means your consumer will, eventually, see the same message twice: after a rebalance, a timeout, or a deploy at the wrong moment. "Exactly-once" is not a broker feature you switch on; it is a property you build on the consuming side.

Make the side effect the dedupe key

The simplest reliable approach is to record the message ID in the same transaction as the business change. If the insert into the dedupe table fails on a unique constraint, you have already processed it.

func (h *Handler) Handle(ctx context.Context, msg Message) error {
	return h.db.WithTx(ctx, func(tx *sql.Tx) error {
		res, err := tx.ExecContext(ctx,
			`INSERT INTO processed_messages (id) VALUES ($1) ON CONFLICT DO NOTHING`, msg.ID)
		if err != nil {
			return err
		}
		if n, _ := res.RowsAffected(); n == 0 {
			return nil // duplicate, already applied
		}
		return h.apply(ctx, tx, msg)
	})
}

Rules I follow

  1. The dedupe record and the state change commit atomically.
  2. Message IDs come from the producer, never generated by the consumer.
  3. Dedupe rows get a TTL that is longer than your broker's retention.
  4. Operations that are naturally idempotent (SET x = 5) skip the table entirely.

Idempotency is cheap when you design for it up front and painful to retrofit after the first duplicate charge.