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.
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 CASState‑machine fragment:
WAIT_PAY ──markPaid──► PAID
│
└cancel(TIMEOUT) ► CANCELLEDOnly 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 & deadlineTimer 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 silently4. 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
payDeadlinemust 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
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
