DEV Community

Mhamd Ghanoum
Mhamd Ghanoum

Posted on

Cancel or send: one winner. An order flow on Prisma Postgres and Compute

Werbung / Disclosure: Prisma gave me a coupon for two months of a paid plan for free and said it will add free credits, so I could try Prisma Postgres and Compute for this project. All results below come from my own runs.

Summary

  • I built an order flow with a 10-minute grace period. Cancellation and dispatch must have exactly one winner in the database.
  • The winner is decided in Postgres, with a guarded update, a row lock and SKIP LOCKED. No clock and no timer decides it.
  • I broke the code on purpose, one line at a time, to check that my tests really fail. One of my race tests passed even without the lock.
  • On Compute, an idle app sleeps. My poller is a timer inside the server, so it only runs when someone calls the app. This changes the timing, not the correctness.

The problem

A customer orders a custom T-shirt. I promise a grace period of 10 minutes. During that time the customer can cancel. After that, the printer gets an email and the customer cannot cancel any more.

Two things can happen at almost the same moment. The customer presses cancel, and my poller wants to send. Exactly one of them must win. A cancelled order must never reach the printer, because an email cannot be recalled.

Design decisions

Claim first, then send

The poller claims an order by changing its status from PENDING to SENT before it sends the email. If the customer cancels first, the claim finds nothing. If the claim wins, the cancel is rejected with a 409.

Here, SENT means the order has been claimed for dispatch, not that the email has been delivered. If the server crashes after the claim, the printer may get no email. I set printerNotifiedAt only after Resend accepts it. A stuck-order query finds SENT orders without that timestamp, and a resend route lets me recover them.Sending the email before claiming the order could allow it to be sent even if the cancellation wins the database race.

A guarded transition

Every status change goes through one function. It checks that the transition is allowed for the actor. Then it writes with the old status in the where clause:

const { count } = await prisma.order.updateMany({
  where: { id, status: order.status },
  data: { status: to },
});
if (count === 0) throw new ConflictException('Order changed, please retry');
Enter fullscreen mode Exit fullscreen mode

If someone changed the row between my read and my write, count is 0 and the caller gets a 409. This is the sixth time I used this read-then-write pattern across my projects.

A Postgres poller, not a queue

The hosted version has no Redis, so there is no BullMQ. The order and its deadline are written in one transaction. A timer loop claims due orders with one statement:

UPDATE "Order" SET status = 'SENT' -- plus updatedAt
WHERE id IN (
  SELECT id FROM "Order"
  WHERE status = 'PENDING' AND "sendAt" <= (now() AT TIME ZONE 'UTC')
  ORDER BY "sendAt" LIMIT 10
  FOR UPDATE SKIP LOCKED
)
RETURNING id
Enter fullscreen mode Exit fullscreen mode

There is no enqueue gap, because the deadline is data. The costs are a polling delay and retry logic that I write myself. Today there is no automatic retry. The safety net is the stuck-order query and the resend route.

The server owns sendAt. A client-supplied value could skip the grace period, so the request schema rejects unknown keys.

A row lock on the design

An order points at a design. If the design changes after the order exists, the printer prints something the customer never ordered, and nobody sees an error. Creating an order, updating a design and deleting a design all take SELECT ... FOR UPDATE on the design row first. A design counts as locked when it has any order that is not cancelled.

Tests, and a test that proved nothing

I wrote integration tests against a real Postgres database. Then I broke the code on purpose, one line at a time, and checked that a test turned red.

I removed What went red
the status in the updateMany guard the cancel-versus-poller test, and all 30 printer/admin confirm iterations
status = 'PENDING' in the claim a cancelled order was emailed, and two pollers sent two emails
SKIP LOCKED the locked-row test (the pollers waited)
FOR UPDATE in order creation only the deterministic lock test
FOR UPDATE in design update only the deterministic lock test (65 passed, 1 failed)
FOR UPDATE in design removal the remove-versus-create test: a foreign key error instead of a 409

