This guide writes a checkout as a saga on Neki: a sequence of single-shard transactions, each with an undo step, instead of one transaction across shards. The checkout creates an order on the customer's shard, reserves stock on the product's shard, and charges the customer's wallet. If a step fails, or the process dies halfway, the compensating steps release the stock, refund the charge and cancel the order. Every step can be repeated safely, so a recovery job can run them again.
What you will build
orders, wallets, charges -> shard group by_customer, xxhash(customer_id)
inventory, reservations -> shard group by_sku, xxhash(sku_id)
checkout: create order (pending) -> reserve stock -> charge wallet -> confirm order
compensate: release stock (delete reservation, add stock back) + refund (delete charge, add balance back) + cancel order
recover: compensate every order that is still pending after MIN_AGE
AI Prompt
Using the PlanetScale CLI (pscale 0.344.0) and psql, write a checkout saga with compensating steps for orders, stock and wallets that live on different Neki shards.
Requirements:
- Create the Neki database with --engine neki, --cluster-size PS-10-AWS-ARM-NEKI and --replicas 2, add two shards, and wait until every shard answers a direct SELECT 1 with SET __neki.shard. Use one database role for every step.
- Shard orders, wallets and charges by customer_id, and inventory and reservations by sku_id, each in its own shard group with an xxhash shard index. Keep CHECK (on_hand >= 0) and CHECK (balance_cents >= 0).
- Make every step one single-shard transaction. Reserve stock with UPDATE ... WHERE on_hand >= qty RETURNING and write the reservation row in the same transaction. Charge with UPDATE ... WHERE balance_cents >= amount RETURNING and write the charge row in the same transaction. Use a psql \gset variable and \if for the condition, because a CTE that contains DML with ON CONFLICT is not supported.
- Make every compensating step idempotent by deleting the reservation or charge row with RETURNING and undoing the change only when a row came back.
- Add a recovery command that compensates orders that are still pending after an age limit.
- Run an order that succeeds, one that is out of stock, one with insufficient funds, and one whose process dies after the charge.
- Run the verification step below before finishing.
Prerequisites
A PlanetScale organization with access to the Neki Platform Preview. Create an account if you do not have one, and see Neki availability and access for how an organization administrator joins the preview.
pscale0.344.0 with a service token that can create databases,psql18.6,jq, andpython3:
export PLANETSCALE_SERVICE_TOKEN_ID="<token id>"
export PLANETSCALE_SERVICE_TOKEN="<token>"
export PLANETSCALE_ORG="<organization name>"
pscale version
- A Neki database with three shards. Create it with these commands:
pscale database create saga --engine neki --org "$PLANETSCALE_ORG" \
--region us-east --cluster-size PS-10-AWS-ARM-NEKI --replicas 2 --wait
pscale role create saga main admin --inherited-roles postgres --org "$PLANETSCALE_ORG" --format json > role.json
export PGHOST=$(jq -r .access_host_url role.json) PGUSER=$(jq -r .username role.json) \
PGPASSWORD=$(jq -r .password role.json) PGPORT=5432 PGDATABASE=postgres PGSSLMODE=require
rm role.json
until psql -At -c "SELECT 1" >/dev/null 2>&1; do sleep 5; done
pscale branch shard create saga main --config-profile default --count 2 --org "$PLANETSCALE_ORG"
until [ "$(psql -At -c 'SELECT count(*) FROM __neki.list_shards()')" = "3" ]; do sleep 10; done
for u in $(psql -At -c "SELECT uid FROM __neki.list_shards()"); do
until psql -At -c "SET __neki.shard='$u';" -c "SELECT 1" >/dev/null 2>&1; do sleep 5; done
done
SHARD_1=$(psql -At -c "SELECT uid FROM __neki.list_shards() WHERE name='sh1'")
SHARD_2=$(psql -At -c "SELECT uid FROM __neki.list_shards() WHERE name='sh2'")
SHARD_3=$(psql -At -c "SELECT uid FROM __neki.list_shards() WHERE name='sh3'")
Note: Output samples are trimmed to the relevant lines. Each shard bills as a three-node cluster.
PS-10-AWS-ARM-NEKIlists at $30 per month per shard, prorated to the millisecond. Current rates are on the Neki pricing page. Delete the database when you finish withpscale database delete saga --force --org "$PLANETSCALE_ORG".
1. Create the tables, shard them and load stock and wallets
psql -q -v ON_ERROR_STOP=1 \
-c "CREATE TABLE inventory (sku_id bigint PRIMARY KEY, on_hand int NOT NULL CHECK (on_hand >= 0))" \
-c "CREATE TABLE reservations (sku_id bigint NOT NULL, order_id bigint NOT NULL, qty int NOT NULL, PRIMARY KEY (sku_id, order_id))" \
-c "CREATE TABLE wallets (customer_id bigint PRIMARY KEY, balance_cents bigint NOT NULL CHECK (balance_cents >= 0))" \
-c "CREATE TABLE charges (customer_id bigint NOT NULL, order_id bigint NOT NULL, amount_cents bigint NOT NULL, PRIMARY KEY (customer_id, order_id))" \
-c "CREATE TABLE orders (customer_id bigint NOT NULL, order_id bigint NOT NULL, sku_id bigint NOT NULL, qty int NOT NULL, amount_cents bigint NOT NULL, status text NOT NULL DEFAULT 'pending', created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (customer_id, order_id))" \
2>&1 | grep -v NOTICE
cat > data-topology.json <<JSON
{
"shard_indexes": {
"customer": { "type": "xxhash", "columns": ["customer_id"] },
"sku": { "type": "xxhash", "columns": ["sku_id"] }
},
"shard_groups": [
{ "uid": "$SHARD_1", "key_ranges": [ { "shard_uid": "$SHARD_1" } ] },
{ "uid": "by_customer", "default_shard_index": "customer",
"key_ranges": [ { "shard_uid": "$SHARD_2", "end": "80" }, { "shard_uid": "$SHARD_3", "start": "80" } ] },
{ "uid": "by_sku", "default_shard_index": "sku",
"key_ranges": [ { "shard_uid": "$SHARD_2", "end": "80" }, { "shard_uid": "$SHARD_3", "start": "80" } ] }
],
"databases": { "postgres": { "schemas": { "public": { "tables": {
"orders": { "shard_group": "by_customer" },
"wallets": { "shard_group": "by_customer" },
"charges": { "shard_group": "by_customer" },
"inventory": { "shard_group": "by_sku" },
"reservations": { "shard_group": "by_sku" }
} } } } },
"default_shard_group": "$SHARD_1",
"authoritative_shard_group": "$SHARD_1"
}
JSON
pscale branch data-topology update saga main --org "$PLANETSCALE_ORG" --format json < data-topology.json | jq -r '.status // "applied"'
sleep 10
psql -q -c "INSERT INTO inventory VALUES (1, 5), (2, 0)" -c "INSERT INTO wallets VALUES (1, 10000), (2, 500)"
psql -At -c "SELECT 'sku ' || sku_id || ': ' || on_hand FROM inventory ORDER BY sku_id" -c "SELECT 'customer ' || customer_id || ': ' || balance_cents FROM wallets ORDER BY customer_id"
Real output:
applied
sku 1: 5
sku 2: 0
customer 1: 10000
customer 2: 500
SKU 1 has 5 units and SKU 2 is out of stock. Customer 1 has 10,000 cents and customer 2 has 500.
2. Write the saga
cat > saga.sh <<'EOF'
#!/bin/bash
# saga.sh checkout <customer> <order> <sku> <qty> <amount> | recover
# Every step is one single-shard transaction. CRASH_AFTER=charge simulates a process that dies after that step.
P="psql -X -q -At -v"
create_order() { $P c=$1 -v o=$2 -v s=$3 -v n=$4 -v a=$5 <<'SQL'
INSERT INTO orders (customer_id, order_id, sku_id, qty, amount_cents) VALUES (:c, :o, :s, :n, :a);
SQL
}
reserve() { $P s=$1 -v o=$2 -v n=$3 <<'SQL' 2>&1 | grep -v 'no rows returned'
BEGIN;
UPDATE inventory SET on_hand = on_hand - :n WHERE sku_id = :s AND on_hand >= :n RETURNING 1 AS ok \gset
\if :{?ok}
INSERT INTO reservations (sku_id, order_id, qty) VALUES (:s, :o, :n);
\echo reserved
\else
\echo out_of_stock
\endif
COMMIT;
SQL
}
charge() { $P c=$1 -v o=$2 -v a=$3 <<'SQL' 2>&1 | grep -v 'no rows returned'
BEGIN;
UPDATE wallets SET balance_cents = balance_cents - :a WHERE customer_id = :c AND balance_cents >= :a RETURNING 1 AS ok \gset
\if :{?ok}
INSERT INTO charges (customer_id, order_id, amount_cents) VALUES (:c, :o, :a);
\echo charged
\else
\echo insufficient_funds
\endif
COMMIT;
SQL
}
confirm() { $P c=$1 -v o=$2 <<'SQL'
UPDATE orders SET status = 'confirmed' WHERE customer_id = :c AND order_id = :o AND status = 'pending';
SQL
}
release_stock() { $P s=$1 -v o=$2 <<'SQL' 2>&1 | grep -v 'no rows returned'
BEGIN;
DELETE FROM reservations WHERE sku_id = :s AND order_id = :o RETURNING qty AS q \gset
\if :{?q}
UPDATE inventory SET on_hand = on_hand + :q WHERE sku_id = :s;
\echo released
\else
\echo no_reservation
\endif
COMMIT;
SQL
}
refund() { $P c=$1 -v o=$2 <<'SQL' 2>&1 | grep -v 'no rows returned'
BEGIN;
DELETE FROM charges WHERE customer_id = :c AND order_id = :o RETURNING amount_cents AS a \gset
\if :{?a}
UPDATE wallets SET balance_cents = balance_cents + :a WHERE customer_id = :c;
\echo refunded
\else
\echo no_charge
\endif
COMMIT;
SQL
}
cancel() { $P c=$1 -v o=$2 <<'SQL'
UPDATE orders SET status = 'cancelled' WHERE customer_id = :c AND order_id = :o AND status = 'pending';
SQL
}
compensate() { # compensate <customer> <order> <sku>: undo whatever was done, in reverse order
echo " compensate order $2: $(refund $1 $2), $(release_stock $3 $2)"
cancel $1 $2
}
checkout() { # checkout <customer> <order> <sku> <qty> <amount>
create_order "$@"
r=$(reserve $3 $2 $4); [ "$r" = reserved ] || { echo "order $2: $r"; compensate $1 $2 $3; return 2; }
r=$(charge $1 $2 $5); [ "$r" = charged ] || { echo "order $2: $r"; compensate $1 $2 $3; return 3; }
[ "${CRASH_AFTER:-}" = charge ] && { echo "order $2: process died after the charge"; return 99; }
confirm $1 $2; echo "order $2: confirmed"
}
recover() { # compensate every order that has stayed pending longer than MIN_AGE
$P age="${MIN_AGE:-5 minutes}" -At -F' ' <<'SQL' | while read -r c o s; do echo "recover order $o"; compensate $c $o $s; done
SELECT customer_id, order_id, sku_id FROM orders WHERE status = 'pending' AND created_at < now() - :'age'::interval ORDER BY order_id
SQL
}
"$@"
EOF
chmod +x saga.sh
./saga.sh checkout 1 1001 1 2 2400
Real output:
order 1001: confirmed
The first order created the order, reserved two units, charged 2,400 cents and confirmed. Each of those was a separate single-shard transaction on the shard that owns the row.
3. Out of stock
./saga.sh checkout 1 1002 2 1 100; echo "exit=$?"
psql -At -c "SELECT order_id || ' ' || status FROM orders WHERE order_id = 1002"
Real output:
order 1002: out_of_stock
compensate order 1002: no_charge, no_reservation
exit=2
1002 cancelled
SKU 2 has no stock, so the reservation step answered out_of_stock. Nothing had been charged, the compensation found nothing to undo, and the order was cancelled.
4. Insufficient funds
./saga.sh checkout 2 1003 1 1 1500; echo "exit=$?"
psql -At -c "SELECT 'sku 1: ' || on_hand FROM inventory WHERE sku_id = 1" -c "SELECT order_id || ' ' || status FROM orders WHERE order_id = 1003"
Real output:
order 1003: insufficient_funds
compensate order 1003: no_charge, released
exit=3
sku 1: 3
1003 cancelled
Customer 2 has 500 cents, so the charge step failed after the stock had been reserved. The compensation released the unit back to SKU 1 and cancelled the order. SKU 1 shows 3 units, which are the 5 it started with less the 2 for the confirmed order.
5. A process that dies after the charge
CRASH_AFTER=charge ./saga.sh checkout 1 1004 1 1 800; echo "exit=$?"
psql -At -c "SELECT order_id || ' ' || status FROM orders WHERE order_id = 1004" \
-c "SELECT 'reserved: ' || count(*) FROM reservations WHERE order_id = 1004" \
-c "SELECT 'charged: ' || count(*) FROM charges WHERE order_id = 1004"
Real output:
order 1004: process died after the charge
exit=99
1004 pending
reserved: 1
charged: 1
The order is still pending, with a reservation and a charge. This is what a crashed checkout leaves behind. The customer paid and the order was not confirmed.
6. Recover
MIN_AGE='0 seconds' ./saga.sh recover
MIN_AGE='0 seconds' ./saga.sh recover; echo "second run printed: $(MIN_AGE='0 seconds' ./saga.sh recover | wc -l | tr -d ' ') lines"
psql -At -c "SELECT order_id || ' ' || status FROM orders ORDER BY order_id"
Real output:
recover order 1004
compensate order 1004: refunded, released
second run printed: 0 lines
1001 confirmed
1002 cancelled
1003 cancelled
1004 cancelled
The recovery job refunded the 800 cents, released the unit, and cancelled order 1004. Running it again found no pending orders and did nothing. In production keep the default MIN_AGE of 5 minutes, so that recovery does not compensate a checkout that is still running.
Verify the result
psql -At -c "SELECT status || ' ' || count(*) FROM orders GROUP BY status ORDER BY status" \
-c "SELECT 'sku 1: ' || on_hand || ' on hand, ' || (SELECT coalesce(sum(qty), 0) FROM reservations WHERE sku_id = 1) || ' reserved' FROM inventory WHERE sku_id = 1" \
-c "SELECT 'wallet 1: ' || balance_cents || ', charges ' || (SELECT coalesce(sum(amount_cents), 0) FROM charges WHERE customer_id = 1) FROM wallets WHERE customer_id = 1"
Expected: cancelled 3, confirmed 1, sku 1: 3 on hand, 2 reserved, wallet 1: 7600, charges 2400. Stock on hand plus reserved is the starting 5, the wallet plus its charges is the starting 10,000, and the only reservation and charge left belong to the confirmed order.
How it works
No step spans two shards, so no cross-shard transaction is needed. Each step is one transaction on the shard that owns the row, and the router sends it there because the shard key is in the WHERE clause. A step records what it did in a row on its own shard (the reservation, the charge), and the compensating step deletes that row and undoes the change only when the delete returned a row. That makes each compensation safe to run twice, and it means recovery needs no log of which steps ran: it looks at the rows themselves. The condition is a psql \gset variable and \if, because a statement that puts INSERT ... ON CONFLICT inside a CTE fails with [992]. The Neki data topology guide describes how a shard key is hashed and placed.
Common issues
A saga is eventually consistent
Between two steps the data is partly done. After step 5 the customer has paid for an order that is still pending. Read the orders.status column, not the reservation or charge rows, to decide what to show a customer.
Recovery must not race a running checkout
recover compensates every order that has been pending for MIN_AGE. Set it longer than the slowest checkout. This guide tested it with an age of zero seconds on a crashed order and did not test recovery running at the same moment as a live checkout.
Compensation runs in reverse order
compensate refunds first, then releases stock, then cancels the order. The order stays pending until the end, so a crash in the middle leaves it for the next recover to finish.
A unique key per step
The reservation key (sku_id, order_id) and the charge key (customer_id, order_id) allow one reservation and one charge per order. A retry of a step after a crash fails on the key instead of reserving or charging twice.
Next steps
- Delete the database with
pscale database delete saga --force --org "$PLANETSCALE_ORG". - Read the guide on completing a cross-shard transfer reliably with an outbox table for a pattern that delivers one side of a movement later.
- Read the Neki data topology guide for placing tables in a shard group.
verification:
status: verified
tested_at: "2026-10-29"
product_version: "pscale 0.344.0, Neki PostgreSQL 18.6 (Platform Preview)"
command: "order counts by status, stock on hand plus reserved, wallet balance plus charges"
expected_result: "cancelled 3, confirmed 1, sku 1: 3 on hand, 2 reserved, wallet 1: 7600, charges 2400"