prisma: interactiveTransactions: 2 concurrent writes to the same row will cause it to hang until expiring

Bug description

I’m trying to use interactive transactions to build a bank transfer. I’d like to try to achieve the following:

  • Alice and Bob have $100
  • Concurrently, Alice sends Bob $100 twice
    • One of those requests goes through
    • The other one is rejected saying there’s not enough money
  • Alice has $0, Bob has $100

How to reproduce

Reproduction Repo: https://github.com/matthewmueller/interactive-transactions

Given the following Prisma Schema

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["interactiveTransactions"]
}

model Account {
  id      Int    @id @default(autoincrement())
  email   String @unique
  balance Int
}

And the following script:

import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()

async function unseed() {
  await prisma.account.deleteMany({
    where: {
      OR: [{ email: "alice@prisma.io" }, { email: "bob@prisma.io" }],
    },
  })
}

async function seed() {
  await prisma.account.create({
    data: {
      email: "alice@prisma.io",
      balance: 100,
    },
  })
  await prisma.account.create({
    data: {
      email: "bob@prisma.io",
      balance: 100,
    },
  })
}

async function transfer(nth: number, from: string, to: string, amount: number) {
  return await prisma.$transaction(
    async (prisma) => {
      console.time("send " + nth)
      const sender = await prisma.account.update({
        data: {
          balance: {
            decrement: amount,
          },
        },
        where: {
          email: from,
        },
      })
      console.timeEnd("send " + nth)
      console.time("throw " + nth)
      if (sender.balance < 0) {
        throw new Error(`${from} doesn't have enough to send ${amount}`)
      }
      console.timeEnd("throw " + nth)
      console.time("recieve " + nth)
      const recipient = prisma.account.update({
        data: {
          balance: {
            increment: amount,
          },
        },
        where: {
          email: to,
        },
      })
      console.timeEnd("recieve " + nth)
      return recipient
    },
    {
      timeout: 20000,
    }
  )
}

async function main() {
  await prisma.$connect()
  await unseed()
  await seed()
  console.time("transfer")
  await Promise.all([
    transfer(1, "alice@prisma.io", "bob@prisma.io", 100),
    transfer(2, "alice@prisma.io", "bob@prisma.io", 100),
  ])
  console.timeEnd("transfer")
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect())

If you setup a database and then run ts-node index.ts, you’ll get:

