How to Automatically Cancel Unpaid Orders When They Timeout

The article explains a reliable, idempotent solution for automatically cancelling orders that remain unpaid after a configured deadline, covering data modeling, state transitions, trigger mechanisms using delayed messages or scans, handling race conditions with payment, and essential monitoring and pitfalls.

Xike
Xike
Xike
How to Automatically Cancel Unpaid Orders When They Timeout

Background and Requirements

In e‑commerce, ticketing, food‑delivery and similar scenarios a user creates an order that enters a waiting‑for‑payment window (commonly 15–30 minutes). If payment is not completed before the deadline the system must:

Set the order to a cancelled (or equivalent closed) state.

Release occupied resources such as stock, coupons or quota.

Close or invalidate the unfinished payment order to avoid later mismatched payments.

Functional Scope

Pay deadline – fixed at order creation as payDeadline.

Automatic cancellation – when the deadline passes the waiting order moves to the cancelled state.

Cancellation side‑effects – release stock and notify the payment service (asynchronously).

Mutual exclusion with payment – an order cannot be both PAID and CANCELLED.

Idempotence & retry – repeated delayed messages or scans must produce the same result.

Non‑functional Requirements

Timeliness : cancellation should finish within seconds to minutes after the deadline.

Correctness : final state must be unique and auditable despite concurrent payment callbacks.

Throughput : high‑traffic periods must not overload the primary database.

Observability : track cancellation volume, failures, and payment‑cancellation conflicts.

Problem Essence

The core difficulty is not a simple scheduled task that flips status to CANCELLED. Four intertwined issues must be addressed:

Time is an asynchronous trigger; the deadline must be decoupled from business processing and rely on a reliable scheduler or messaging system.

Race with payment callbacks: users may complete payment seconds before or after the deadline, and cancellation tasks may be delayed or duplicated.

Cancellation has external side‑effects (stock release, payment closure) that can temporarily leave the system inconsistent, requiring compensation.

Changing only local status is insufficient; if the payment channel still considers the order open, money may be received for a cancelled order.

Recommended Practices

1. Model – pay deadline as a first‑class field

Order
  orderId
  status: WAIT_PAY|PAID|CANCELLED
  payDeadline   // persisted at creation, used for cancellation decision
  cancelReason? // TIMEOUT / USER / PAY_FAIL ...
  version        // optimistic‑lock version for CAS

State‑machine fragment:

WAIT_PAY ──markPaid──► PAID
    │
    └cancel(TIMEOUT) ► CANCELLED

Only orders in WAIT_PAY are eligible for timeout cancellation; PAID or already CANCELLED tasks return idempotently.

Both markPaid and cancel use conditional updates (optimistic lock or UPDATE … WHERE status='WAIT_PAY') to guarantee mutual exclusion.

If a payment arrives after cancellation, the system follows a compensation path (refund or manual reconciliation) rather than forcing the order back to PAID.

2. Trigger Mechanism – delayed message as primary, scan as backup

Delayed message / queue – main path. When the order is persisted, publish a message scheduled for payDeadline that invokes CancelUnpaidOrder.

Periodic scan – backup. SQL: pay_deadline < now() AND status=WAIT_PAY. Protects against message loss, process crash, or clock drift.

Reconciliation task – convergence. Handles cases such as cancelled orders with unreleased stock or paid orders without fulfillment.

2.1 Using RocketMQ for delayed cancellation

After order persistence, send a delayed message to a dedicated topic:

// After order persistence
msg.body = { orderId, payDeadline }
// RocketMQ 5.x Timer – precise delivery
msg.setDeliverTimeMs(payDeadline.toEpochMilli())
producer.send(msg)
// Or classic delay level (≈18 levels: 1s, 5s, …, 2h)
msg.setDelayTimeLevel(levelForAtLeast(payDeadline))
producer.send(msg)

Consumer simply calls the idempotent command:

onMessage(msg):
  CancelUnpaidOrder(msg.orderId)   // internal re‑validation of status & deadline

Timer message (RocketMQ 5.x) – set deliverTimeMs = payDeadline for exact timing.

Classic delay level – choose a level ≥ the payment window (e.g., 15 min). If the chosen level is smaller the message may fire early; the consumer must re‑check payDeadline before cancelling.

2.2 In‑process time wheel

A time wheel partitions future time into fixed slots; tasks are placed into the slot corresponding to their deadline. Netty's HashedWheelTimer or a simple DelayQueue implement this pattern.

tick →
┌───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │…│ n │   // each slot holds a set of orderIds
└───┴───┴───┴───┴───┘
        ▲
        current pointer

Order creation → compute slot from payDeadline and enqueue orderId
Tick reaches slot → dequeue and invoke CancelUnpaidOrder(orderId)

