A worker test can stay green even when the message will be processed twice. Test only the first delivery: the handler finishes, the effect is created, and the case checks a successful ack. The dangerous window appears after the effect is committed, when the worker dies before it confirms the delivery to the broker.

To test that window, make the ack fail after the effect, deliver the same message again, and count the business effect. The test should prove that redelivery is possible while the effect remains singular. That is different from testing a retry function or counting how many messages the consumer received.

This article connects to the guide on durable execution with queues and checkpoints. That article helps choose how work stays alive. Here, the question is how to test a consumer when delivery can return. The transactional outbox boundary covers publication; this test covers the consumer's effect.

I am not presenting a benchmark of RabbitMQ, SQS, or another broker. I ran the local fixture with Node.js v25.5.0 and node:test. It uses in-memory state to control the ack failure. It does not prove database atomicity, multi-process behavior, or the policy of a specific provider.

Diagram showing a message being delivered, producing an effect, failing its ack, and being delivered again without repeating the effect.

Short answer

  • The worker should apply the effect before confirming the ack.
  • The test should force failure between those steps and deliver the same message again.
  • The consumer or business operation must recognize the same ID without creating a second effect.
  • An in-memory test proves the code decision, but it does not replace a test with the real broker and storage.

Why is the first green test not enough?

Distributed queues usually separate delivery from confirmation. Amazon SQS documents at-least-once delivery: under a rare condition, a copy of a message can appear again. The same documentation recommends making processing idempotent.

RabbitMQ describes a similar boundary in its documentation on consumer acknowledgements. The consumer confirms that it processed a delivery; connection failures and requeueing can make the message return. The broker cannot know whether the application created its effect before the process went down.

That is why a test that checks only ack answers a narrow question: did the code send a confirmation? It does not answer whether the operation can be repeated after the effect was persisted. The most important assertion should observe the order, record, email, or other effect that must not happen twice.

The most informative test does not start with a retry. It starts with losing the ack after the effect. That ordering imitates the failure that turns a completed operation into a delivery that appears to be pending.

What contract should the consumer test?

Write the contract before the fixture. For each delivery, define when the worker may confirm, when it should request a retry, and which situation goes to a dead-letter queue. A consumer should not use the same outcome for a transient error, an invalid payload, and an already-applied effect.

Scenario Setup What the test verifies
New delivery ID has not been processed one effect and one ack
Lost ack effect completes, ack throws delivery returns and effect does not repeat
Failure before effect handler throws before persistence no effect and a retryable message
Invalid payload schema or version is rejected failure without an effect and an explicit discard decision
Same ID in two workers two attempts overlap one operation accepts the ID and the other creates no second effect
Permanent error dependency returns a non-recoverable failure worker does not retry forever

The message ID and effect ID can be different. One event can be delivered twice, while two different messages can represent the same business operation. The deduplication rule must say which identity protects which effect.

How do you build a fixture without waiting on the clock?

Start with a small consumer. The worker runs the effect, confirms the delivery only after the effect completes, and lets an ack error escape so the delivery mechanism can redeliver the message.

type Delivery = {
  id: string;
  orderId: string;
};

type Acknowledge = (delivery: Delivery) => Promise<void>;
type ApplyEffect = (delivery: Delivery) => Promise<"applied" | "duplicate">;

async function consume(
  delivery: Delivery,
  applyEffect: ApplyEffect,
  acknowledge: Acknowledge,
) {
  const result = await applyEffect(delivery);
  await acknowledge(delivery);
  return result;
}

consume does not catch the ack failure to call the effect again in the same process. The delivery mechanism owns the retry decision. This keeps responsibilities separate: the broker decides whether the message returns; the consumer decides whether the same effect is safe.

An in-memory fixture can represent an idempotent operation with a set of IDs already applied:

class FakeEffects {
  private readonly applied = new Set<string>();
  readonly writes: string[] = [];

  async applyOnce(delivery: Delivery): Promise<"applied" | "duplicate"> {
    if (this.applied.has(delivery.id)) return "duplicate";

    this.writes.push(delivery.orderId);
    this.applied.add(delivery.id);
    return "applied";
  }
}

This Set is a fixture, not production storage. In a real system, the ID record and the effect need the transaction, unique constraint, or idempotency key appropriate for the resource. The example makes the decision visible so the test can verify it.

How do you force redelivery after the effect?

Make the first acknowledgement throw a controlled error. Then run the same delivery again with the same ID. The case passes only if the second consumption recognizes the operation as already applied and the write list still contains one item.

import assert from "node:assert/strict";
import test from "node:test";

test("redelivery after a lost ack does not repeat the effect", async () => {
  const effects = new FakeEffects();
  let acknowledgements = 0;
  const acknowledge: Acknowledge = async () => {
    acknowledgements += 1;
    if (acknowledgements === 1) {
      throw new Error("connection lost after the effect");
    }
  };

  const delivery = { id: "delivery-1", orderId: "order-1" };
  const firstAttempt = consume(delivery, effects.applyOnce.bind(effects), acknowledge);

  await assert.rejects(firstAttempt, /connection lost/);
  const secondAttempt = await consume(
    delivery,
    effects.applyOnce.bind(effects),
    acknowledge,
  );

  assert.equal(secondAttempt, "duplicate");
  assert.deepEqual(effects.writes, ["order-1"]);
  assert.equal(acknowledgements, 2);
});

