prisma: `updateMany()` causes lost-updates
Bug description
updateMany() returns incorrect updated counts when multiple processes or asynchronous functions run simultaneously.
The SQL log of updateMany() shows it emits SELECT and then UPDATE. Since prisma uses the default isolation level of DBMS, those queries are NON-REPEATEBLE READ in many DBMS and can cause lost-updates.
I believe SELECT ... FOR UPDATE or single query UPDATE .... WHERE ... is required.
How to reproduce
bookSeat.js
const {PrismaClient} = require('@prisma/client')
const client = new PrismaClient({
// log: ["query"]
})
async function bookSeat(userId) {
const movieName = 'Hidden Figures'
// Find the first available seat
// availableSeat.version might be 0
const availableSeat = await client.seat.findFirst({
where: {
// Movie: {
// name: movieName,
// },
claimedBy: null,
},
orderBy: [{id: "asc"}]
})
if (!availableSeat) {
throw new Error(`Oh no! ${movieName} is all booked.`)
}
// Only mark the seat as claimed if the availableSeat.version
// matches the version we're updating. Additionally, increment the
// version when we perform this update so all other clients trying
// to book this same seat will have an outdated version.
const seats = await client.seat.updateMany({
data: {
claimedBy: userId,
version: {
increment: 1,
},
},
where: {
id: availableSeat.id,
version: availableSeat.version, // This version field is the key; only claim seat if in-memory version matches database version, indicating that the field has not been updated
},
})
if (seats.count === 0) {
throw new Error(`That seat is already booked! Please try again.`)
}
return seats.count
}
async function demonstrateLostUpdate() {
if (process.argv[2] === "createData") {
await client.seat.deleteMany()
for (let i = 0; i < 1000; i++) {
await client.seat.create({
data: {
id: i,
version: 0,
movieId: 1,
claimedBy: null,
}
})
}
process.exit()
}
const userId = process.argv[2]
let updatedCount = 0
for (let i = 0; i < 1000; i++) {
try {
updatedCount += await bookSeat(userId)
} catch {
// ignore lock failure
}
}
// Detect lost-updates
const actualCount = await client.seat.count({
where: {
claimedBy: userId
},
})
console.log({
userId,
updatedCountByUpdateMany: updatedCount,
actualUpdatedCount: actualCount
})
process.exit()
}
demonstrateLostUpdate()
With the above script, run
$ node bookSeat.js createData
$ node bookSeat.js Sorcha & node bookSeat.js Ellen
The schema and logic in the script are taken from the optimistic concurrency control example in the doc: https://www.prisma.io/docs/guides/performance-and-optimization/prisma-client-transactions-guide#optimistic-concurrency-control
Outputs:
{
userId: 'Sorcha',
updatedCountByUpdateMany: 968,
actualUpdatedCount: 461
}
{
userId: 'Ellen',
updatedCountByUpdateMany: 974,
actualUpdatedCount: 539
}
Expected behavior
updatedCountByUpdateMany should be equal to actualUpdatedCount in the outputs.
Prisma information
schema.prisma:
// 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"
}
model Seat {
id Int @id @default(autoincrement())
userId Int?
// claimedBy User? @relation(fields: [userId], references: [id])
claimedBy String?
movieId Int
// movie Movie @relation(fields: [movieId], references: [id])
version Int
}
SQL query Logs of repro:
prisma:query SELECT “public”.“Seat”.“id”, “public”.“Seat”.“userId”, “public”.“Seat”.“claimedBy”, “public”.“Seat”.“movieId”, “public”.“Seat”.“version” FROM “public”.“Seat” WHERE “public”.“Seat”.“claimedBy” IS NULL ORDER BY “public”.“Seat”.“id” ASC LIMIT $1 OFFSET $2 prisma:query BEGIN prisma:query SELECT “public”.“Seat”.“id” FROM “public”.“Seat” WHERE (“public”.“Seat”.“id” = $1 AND “public”.“Seat”.“version” = $2) prisma:query UPDATE “public”.“Seat” SET “claimedBy” = $1, “version” = (“version” + $2) WHERE “public”.“Seat”.“id” IN ($3) prisma:query COMMIT
Environment & setup
- OS: Mac OS
- Database: PostgreSQL
- Node.js version: v14.17.0
Prisma Version
Environment variables loaded from .env
prisma : 2.28.0
@prisma/client : 2.28.0
Current platform : darwin
Query Engine : query-engine 89facabd0366f63911d089156a7a70125bfbcd27 (at node_modules/prisma/node_modules/@prisma/engines/query-engine-darwin)
Migration Engine : migration-engine-cli 89facabd0366f63911d089156a7a70125bfbcd27 (at node_modules/prisma/node_modules/@prisma/engines/migration-engine-darwin)
Introspection Engine : introspection-core 89facabd0366f63911d089156a7a70125bfbcd27 (at node_modules/prisma/node_modules/@prisma/engines/introspection-engine-darwin)
Format Binary : prisma-fmt 89facabd0366f63911d089156a7a70125bfbcd27 (at node_modules/prisma/node_modules/@prisma/engines/prisma-fmt-darwin)
Default Engines Hash : 89facabd0366f63911d089156a7a70125bfbcd27
Studio : 0.417.0
About this issue
- Original URL
- State: closed
- Created 3 years ago
- Reactions: 37
- Comments: 20 (7 by maintainers)
Commits related to this issue
- test(client): regression test for OCC #8612 (#15147) Co-authored-by: Jan Piotrowski <piotrowski+github@gmail.com> — committed to prisma/prisma by millsp 2 years ago
Do we really need a repro to see why executing these two SQL statements is prone to a race condition?
You need to fix this by including all where clauses in the UPDATE statement:
updateMany() is currently not suitable for optimistic concurrency control, but that is what the docs claim.
This is fixed and will be in the next Prisma release 🎉
We do not have a confirmed reproduction yet. If you can help us get to that, please post it below. Thanks.
Any update on fixing this @matthewmueller?
I just tested it out and there is indeed a race condition here due to
updateManydoing a select before update. In the example below, you’ll see “BOOKED” twice across 3 concurrent requests, where it should only appear once.As @barryhagan , the problem is that updateMany performs 2 queries:
Even if it’s inside a transaction, there can still be a race condition because by default selects don’t wait for each other, even inside a transaction. This post goes into further details: https://www.2ndquadrant.com/en/blog/postgresql-anti-patterns-read-modify-write-cycles/
We need to fix this. In the meantime, I’d suggest implementing one of the solutions inside this blog post using
$queryRaw.Sorry for the misguided information and inconvenience. I’ll be temporarily adjusting the guide to make note of this issue.
schema.prisma
index.ts
Logs
@renecouto thanks for the interest in working on this but the Prisma team will be working on this quite soon.
OCC is not only broken in updateMany/deleteMany but also update/delete in unique key situation.
It seams update/delete are like updateMany/deleteMany to select for primary key first then update/delete by only the primary key.
Setting the transaction isolation level is now supported in Prisma 4.2.0. While it’s not an actual fix to this problem, I was able to get this script to pass using serializable transaction isolation levels in a PostgreSQL database.
This issue still needs to be fixed, but wanted to mention this for anyone who has been wanting a fix for this for awhile and uses a database that supports serializable transactions.
I’m still seeing this in prisma 4.5.0; it lists a bunch of primary keys in the query log whenever I do updateMany, even inside an interactive transaction. It’s resulting in the rows being overwritten multiple times (only the last one ‘wins’ and all of them think they succeeded and don’t cause the other transaction to roll back) if I fire off multiple of those transactions quickly in succession (by hitting my Next.js endpoint that does it with 10 simultaneous requests).
I think a workaround might be a unique field
rowUpdateIdusingcuid()(better for indexing? ) oruuid()besides the normal id of the row. You then can useupdatewithwhere: { rowUpdateId: myPreviousId }and update it via client generatedcuid()which should be collision resistant. Or would this still generate aSELECT id ...->UPDATE ... WHERE idinstead ofUPDATE ... WHERE rowUpdateId?The advantage is, it will also throw an error so it can be used with transactions to support rollback.
Disadvantages might be: increased storage needs. It might be more complex or confusing to use (especially for new developers). You need to use an additional index to improve the lookups.
I think the use of
cuid()is robust enough to not generate duplicates, even in serverless where a timestamp and internal counter could overlap. Because it also uses a client fingerprint (by pid and hostname) it would still be different in diffrent containers/cloud functions. Whereuuid()might have the chance of overlaps. Alsocuid()is optimized for binary searches and therefore better suited to use as an index.If I am missing something or this won’t work please let me know.
Is there any guidance on when this may be fixed?
In particular
updatewith unique compound key is more desired overupdateManydue to the limitation of nested writes on latter.In the meantime dropping to raw queries isn’t really a great option for us as we have a generic [DDD] Aggregate OCC versioning solution and this effectively means we can’t use the Prisma client anywhere (safely). We believe temporarily dropping into interactive transactions is the best bet for now but have not fully explored how locking might work here and the effect on performance (not a great concern for now).