Eventual Consistency with QueueBear

Design systems that guarantee eventual consistency across microservices, even when services experience downtime or failures.

What is Eventual Consistency?

Eventual consistency is a consistency model where, given enough time without new updates, all replicas of data will converge to the same state. Unlike strong consistency (where all nodes see the same data immediately), eventual consistency allows for temporary inconsistencies while guaranteeing that the system will eventually become consistent.

When to use eventual consistency:

  • Distributed microservices that need to share state
  • Systems where availability is more important than immediate consistency
  • Long-running processes that span multiple services
  • Scenarios where network partitions may occur

QueueBear's Delivery Semantics

QueueBear provides at-least-once delivery with deduplication capabilities, which together enable effectively-once semantics:

Guarantee Description
At-least-once Every message is delivered at least once (may be delivered multiple times on retry)
Deduplication Messages with the same deduplicationId are only created once
Idempotency keys Workflows with the same idempotencyKey return the existing run

Message Deduplication

Prevent duplicate message processing at publish-time:

await qb.messages.publish(
  "https://orders.example.com/create",
  { orderId: "order-123", items: [...] },
  {
    deduplicationId: "create-order-123",  // Same ID = same message returned
  }
);

Workflow Idempotency

Ensure workflow runs are unique:

const { runId } = await qb.workflows.trigger(
  "order-fulfillment",
  "https://app.com/workflows/fulfill",
  { orderId: "order-123" },
  {
    idempotencyKey: "fulfill-order-123",  // Same key = same run returned
  }
);

Step Caching: Exactly-Once Step Execution

The most powerful feature for eventual consistency is step caching. When using serve() workflows, each step is executed exactly once, even if the workflow is retried:

export const POST = serve<{ orderId: string }>(async (ctx) => {
  // Step 1: Charge payment
  // If workflow fails after this step and is retried,
  // this step returns the cached result instead of charging again
  const payment = await ctx.run("charge-payment", async () => {
    return await paymentService.charge(ctx.input.orderId, amount);
  });

  // Step 2: Reserve inventory
  // Only executes if step 1 completed successfully
  await ctx.run("reserve-inventory", async () => {
    await inventoryService.reserve(ctx.input.orderId, items);
  });

  return { success: true, paymentId: payment.id };
});

How it works:

  1. When a step completes, its result is stored in the database
  2. If the workflow is re-executed (after failure, timeout, or sleep), the cached result is returned
  3. The step function is NOT called again - preventing duplicate side effects

Consistency Windows

When designing for eventual consistency, understand the timing:

Operation Typical Consistency Window
Message delivery Immediate to seconds (depends on delays)
Retry on failure 1s → 2s → 4s → 8s → 16s (exponential backoff)
DLQ recovery Manual (minutes to hours, depending on monitoring)
Workflow step Immediate (cached results available instantly)
Sleep resume At scheduled time (±30 seconds)

Example: Order System with Eventual Consistency

Here's a complete example of an order processing system:

Workflow Definition

export const POST = serve<OrderInput>(async (ctx) => {
  const { orderId, customerId, items, total } = ctx.input;

  // Step 1: Validate inventory (cached)
  const inventoryCheck = await ctx.run("validate-inventory", async () => {
    const response = await fetch("https://inventory.internal/validate", {
      method: "POST",
      body: JSON.stringify({ items }),
    });
    if (!response.ok) throw new Error("Insufficient inventory");
    return response.json();
  });

  // Step 2: Process payment (cached - critical for consistency)
  const payment = await ctx.run("process-payment", async () => {
    const response = await fetch("https://payments.internal/charge", {
      method: "POST",
      body: JSON.stringify({ customerId, amount: total, orderId }),
    });
    if (!response.ok) throw new Error("Payment failed");
    return response.json();
  });

  // Step 3: Reserve inventory (cached)
  await ctx.run("reserve-inventory", async () => {
    await fetch("https://inventory.internal/reserve", {
      method: "POST",
      body: JSON.stringify({ orderId, items }),
    });
  });

  // Step 4: Create shipment (cached)
  const shipment = await ctx.run("create-shipment", async () => {
    const response = await fetch("https://shipping.internal/create", {
      method: "POST",
      body: JSON.stringify({ orderId, items }),
    });
    return response.json();
  });

  return {
    success: true,
    orderId,
    paymentId: payment.id,
    shipmentId: shipment.id,
  };
});

Triggering with Idempotency

async function processOrder(order: Order) {
  const { runId } = await qb.workflows.trigger(
    "order-fulfillment",
    "https://app.com/api/workflows/order",
    {
      orderId: order.id,
      customerId: order.customerId,
      items: order.items,
      total: order.total,
    },
    {
      // Idempotency key ensures this order is only processed once
      idempotencyKey: `process-order-${order.id}`,
      maxDuration: 3600,  // 1-hour timeout
    }
  );
  return runId;
}

Handling Prolonged Downtime

When a downstream service experiences extended outages:

1. Monitor the Dead Letter Queue

const { entries } = await qb.dlq.list();

for (const entry of entries) {
  console.log(`Failed: ${entry.destination}`);
  console.log(`Reason: ${entry.failureReason}`);
  console.log(`Attempts: ${entry.totalAttempts}`);
}

2. Recover When Service Returns

// Retry all failed messages when service recovers
const results = await qb.dlq.retryAll();
console.log(`Recovered ${results.length} messages`);

3. Use Event-Based Waiting

export const POST = serve<OrderInput>(async (ctx) => {
  await ctx.run("create-order", async () => { /* ... */ });

  // Wait for external service to signal readiness
  const externalResult = await ctx.waitForEvent(
    "wait-for-external-service",
    "external.service.ready",
    {
      eventKey: ctx.input.orderId,
      timeoutSeconds: 86400,  // Wait up to 24 hours
    }
  );

  await ctx.run("complete-with-external", async () => {
    await externalService.complete(externalResult.data);
  });
});

Best Practices

1. Design Idempotent Consumers

app.post("/api/webhooks/order-created", async (req, res) => {
  const { orderId } = req.body;

  // Check if already processed
  const existing = await db.orders.findUnique({ where: { id: orderId } });
  if (existing) {
    return res.json({ success: true, message: "Already processed" });
  }

  await db.orders.create({ data: { id: orderId, ... } });
  res.json({ success: true });
});

2. Use Unique Identifiers

const orderId = crypto.randomUUID();

await qb.messages.publish(endpoint, { orderId }, {
  deduplicationId: `order-${orderId}`,
});

3. Track State Externally

await ctx.run("update-order-status", async () => {
  await db.orders.update({
    where: { id: orderId },
    data: {
      status: "processing",
      lastStepCompleted: "payment",
      updatedAt: new Date(),
    },
  });
});

Summary

Feature Consistency Benefit
Step caching Exactly-once step execution, even on retry
Idempotency keys Prevent duplicate workflow runs
Deduplication IDs Prevent duplicate message creation
DLQ with recovery No message loss, manual intervention possible
Event coordination Cross-service synchronization
Retry with backoff Automatic recovery from transient failures