Because an in‑process wheel does not survive process crashes, a DB‑scan backup is mandatory, and in multi‑instance deployments the wheel must be externalized (e.g., MQ or Redis) rather than kept locally.

3. Idempotent Commands for Cancellation and Payment

usecase CancelUnpaidOrder(orderId, expectedDeadline?):
  order = repo.get(orderId)
  if order.status != WAIT_PAY:
    return  // already paid or cancelled – idempotent success
  if now < order.payDeadline:
    return  // not yet due – let next attempt handle it
  order.cancel(TIMEOUT)               // internal status check
  repo.save(order)                     // optimistic‑lock retry on conflict
  inventory.release(order.occupyToken) // idempotent
  payment.close(order.paymentId)       // async retry possible
usecase OnPaymentSucceeded(orderId, payEvidence):
  order = repo.get(orderId)
  try:
    order.markPaid(payEvidence)      // WAIT_PAY → PAID, idempotent for duplicate callbacks
    repo.save(order)
    // proceed with fulfillment...
  catch AlreadyCancelled:
    // order was timed‑out – trigger refund / reconciliation, do not swallow silently

4. Coordination with Downstream Services

Inventory : release(occupyToken). Must be idempotent; failures go to a compensation queue.

Payment : close / cancel the payment order. If channel close fails but callback succeeds, fall back to refund/reconciliation.

Coupon / Quota : synchronously return the resource, handled similarly to inventory.

The system tolerates brief intermediate states (order cancelled but stock not yet released) by relying on retries and reconciliation; distributed transactions are not attempted.

5. Monitoring

Successful vs. failed timeout cancellations and backlog of still‑WAIT_PAY orders.

Conflicts between cancellation and payment (e.g., AlreadyCancelled leading to refunds).

Inventory release failures.

Latency distribution of delayed messages (P99 execution after deadline).

Common Pitfalls and Mitigations

Pitfall 1: Scanning without a stored deadline

Problem: Using created_at + 15min in SQL makes rule changes retroactively affect historic orders.

Fix: Persist payDeadline at creation; rule changes only affect new orders.

Pitfall 2: Non‑conditional updates cause race

Problem: Both payment and cancellation read WAIT_PAY, then write PAID or CANCELLED, leading to lost updates.

Fix: Use CAS updates such as UPDATE … WHERE id=? AND status='WAIT_PAY' AND version=?; if affected rows = 0, reload and decide again.

Pitfall 3: Delayed message processed without re‑checking deadline or status

Problem: Early delivery or already‑paid order gets cancelled.

Fix: Reload order at execution; skip if not WAIT_PAY or deadline not reached.

Pitfall 4: Cancellation succeeds but inventory release fails without compensation

Fix: Make inventory release a retryable task and monitor "cancelled but not released" cases.

Pitfall 5: Payment succeeds after order is cancelled

Fix: Payment callback must detect cancelled state and trigger refund/reconciliation instead of silently ignoring.

Pitfall 6: Selecting a too‑small RocketMQ delay level

Problem: Message fires before the payment window, causing premature cancellation.

Fix: Choose a level ≥ the actual deadline and re‑validate payDeadline inside the consumer; prefer RocketMQ 5.x Timer for precise timing.

Verification and Testing

Normal path : order → deadline → successful cancellation → stock released.

Payment within window : payment succeeds → status PAID; later timeout message results in a no‑op.

Race condition : payment and timeout occur almost simultaneously; final state is unique and observable.

Duplicate delivery : same cancel message processed three times; inventory released only once.

Scan fallback : deliberately drop delayed message; periodic scan still cancels the order.

Payment after cancellation : callback arrives after order is cancelled → triggers refund/reconciliation.

Domain rules can be covered by unit tests (see ./order-timeout-cancel) without a full middleware stack.

Conclusion

payDeadline

must be persisted on the order; cancellation relies on the stored deadline and current status.

Combine delayed triggers with a scan backup: delayed messages (RocketMQ Timer, classic delay levels, time wheel, or Redis ZSET) provide timeliness; scans guarantee no‑loss.

Both cancellation and payment use conditional updates (CAS) to ensure a single final state; conflicts are handled via refund/reconciliation.

Do not delete delayed messages after payment; let the idempotent cancellation absorb them.

Inventory release and payment closure must be idempotent and retryable, accepting short intermediate inconsistencies.

A minimal code model and single‑machine delay semantics demonstrate the core ideas.

Source code repository: https://gitcode.com/business-project/movie-ticket-domain

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

backenddistributed systemstransactionmessage queueRocketMQidempotentorder timeout
Xike
Written by

Xike

Stupid is as stupid does.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.