The lock rows are the lesson. First I tested the lock with 30 free-running iterations of update versus create. They all passed with the lock removed. The broken interleaving ends in the same final database state as the harmless one, so no assertion on the final state can tell them apart. Only a test that pauses order creation after it takes the lock, and then checks whether the update can finish, sees the bug.

A test also found a timezone bug. I ran the integration session in America/Los_Angeles. Comparing sendAt with a bare now() failed because sendAt is a timestamp without time zone. The comparison now uses now() AT TIME ZONE 'UTC', assuming sendAt is stored as a UTC timestamp. This fixes the timezone interpretation, but not clock skew between the application and database; I discuss that remaining limitation below.

Prisma Postgres and Compute

Prisma ORM 8 (I write Prisma 8 for short) is a release candidate and Composer is in Early Access. Compute has been generally available since August 2026. Everything in this section was true on my machine in October 2026. Versions: Prisma CLI 8.0.0-rc.27, @prisma/orm-postgres 8.0.0-rc.20, Composer 0.34.0.

Why Prisma ORM 8 is in this project

The Console told me that my repository used prisma ^7.7.0, that deploys run on Prisma 8, and that I should upgrade first. So I installed Prisma 8 next to Prisma 7, inferred a contract from my database, and planned a baseline migration. The plan had 15 operations from an empty database: four enums, four tables, the (status, sendAt) index and four foreign keys.

Later, Martin from Prisma told me that a Prisma 7 app runs fine on Compute and that Prisma 8 is not required. I did not test a Prisma 7 only deploy myself, so I cannot confirm it, and I reported the Console banner to Prisma.

I kept Prisma 8 for one job: the deploy applies my baseline migration to the new database. My queries still run on Prisma 7 with the pg adapter. The docs also describe a plainer option, rawPostgres(), which hands your own client a db.url. With it, you migrate the database yourself. I did not use it.

I kept the queries on Prisma 7 for another reason. The release status page lists what Prisma 8 does not have yet, for example transaction isolation levels, atomic increment, most nested writes and P2002-style error codes. My locks use raw SQL and a guarded updateMany, and I did not try them on Prisma 8.

Two more facts for Prisma 7 users. First, npx prisma now runs the Prisma 8 CLI, which has different data commands (contract, db and migration). Pin prisma to ^7 and use npx prisma@7, or install an alias package like I did. Second, Prisma 8 is a release candidate, and the release status page expects general availability in October 2026.

Getting a NestJS 11 app onto Compute

Compute runs a single bundled file. My notes:

  • The deploy tool refused Node 22.23.4 and asked for Node 24.11.1 or newer. The Compute docs say Node 22.18 or newer on the 22 line is enough, so the docs and the tool disagree.
  • bcrypt is a native addon. Bundled into one file, it crashed at startup with __dirname is not defined. I switched to bcryptjs. Login and the tests still passed.
  • prisma dev is not supported on Windows yet, so I tested the bundle with plain node.
  • Composer needs type: module. I put the Composer files in a deploy folder with its own package.json, so nest build stays CommonJS. A small launcher reads the database URL that the platform provides and then starts the compiled app.
  • The Console's setup pull request failed at npm install with ERESOLVE. I could reproduce a conflict on my machine by adding alchemy next to Jest 30 (a Babel peer dependency clash). I could not see the Console's log, so I do not know if it is the same cause.

The first deploy created ten resources, including the database and the migration step, and returned a public URL. The docs say Compute runs deployed apps on Bun, and I built my bundle for Node. The app worked when I tried it. The poller is switched off on that deployment, so orders stay PENDING there.

The surprise: scale to zero versus my poller

My poller is a timer inside the server. I deployed a small test app with a counter that ticks every 5 seconds and shows its history. Then I left it alone overnight.

  • The startedAt value was the same after 15 hours, so the app was never restarted.
  • Only 2 ticks were recorded. The first gap was 54,940 seconds.
  • New ticks appeared at the moments I opened the page. Then they stopped again.

