Delivery Guarantees
Exactly-once dispatch, at-least-once execution, and how to design idempotent tasks.
Exactly-once dispatch, at-least-once execution, and how to design idempotent tasks.
FlexiQ gives exactly-once dispatch: an atomic claim in storage makes sure two workers never pick up the same job at once. But the system as a whole is at-least-once execution — if a worker crashes after it starts a task but before the result is recorded, the job is retried and the task body runs again. Design task code to tolerate that.
Before dispatching a job, the scheduler makes an atomic claim on it — a
guarded update only one caller can win (SQLite: INSERT OR IGNORE; Postgres:
INSERT ... ON CONFLICT DO NOTHING; Redis: SET NX). If another scheduler
instance already claimed the job, the claim fails and that instance skips it.
There's no manual ack/nack: a claimed job's redelivery is driven by the claim lapsing, not by a consumer forgetting to acknowledge. You still get the same at-least-once contract you'd design a JMS listener around.
A handler reports what it returns — Ok, an Abort::Fail, or a sleep — and
nothing else. Redelivery is driven by the claim lapsing, so a worker that dies
mid-body acknowledges nothing and the job comes back on its own. A panic is
caught rather than lost: it becomes a retryable failure with the panic's
message, so it travels the same path as any other one.
The claim prevents duplicate dispatch — two workers picking up the same job at once. It does not prevent duplicate execution after a crash: the claim lapses once the stale reaper detects the timeout, and the job is dispatched again as a new attempt.
Exactly-once delivery is impossible in general: a worker can run a task's side effect and then crash before it acknowledges completion, so the job is retried and the side effect runs again. No protocol closes this gap — the effect and the acknowledgement can't be committed atomically across separate systems. What you can get is exactly-once effect, by making tasks idempotent (dedup keys, upserts) or routing writes through a transactional outbox/inbox. This matches Celery, SQS, and most production job systems: deliver at least once, design tasks to handle duplicates.
Because a job may run more than once, design task code to be safe on re-execution:
@queue.task()
def create_user(email, name):
# UPSERT — safe to run twice
db.execute(
"INSERT INTO users (email, name) VALUES (?, ?) "
"ON CONFLICT (email) DO UPDATE SET name = ?",
(email, name, name),
)@queue.task()
def charge_customer(order_id, amount):
# Check if already charged
if db.execute("SELECT 1 FROM charges WHERE order_id = ?", (order_id,)).fetchone():
return # Already processed
payment_provider.charge(amount, idempotency_key=f"order-{order_id}")
db.execute("INSERT INTO charges (order_id, amount) VALUES (?, ?)", (order_id, amount))import { currentJob } from "@byteveda/flexiq";
queue.task("charge", async (orderId: string) => {
const job = currentJob();
await payments.charge(orderId, { idempotencyKey: job?.jobId });
});// Java handlers receive only the payload, so idempotency comes from a
// natural key in it — here order.id().
Worker worker = queue.worker()
.handle(CHARGE, order -> {
if (charges.exists(order.id())) return; // already processed on a prior attempt
payments.charge(order.total(), order.id()); // key also dedupes at the provider
charges.record(order.id(), order.total());
})
.start();// A task body receives its decoded arguments and nothing else, so idempotency
// comes from a natural key in them — here `order_id`.
#[flexiq::task]
fn charge_customer(order_id: String, amount_cents: i64) -> flexiq::Outcome<()> {
let retry = |e: std::io::Error| flexiq::TaskError::retryable(e.to_string());
if charges::exists(&order_id).map_err(retry)? {
return Ok(()); // already processed on a prior attempt
}
payments::charge(amount_cents, &order_id).map_err(retry)?; // also dedupes at the provider
charges::record(&order_id, amount_cents).map_err(retry)?;
Ok(())
}The other answer is a durable step:
flexiq::current_step().run("charge", ..) records what the body returned, so
the next attempt decodes it instead of charging again.
# Bad — sends duplicate emails on retry
@queue.task()
def notify(user_id):
send_email(user_id, "Your order shipped")
# Good — atomically claim the send before doing it
@queue.task()
def notify(user_id):
claimed = db.execute(
"UPDATE orders SET notified = 1 WHERE user_id = ? AND notified = 0",
(user_id,),
)
if claimed.rowcount:
send_email(user_id, "Your order shipped")The guarded UPDATE is atomic — two overlapping executions can't both win
it, unlike a separate check-then-send. The trade-off: a crash between the
claim and the send drops the email (at-most-once for that side effect). When
the provider accepts an idempotency key, prefer that — it keeps the retry
and dedupes the send, as in the charge example above.
A blind send re-sends on every attempt, and the body has no way to tell attempt one from attempt two. Claim the send first, and only do it when this attempt won the claim:
#[flexiq::task]
fn notify(user_id: String) -> flexiq::Outcome<()> {
let retry = |e: std::io::Error| flexiq::TaskError::retryable(e.to_string());
if orders::claim_notification(&user_id).map_err(retry)? {
email::send(&user_id, "Your order shipped").map_err(retry)?;
}
Ok(())
}The guarded claim is atomic — two overlapping attempts cannot both win it, unlike a separate check-then-send. The trade-off: a crash between the claim and the send drops the email (at-most-once for that side effect). When the provider accepts an idempotency key, prefer that; when it does not, a durable step records what the body returned, so the next attempt decodes it instead of sending again.
A unique key coalesces duplicate enqueues into the same job while the first job for that key is still pending or running — the second call returns the existing job's id instead of creating a new one. It's backed by a partial unique index in storage, so the dedup is atomic across producers and processes.
# Only one pending/running instance per key
job1 = send_report.apply_async(args=(user_id,), unique_key=f"report-{user_id}")
job2 = send_report.apply_async(args=(user_id,), unique_key=f"report-{user_id}")
assert job2.id == job1.id # coalesced onto job1 — no duplicate createdqueue.enqueue("welcome", [user.id], { uniqueKey: `welcome:${user.id}` });
queue.enqueue("welcome", [user.id], { uniqueKey: `welcome:${user.id}` }); // ignoredEnqueueOptions once = EnqueueOptions.builder()
.uniqueKey("welcome:" + user.id())
.build();
String first = queue.enqueue(WELCOME, user.id(), once);
String second = queue.enqueue(WELCOME, user.id(), once); // same id — no new joblet key = format!("welcome:{}", user.id);
let first = queue.enqueue(welcome::call(user.id.clone()).unique_key(key.clone()))?;
let second = queue.enqueue(welcome::call(user.id.clone()).unique_key(key))?;
assert_eq!(second.id, first.id); // coalesced onto the first — no new job.idempotent() derives the key from the task name and payload instead of naming
one; an explicit .unique_key(..) beats it.
The key frees up once the job finishes — a duplicate enqueue after that creates a fresh job. Only a pending or running job holds the key; one that completed, was dead-lettered, or was cancelled no longer blocks reuse.
See Unique Tasks
for the full unique_key reference.
See Unique tasks for the full
.unique_key(..) and .idempotent() reference, and which of the two wins.
See Idempotency for more on designing dedup keys.
| Concern | Who handles it |
|---|---|
| Job dispatch deduplication | Framework — the atomic claim |
| Job enqueue deduplication | Framework — the unique key |
| Crash recovery | Framework — the stale reaper |
| Idempotent execution | You — task code |
| Side-effect safety | You — task code |
| Guarantee | FlexiQ | Celery | SQS |
|---|---|---|---|
| Delivery | At-least-once | At-least-once | At-least-once |
| Duplicate prevention | Atomic claim (dispatch-level) | Visibility timeout | Visibility timeout |
| Deduplication | Unique key (enqueue-level) | Manual | Message dedup ID |
| Crash recovery | Stale reaper (timeout-based) | Worker ack timeout | Visibility timeout |
A job's result is stored durably, so any process sharing the same storage can look it up by job id — not just the worker that produced it. Useful when a web backend enqueues a job and a different process needs to poll its outcome.
job = queue.get_job(job_id)
result = job.result(timeout=10) # blocks until the job settlesSee queue.get_job() and
JobResult.
A completed job's result is stored and can be awaited by id from any process sharing the storage — see result.
Coming from BullMQ? BullMQ's
QueueEventsis a dedicated Redis Streams consumer, so any process can listen for job outcomes. flexiq'squeue.on(event, handler)fires inside the worker process handling the job — it isn't a cross-process subscription. For visibility from another process, pollqueue.getJob(id)/queue.stats(), or use the dashboard / REST API.
A completed job's result is stored and can be read by id from any process
sharing the storage — queue.getResult(jobId, Receipt.class). In tests,
queue.awaitJob(jobId, timeout) blocks until the job settles.
queue.get_job(job_id)? reads a job by id from any process sharing the storage.
What it hands back is the row: job.result is an Option<Vec<u8>> holding the
encoded result envelope, and the decoder that reads one is private to the crate,
so there is no typed result read — the crate's own quickstart example
prints the byte length and stops there.
Nothing blocks until a job settles either. Poll get_job until job.status is
terminal, which is what that example does:
while Instant::now() < deadline {
if let Some(job) = queue.get_job(job_id)? {
if matches!(job.status, JobStatus::Complete | JobStatus::Failed) {
break;
}
}
std::thread::sleep(Duration::from_millis(25));
}