send 1: 7.005ms
throw 1: 0.005ms
recieve 1: 0.279ms
send 2: 20.005s
throw 2: 0.367ms
recieve 2: 0.644ms
PrismaClientKnownRequestError3 [PrismaClientKnownRequestError]: 
Invalid `prisma.account.deleteMany()` invocation in
/Users/m/dev/src/github.com/prisma/interactive-transactions/index.ts:5:58

  2 const prisma = new PrismaClient()
  3 
  4 async function unseed() {
→ 5   await prisma.account.deleteMany(
  Transaction API error: Transaction already closed: Transaction is no longer valid. Last state: 'Expired'.
    at RequestHandler.request (/Users/m/dev/src/github.com/prisma/interactive-transactions/node_modules/@prisma/client/runtime/index.js:36361:15)
    at processTicksAndRejections (internal/process/task_queues.js:93:5)
    at PrismaClient._transactionWithCallback (/Users/m/dev/src/github.com/prisma/interactive-transactions/node_modules/@prisma/client/runtime/index.js:36932:18) {
  code: 'P2028',
  clientVersion: '2.30.0-dev.8',
  meta: {
    error: "Transaction already closed: Transaction is no longer valid. Last state: 'Expired'."
  }
}

Two notes:

  • The 2nd send takes the full 20s
  • An FYI that the stack trace is off. It was pointing to that line even when it was commented out.

I think this could be a bug because if I change the code to transfer serially, it works as expected:

async function main() {
  await prisma.$connect()
  await unseed()
  await seed()
  await transfer("alice@prisma.io", "bob@prisma.io", 100)
  await transfer("alice@prisma.io", "bob@prisma.io", 100) // Error: alice@prisma.io doesn't have enough to send 100
}

Expected behavior

I’d expect this to work as expected, you should be able to initialize multiple transactions concurrently and let the database sort it out.

Prisma information

Environment & setup

  • OS: OSX
  • Database: Postgres
  • Node.js version: v14.16.0

Prisma Version

Environment variables loaded from .env
prisma                : 2.30.0-dev.8
@prisma/client        : 2.30.0-dev.8
Current platform      : darwin
Query Engine (Binary) : query-engine 71d96e8bbd21982078694f00add0f51da2056a8b (at node_modules/@prisma/engines/query-engine-darwin)
Migration Engine      : migration-engine-cli 71d96e8bbd21982078694f00add0f51da2056a8b (at node_modules/@prisma/engines/migration-engine-darwin)
Introspection Engine  : introspection-core 71d96e8bbd21982078694f00add0f51da2056a8b (at node_modules/@prisma/engines/introspection-engine-darwin)
Format Binary         : prisma-fmt 71d96e8bbd21982078694f00add0f51da2056a8b (at node_modules/@prisma/engines/prisma-fmt-darwin)
Default Engines Hash  : 71d96e8bbd21982078694f00add0f51da2056a8b
Studio                : 0.419.0
Preview Features      : interactiveTransactions

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 7
  • Comments: 16 (7 by maintainers)

Commits related to this issue

Most upvoted comments

@matthewmueller Any chance it will be fixed in v3.5.0? For now because of that, the interactive transactions are unusable in any http/graphql scenario when the requests can come at the same time 😕

Unfortunately, I’ve updated to 3.6.0 in prod and am seeing the same failure case. Concurrent transactions stall, consume 100% of CPU, and eventually time-out.

Hey @millsp, is this still slated for milestone 3.1.0? Actively blocking me at the moment.

I just tested it with Postgres via pg. First I used a single connection:

import { Client } from "pg"
import dotenv from "dotenv"
dotenv.config()

const client = new Client({
  connectionString: process.env.DATABASE_URL,
})

async function unseed() {
  await client.query(`delete from "Account" where email in($1, $2)`, [
    "alice@prisma.io",
    "bob@prisma.io",
  ])
}

async function seed() {
  await client.query(`insert into "Account" (email, balance) values ($1, $2)`, [
    "alice@prisma.io",
    100,
  ])
  await client.query(`insert into "Account" (email, balance) values ($1, $2)`, [
    "bob@prisma.io",
    100,
  ])
}

async function transfer(nth: number, from: string, to: string, amount: number) {
  await client.query("begin")
  console.time("send " + nth)
  const sender = await client.query(
    `update "Account" set balance = balance - $1 where email = $2 returning *`,
    [amount, from]
  )
  console.timeEnd("send " + nth)
  console.time("throw " + nth)
  if (sender.rows[0].balance < 0) {
    await client.query("rollback")
    console.timeEnd("throw " + nth)
    return
  }
  console.timeEnd("throw " + nth)
  console.time("recieve " + nth)
  const recipient = await client.query(
    `update "Account" set balance = balance + $1 where email = $2 returning *`,
    [amount, to]
  )
  console.timeEnd("recieve " + nth)
  await client.query("commit")
  return recipient.rows[0]
}

async function main() {
  await client.connect()
  await unseed()
  await seed()
  console.time("transfer")
  await Promise.all([
    transfer(1, "alice@prisma.io", "bob@prisma.io", 100),
    transfer(2, "alice@prisma.io", "bob@prisma.io", 100),
  ])
  console.timeEnd("transfer")
}

main()
  .catch(console.error)
  .finally(() => client.end())

Running ts-node postgres.ts,

send 1: 1.634ms
throw 1: 0.009ms
send 2: 2.25ms
recieve 1: 0.763ms
throw 2: 0.552ms
transfer: 4.121ms

The result:

id email balance
43 alice@prisma.io 100
44 bob@prisma.io 100

This sort of makes sense because with 1 connection, 2 concurrent requests, it’s not clear in what order the queries happen.

Next I setup the pool and used a separate connection per transfer.

import { Pool, PoolClient } from "pg"
import dotenv from "dotenv"
dotenv.config()

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
})

async function unseed(client: PoolClient) {
  await client.query(`delete from "Account" where email in($1, $2)`, [
    "alice@prisma.io",
    "bob@prisma.io",
  ])
}

async function seed(client: PoolClient) {
  await client.query(`insert into "Account" (email, balance) values ($1, $2)`, [
    "alice@prisma.io",
    100,
  ])
  await client.query(`insert into "Account" (email, balance) values ($1, $2)`, [
    "bob@prisma.io",
    100,
  ])
}