The Compute docs describe this: idle instances sleep with a memory snapshot and resume in milliseconds. A timer inside the process only runs when someone calls the app.

What still holds: one database claim wins, and a cancelled order cannot be claimed for dispatch through this flow. What breaks is the timing. The email goes out after the next request, not at minute 10. Exactly-once email delivery is not guaranteed; a crash during recovery can result in a duplicate.

Open question: where should the poller run?

Compute has no built-in cron scheduler. A Prisma engineer told me that the recommended patterns are options 2 and 3 below, and that keep-awake is meant for short, bounded work. The Compute limitations page and the Composer cron building block describe both. I have not chosen between them yet, and I would like to discuss it. These are the options I found:

  1. Keep the instance awake. Prisma says keep-awake is meant for short, bounded work, not for an always-on loop. The docs add that it does not guarantee continuity through restarts or deployments. I have not measured what a permanently awake instance costs.
  2. An external trigger (recommended by Prisma). A scheduler outside Compute calls a protected route. The route wakes the app, runs the same claim logic and returns. My tests show that extra triggers are safe: two pollers claim an order once. The timing precision depends on the scheduler, and the request must start responding within 60 seconds.
  3. The Composer cron building block (recommended by Prisma). Its scheduler is a service that never scales to zero, so it costs one warm instance. Composer is in Early Access, and I did not find an example of mounting the cron runner in a NestJS app.

Questions that are still open:

  • Does the always-warm cron scheduler count against the Free plan, and how much memory does it use?
  • Has anyone mounted the cron runner inside a NestJS app?

If you solved this, I would like to hear how.

Raw output

These are trimmed outputs from my own runs.

The deploy:

t-shirt-backend-test
├─ database   postgres-database db_...
└─ app        compute-service cps_...
Stack deployed (10/10)
Deployed t-shirt-backend-test to production in 3m 1s.
Enter fullscreen mode Exit fullscreen mode

The bundle with the native bcrypt addon, at startup:

ReferenceError: __dirname is not defined in ES module scope
    at ... require_node_gyp_build()(path.resolve(__dirname))
Enter fullscreen mode Exit fullscreen mode

The test app's ticks, first call (15 hours after the deploy):

{"startedAt":"2026-10-25T16:36:23.113Z","now":"2026-10-26T07:59:53.581Z","count":2,
 "gaps":[{"from":"2026-10-25T16:42:26.838Z","to":"2026-10-26T07:58:06.462Z","seconds":54940}]}
Enter fullscreen mode Exit fullscreen mode

The second call, about 10 minutes later (the first gap is shortened):

{"startedAt":"2026-10-25T16:36:23.113Z","now":"2026-10-26T08:10:05.860Z","count":4,
 "gaps":[{"seconds":54940},{"from":"2026-10-26T07:58:06.462Z","to":"2026-10-26T07:59:53.591Z","seconds":107}]}
Enter fullscreen mode Exit fullscreen mode

Two failures from the mutation runs:

status guard removed, confirm race:   Expected length: 1   Received length: 2
design lock removed, lock-held test:  Expected: "rejected"  Received: "fulfilled"
Enter fullscreen mode Exit fullscreen mode

What I did not verify

  • I did not run the poller on Compute under load, and I did not measure how late the emails are.
  • I have not fixed clock skew between the app server and the database. sendAt is written from the app clock and compared with the database clock, so skew changes the grace period silently.
  • There is no automatic retry for failed emails.
  • I did not port locks and transactions to Prisma 8. They stay on Prisma 7.

Takeaways

  1. Let the database decide the winner. Do not let a timer or a clock decide it.
  2. Break your code on purpose. A test that also passes without the guard proves nothing.
  3. A free-running race loop can pass for the wrong reason. Force the interleaving.
  4. Scale to zero changes what a timer inside your server means. Check it before you design around it.

I'm Mhamd, a backend engineer working with NestJS and TypeScript. Portfolio: mhamd021.github.io · GitHub: Mhamd021

Top comments (0)