The example uses node:test, which Node.js documents as its native test runner, and does not use sleep, setTimeout, or an accidental race. Run it as a TypeScript file after transpiling, or adapt the syntax to .mjs:

node --test queue-worker.test.mjs

To check that the assertion reaches the risk, temporarily remove the deduplication from FakeEffects and run the case again. The list should receive order-1 twice. A test that stays green after that removal is not measuring the effect that matters.

Which other paths belong in the matrix?

The lost-ack case is the center of the suite, but it is not the only path. Keep each scenario tied to an observable decision:

  1. Failure before the effect. Make the handler throw before writing and confirm that no write occurred. If the failure is transient, the next delivery should be able to retry.
  2. Failure after part of the effect. Separate a partial write from a complete operation. The test should prove rollback, compensation, or idempotency according to the real contract.
  3. Invalid payload. Deliver a broken schema, unknown version, or missing ID. The message must not cross into the business handler.
  4. Exhausted retry. Simulate enough attempts for the dead-letter policy and verify the recorded reason and ID.
  5. Concurrency. Start two attempts for the same ID with a controlled barrier. Verify that the effect reservation is atomic at the boundary you intend to protect.

The last case is not solved by a Set when more than one process is involved. Use the deterministic JavaScript race-condition fixture to control the algorithm's order, then test the database, cache, or broker that actually coordinates the workers.

What must remain real in the integration test?

The in-memory test is fast and explains the consumer policy. It does not prove the database transaction, ack visibility, lease timeout, broker redelivery, or recovery after the process dies. Those questions need a separate integration layer.

Use a real broker and storage for a few carefully chosen cases:

  • publish a message with a known ID;
  • let the worker persist the effect and stop the process before the ack;
  • restart the consumer and wait for redelivery;
  • verify the final state and effect count;
  • confirm that an invalid message followed the expected path.

Do not use sleep(2000) as proof that the worker finished. Wait for an observable condition with one deadline, or use an API from the broker that reports state. A sleeping test can pass without finding the message, wake too late, or hide a worker that never stopped.

The integration test should also record which part stayed real. A suite can use the real broker with a fake effect, or the real database with a controlled transport. Each combination answers a different question. Do not call it end to end if the decisive boundary is still replaced.

False signals of safety

Acknowledge before applying the effect. This reduces some redeliveries, but it opens a loss window when the process dies after the ack and before the operation. The order must follow the durability contract you chose.

Deduplicate only in worker memory. The second worker may not share the same set. Use a persistent identity or an exclusion guarantee that covers every process that can compete.

Count only received messages. Receiving a message twice is not automatically an error. The error is producing two effects when the contract requires one. Count the observable output.

Retry every error until timeout. Schema, authorization, or unknown-version errors do not become correct with more attempts. Classify transient and permanent failures before choosing retry or dead-letter.

Call the broker exactly once. Queue-service documentation commonly exposes at-least-once delivery, so the application must handle repetition. The result can be an idempotent effect, not a magical transport guarantee.

CI checklist

Before adding the worker to the pipeline, check that:

  • the test forces ack failure after the effect;
  • the same message returns with the same ID;
  • the observable effect appears once;
  • a failure before the effect can retry when the contract allows it;
  • unknown schemas and versions fail before the handler;
  • deduplication covers the processes that can compete;
  • retry, backoff, and dead-letter have separate cases;
  • the integration path uses one deadline and an observable condition;
  • the test identifies what was fake and what was real;
  • real storage proves the atomicity that the in-memory fixture cannot prove.

If messages trigger an agent operation or tool, apply the same matrix to the external effect. The test should still verify the operation ID, any required approval, and the absence of duplication. A model does not change the window between the effect and the ack.

Conclusion

A queue worker can work on its first delivery and still duplicate the job when it loses the ack. The test that finds this defect lets the effect finish, breaks the confirmation, and delivers the same message again. Then it counts the business effect instead of counting only broker calls.

The in-memory fixture makes the consumer policy fast and deterministic. The integration test still needs to cross the broker, storage, and process-recovery boundaries. These layers do not compete. One explains the consumer decision; the other checks whether infrastructure preserves it when failure occurs.

How this article was produced

Samuel Fajreldines is responsible for this article. The research combined official Amazon SQS, RabbitMQ, and Node.js documentation with current results and public discussions about redelivery, leases, and deduplication. The fixture ran locally on Node.js v25.5.0 with in-memory state. It uses no broker, database, or external service, so it does not measure distributed recovery.

I used AI assistance to organize sources, compare existing coverage, and review the matrix for consistency. The assistance did not run a production worker, measure latency, or replace verification of the selected broker's guarantees.

Sources consulted