Mehdi Akiki
Published on

Keep HTTP Calls Outside the Checkout Transaction

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Article · Interrupted execution

A shipping request is still waiting, and a second checkout cannot update stock. The first handler owns a transaction. Inside it, the code changed stock and then called a provider. A slow dependency now decides how long the database lock remains held.

There is a second problem. The provider can accept the shipping command before the local transaction rolls back. Putting an HTTP call between two SQL statements does not put the provider inside the database transaction.

I would give shipping its own durable state. Commit the local order with a command that a worker can recover. Tell the customer what that state means. Then make the worker's retry contract explicit.

Commit an order and its intent together

The reproduction uses PostgreSQL 18, SQLx 0.8.6, and a controlled shipping adapter. The complete fixture is in the Rust database release tests. It uses a disposable schema. It is not a production checkout implementation.

There are three local tables: stock, orders, and shipping commands. The command has a unique identity, ship-42, and refers to order 42. This is the local transaction:

async fn checkout(pool: &PgPool, commit: bool) -> std::result::Result<(), sqlx::Error> {
    let mut tx = pool.begin().await?;
    sqlx::query("UPDATE stock SET quantity = quantity - 1 WHERE id = 1")
        .execute(&mut *tx)
        .await?;
    sqlx::query("INSERT INTO orders VALUES (42, 'shipping_pending')")
        .execute(&mut *tx)
        .await?;
    sqlx::query("INSERT INTO shipping_outbox VALUES ('ship-42', 42, 'pending')")
        .execute(&mut *tx)
        .await?;
    if commit {
        tx.commit().await?;
    } else {
        tx.rollback().await?;
    }
    Ok(())
}

The boolean is a test control. The rollback branch represents stopping before local acceptance. A real handler needs authorization, a stock condition that prevents overselling, and a stable order identity. Those concerns are covered in the MySQL checkout example. This new lab uses PostgreSQL to examine the external-work boundary; it does not claim identical driver behavior across engines.

After rollback, the observer must see stock two, no order, and no command. After commit, it must see stock one, a pending order, and a pending command. Closing the pool and opening another one checks that the command is stored in the database rather than remembered only by a task.

An outbox is that stored intent. Its value is the shared local commit boundary. The outbox explanation develops the pattern further.

Let the provider wait outside the stock transaction

The worker reads a pending command and releases the query connection. It calls the controlled adapter with the stored command identity. It then updates command and order state in another short transaction.

The adapter pauses after recording acceptance. While it is paused, another transaction updates the stock row with a bounded lock wait. That update must finish. This directly checks that the worker did not keep the checkout's stock lock while waiting for the external step.

A pool with four connections is enough for this fixture. More connections would not repair a stock lock retained by the wrong transaction. In a service, trace both the checkout lifetime and the worker lifetime. A successful SQL statement does not show when its transaction or checked-out connection ends.

The adapter is an in-process test double. It models acceptance and duplicate handling. It does not reproduce an HTTP proxy, TLS failure, provider durability, or an undocumented remote idempotency policy. Those need their own integration checks.

Stop after acceptance, then recover

The awkward interruption happens after provider acceptance and before the worker records success. The test waits for an explicit signal at that point, aborts the worker task, and waits for cancellation to finish. It then starts a new worker operation using the same pending row.

The provider double keeps one result per command identity. Repeating ship-42 with the same order returns the same logical result. The test checks one provider identity, a completed command, and a confirmed shipping state after recovery. Running the worker again must leave those results unchanged.

This verifies a cancellation boundary and a database reconnect. It is not an operating-system process-crash or power-loss test. The provider double survives the worker cancellation, just as an independently running provider can outlive a lost caller.

A real provider must document what its key covers, how long it retains that key, and how changed input is handled. If the provider cannot deduplicate or answer an outcome lookup, the local outbox still helps recover intent. It cannot manufacture certainty about a remote effect.

Make pending useful to the customer and operator

A pending order needs an owner. Define its maximum age, retry budget, and escalation path. Decide whether a customer can cancel it and when stock may be released. A retryable shipping failure and a permanently invalid address usually need different actions.

The small worker in this lab is single-worker code. It does not implement leases, competing claims, or ordering across several workers. Add those before running multiple consumers. Keep the remote call outside long-held database locks while designing the claim protocol.

Return a response that describes the committed fact. An accepted order with shipping pending is useful information. Calling it shipped before confirmation creates a false promise. After an incident, the stored order, command identity, provider lookup, and recorded outcome should let a person explain what happened and continue the operation safely.