Optimistic Concurrency Control
See original GitHub issueProblem statement
The current API doesn’t allow to implement application-level Optimistic Concurrency Control, which is a pattern commonly implemented by applications with high concurrency needs, to avoid creating a bottleneck on the database, while obtaining certain guarantees about the integrity of the modified data.
Use cases
Imagine data that is bound to a given user profile, which only the said user can modify, or their admin. There are few chances that the same user would try to update the same data at the same time, which makes the use of pessimistic locking or long-running transaction solutions suboptimal. Implementing OCC, in this case, will be likely more performant.
Context about Optimistic Concurrency Control
OCC assumes that multiple transactions can frequently complete without interfering with each other, hence proposes to not block concurrent transactions and require the application to rollback in case of a detected conflict:
This SO answer gives a great summary
Optimistic Concurrency Control implies reading a record, taking note of a version number (other methods to do this involve dates, timestamps or checksums/hashes), and checking that the version hasn’t changed before the record gets written back. When writing the record back, one filters the update on the version to make sure it’s atomic. (i.e. hasn’t been updated between when checking the version and writing the record to the disk) and update the version in one hit.
If the record is dirty (i.e. has a different version to the user’s) the transaction should abort and require the process to be restarted.
Pessimistic Concurrency Control
PCC is assuming that data will often be accessed by multiple processes at the same time and proposes to lock data to sequence how these processes access it.
In practice, database vendors like Postgres or MySQL implement a concept of table- or row-level locking, giving control over what can be read and written (depending on the transaction’s isolation level):
SELECT ... FOR SHARE
which leaves the data available to read, but not to update outside the transaction.SELECT ... FOR UPDATE
which prevents the selected data from being read or updated outside of the transaction.- the
NOWAIT
variant to fail if a row is locked - the
SKIP LOCKED
variant to skip locked rows - …
This approach comes with risk of deadlocks, which can block the application, so should be considered carefully.
Scope
While we should consider an API design allowing us to evolve towards supporting and controlling both policies, the first iteration should focus on supporting Optimistic Concurrency Control.
Pessimistic Concurrency Control requires a deeper analysis of combinations of transaction isolation levels along with the locking strategy for each record, which makes it significantly more complex.
Solution ideas
The API should offer a way to specify, on update
and in $transaction
calls, whether to check on a certain field used to implement Optimistic Concurrency Control (version, timestamp…).
Proposal 1
A single attribute defining the locking policy, so that people can implement Optimistic Concurrency Control:
lockingPolicy: {
optimistic: 'fieldName'
}
This design would possibly allow defining a pessimistic policy in the future:
lockingPolicy: {
pessimistic: 'ROW' // or 'TABLE', depends on database vendor
}
Applied example
Imagine users detaining a number of points in a competition. And that points can be updated based on how each user behaves in the application.
model User {
team Team
numPoints Int
updatedAt DateTime @updatedAt
}
The exchange of points needs to be done in a consistent fashion across users to ensure a fair game.
Pseudo code:
// Optimistic
const scored = 2
prisma.$transaction([
prisma.user.update({
data: {
numPoints: {
decrement: scored
}
},
where: {
id: 'loser-user-id'
},
lockingPolicy: {
optimistic: 'updatedAt'
}
),
prisma.user.update({
data: {
numPoints: {
increment: scored
}
},
where: {
id: 'winner-user-id'
},
lockingPolicy: {
optimistic: 'updatedAt'
}
)
])
// Pessimistic (used to proof the API design, not meant for implementation)
const scored = 2
prisma.$transaction([
prisma.user.update({
data: {
numPoints: {
decrement: scored
}
},
where: {
id: 'loser-user-id'
},
lockingPolicy: {
pessimistic: 'ROW'
}
),
prisma.user.update({
data: {
numPoints: {
increment: scored
}
},
where: {
id: 'winner-user-id'
},
pessimisticLock: {
on: 'ROW'
}
)
], {
isolation: 'REPEATABLE READ'
})
Proposition 2
// Optimistic
const scored = 2
const user = prisma.user.findOne({ where: { id: 'loser-user-id' } })
const controlPoints = user.points
try {
// Throws if `numPoints` has changed between the fetching and the update
prisma.user.update({
data: {
numPoints: {
decrement: scored
}
},
where: {
id: 'loser-user-id'
},
if: {
numPoints: controlPoints
}
)
} catch (e) {
// Consider redoing, or error out.
}
Issue Analytics
- State:
- Created 4 years ago
- Reactions:50
- Comments:16 (5 by maintainers)
Anybody working on this?
My current approach is to use
prisma.$executeRaw