async function transfer(
  pool: Pool,
  nth: number,
  from: string,
  to: string,
  amount: number
) {
  const client = await pool.connect()
  await client.query("begin")
  console.time("send " + nth)
  const sender = await client.query(
    `update "Account" set balance = balance - $1 where email = $2 returning *`,
    [amount, from]
  )
  console.timeEnd("send " + nth)
  console.time("throw " + nth)
  if (sender.rows[0].balance < 0) {
    await client.query("rollback")
    client.release()
    console.timeEnd("throw " + nth)
    return
  }
  console.timeEnd("throw " + nth)
  console.time("recieve " + nth)
  const recipient = await client.query(
    `update "Account" set balance = balance + $1 where email = $2 returning *`,
    [amount, to]
  )
  console.timeEnd("recieve " + nth)
  await client.query("commit")
  client.release()
  return recipient.rows[0]
}

async function main() {
  const client = await pool.connect()
  await unseed(client)
  await seed(client)
  client.release()
  console.time("transfer")
  await Promise.all([
    transfer(pool, 1, "alice@prisma.io", "bob@prisma.io", 100),
    transfer(pool, 2, "alice@prisma.io", "bob@prisma.io", 100),
  ])
  console.timeEnd("transfer")
}

main()
  .catch(console.error)
  .finally(() => pool.end())

This works as expected:

id email balance
45 alice@prisma.io 0
46 bob@prisma.io 200

From this, it seems like Prisma should use a separate connection per transaction. I’m still not sure why it hangs since even on the same connection, pg still processes the queries.

I did experience this too. What I also found is that when using the interactiveTransactions preview feature, regular transaction will also suffer from the same probleme, i.e a transaction using an array of Prisma Promise. When disabling the feature, regular transaction work as expected.

I have seen the same thing in my application. I created a “Poor Man’s” SEQUENCE for MySQL, and it uses the transaction array listed in s-montigny-desautels comment above. The application is running in AWS with a Health Check that hits it about every 10 seconds. I put many changes in, including upgrading all the packages, and Prisma to 3.7. Suddenly my application was crashing randomly (varying from <30 minutes to 4+ hours), and forcing an AWS restart. This was too difficult to narrow down for debugging purposes, as when the CPU went to 100%, it was only 1 - 2 minutes before AWS restarted the application, and it wasn’t happening locally.

I was at a complete loss to determine what was going on, due to the number of changes, but it finally occurred to me it was the Prisma change, as two applications were crashing, neither of which were doing anything at the time, except the Health Checks. After investigating the Health Check, I realized it was using a prisma.$transaction([]), and had a lead, since I had also turned on the previewFeatures = [“interactiveTransactions”], and even though it was not being used anywhere, it was a likely cause.

I commented out the interactiveTransaction in the schema.prisma, and redeployed. The attached graph shows the Random crashes before the ~ 9:30am deployment, and none since.

I could provide the code that caused it, but I don’t think it is needed, as it is pretty obvious that the Health Check Transaction was somehow getting called again, before completing the previous call. The only reason this would likely happen, is that the prisma.$transaction wasn’t finishing and/or cleaning up anything it was doing before returning from the Promise, so the next call to it is not starting with a “clean slate”.

NOTE: This is some how tied to the previewFeature interactiveTransactions.

CPU_Utilization_Problem

Update:

After looking at the graphs highly expanded (image attached), another thought occurred to me. These Memory Usages were from 2 separate Tasks running in separate Containers. I am now thinking that whatever Prisma is using to create (identify) the Transaction is stored in the Database (possibly a timestamp ?), because the Database requests are coming from 2 different systems at the same time, and both systems CPU usage went to 100%, and then both got restarted. I am guessing that the 2nd transaction in is somehow corrupting the first one because it ends up with the same identifier and then whatever they use to check completion goes into an infinite loop, causing both to go to 100% CPU utilization.

CPU_Utilization_Expanded

I think I might have a repro - which differs from the OP since that issue has been fixed. Instead, I just performed a load test and triggering many transactions at once. That sometimes works and other times it leads to the screenshot below. binary engine seems more susceptible to this, even though it happens on library too (without proof of where is stalls on that one though). Screenshot from 2022-01-11 20-07-35

@janpio I’ll see if I can get some time from my client to set up a reproduction repo.

We’re working on it! I agree it’s really bad right now. I can’t promise 3.5.0, but it’s WIP.

Sorry, I had a dyslectic moment with the Prisma Version numbers in the first Graph above. I have corrected it now…

I think I might have a repro - which differs from the OP since that issue has been fixed. Instead, I just performed a load test and triggering many transactions at once. That sometimes works and other times it leads to the screenshot below. binary engine seems more susceptible to this, even though it happens on library too (without proof of where is stalls on that one though).

I did experience this too. What I also found is that when using the interactiveTransactions preview feature, regular transaction will also suffer from the same probleme, i.e a transaction using an array of Prisma Promise. When disabling the feature, regular transaction work as expected.