core.async Retry Strategy: Avoiding Duplicate Writes Without Idempotency Keys
0 reputation · 20 Nov 2025, 21:23 UTC
0 reputation · 20 Nov 2025, 21:23 UTC
In a ClojureScript application that uses core.async to serialize side effects, a common pattern is to re‑queue a failed operation on a channel to retry later. The goal is to guarantee that the underlying write – e.g. a database insert or an HTTP POST – is performed at most once, even if the message is retried multiple times.
Because core.async channels do not enforce semantic idempotency, the developer must decide how to distinguish a genuine retry from a new operation. One approach is to embed a unique identifier in the message and guard the write with an atom or a transactional flag. Another is to rely on an effect‑handler that checks a flag before performing the side effect. The ambiguity lies in whether the same message can be sent again without creating a duplicate write, and how to enforce that boundary when the channel buffer grows or when the retry logic is triggered from multiple goroutines.
Unresolved decisions include: the best way to generate and propagate an idempotency key across retries; the trade‑off between buffering retries in an unbounded channel versus using a bounded channel with back‑pressure; and whether to treat a retry as a separate event or as an idempotent re‑dispatch of the same event.
What mechanisms can be used to prevent duplicate writes when re‑queuing a message in core.async?
How does the size of a core.async channel buffer affect the risk of duplicate writes during rapid retry cycles?
Is it more reliable to guard writes with an atom flag or to rely on a transactional event‑handler that checks a global idempotency flag?
34275 reputation · 20 Nov 2025, 23:23 UTC
1. Mechanisms to prevent duplicate writes when re‑queuing a message in core.async?
INSERT … ON CONFLICT DO NOTHING or UPDATE … WHERE version = expected so that the operation only takes effect once.System/nanoTime or a short hash) and include it in the message. On retry, the same payload is sent; the database will reject duplicates via the unique constraint.atom that records the last processed ID. Before performing the side effect, atomically check and set the flag. This protects against duplicate writes from the same process.2. Impact of core.async channel buffer size on duplicate‑write risk during rapid retries
3. Atom flag vs. transactional global idempotency check: which is more reliable?
user-id + event-id).(defn make-token [payload]
(str (System/nanoTime) ":" (hash payload)))
Attach the token to the message before putting it on the channel.
(defmacro with-retry [max-retries & body]
`(loop [attempt# 0]
(try
~@body
(catch Exception e#
(if (< attempt# max-retries)
(do
(js/console.warn "Retry" attempt# ":" e#)
(recur (inc attempt#)))
(throw e#))))))
(with-retry 5
(jdbc/execute! db-conn
["INSERT INTO events (user_id, event_id, token) VALUES (?,?,?)"
[user-id event-id token]]))
In databases that support ON CONFLICT, replace the insert with INSERT … ON CONFLICT DO NOTHING to make the operation idempotent.
(def processed (atom #{}))
(defn safe-write [payload]
(let [id (:token payload)]
(when (swap! processed (fn [s] (if (contains? s id) s (conj s id))))
;; perform the write
(db/write payload))))
(jdbc/with-transaction [tx db-conn]
(let [exists? (jdbc/execute! tx
["SELECT 1 FROM flags WHERE token = ?" [token]])]
(when-not (seq exists?)
(jdbc/execute! tx
["INSERT INTO flags (token) VALUES (?)" [token]])
(db/write payload))))
Do you currently have a unique constraint on the database columns that identify the record? If not, the atom or transaction guard alone will not prevent duplicate writes; the database itself must enforce uniqueness.
Use comments to ask for clarification. Post a solution as an answer.
34,275 reputation · 21 Nov 2025, 02:58 UTC
Place a distinct transducer on the retry channel that filters messages by their UUID before they reach the consumer go‑block. The transducer keeps a set of seen IDs; any subsequent put of the same UUID is dropped, guaranteeing at‑most‑once processing even if the message is re‑queued many times. To prevent the set from growing without bound, pair it with a periodic cleanup go‑loop that removes entries older than a configurable TTL (e.g., using java.util.concurrent.ScheduledThreadPoolExecutor or a core.async timeout channel). This approach works regardless of channel buffer size or the number of concurrent consumers because the deduplication happens before each take!.