Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:22:25 +08:00
commit c3294f28aa
60 changed files with 10297 additions and 0 deletions

View File

@@ -0,0 +1,347 @@
---
name: configuring-transaction-isolation
description: Configure transaction isolation levels to prevent race conditions and handle concurrent access. Use when dealing with concurrent updates, financial operations, inventory management, or when users mention race conditions, dirty reads, phantom reads, or concurrent modifications.
allowed-tools: Read, Write, Edit
version: 1.0.0
---
# Transaction Isolation Levels
This skill teaches how to configure transaction isolation levels in Prisma to prevent race conditions and handle concurrent database access correctly.
---
<role>
This skill teaches Claude how to configure and use transaction isolation levels in Prisma 6 to prevent concurrency issues like race conditions, dirty reads, phantom reads, and lost updates.
</role>
<when-to-activate>
This skill activates when:
- User mentions race conditions, concurrent updates, or dirty reads
- Working with financial transactions, inventory systems, or booking platforms
- Implementing operations that must maintain consistency under concurrent access
- User asks about Serializable, RepeatableRead, or ReadCommitted isolation
- Dealing with P2034 errors (transaction conflicts)
</when-to-activate>
<overview>
Transaction isolation levels control how database transactions interact with each other when running concurrently. Prisma supports setting isolation levels to prevent common concurrency issues.
**Key Isolation Levels:**
1. **Serializable** - Strictest isolation, prevents all anomalies
2. **RepeatableRead** - Prevents dirty and non-repeatable reads
3. **ReadCommitted** - Prevents dirty reads only (default for most databases)
4. **ReadUncommitted** - No isolation (not recommended)
**Common Concurrency Issues:**
- **Dirty Reads:** Reading uncommitted changes from other transactions
- **Non-Repeatable Reads:** Same query returns different results within transaction
- **Phantom Reads:** New rows appear in repeated queries
- **Lost Updates:** Concurrent updates overwrite each other
**When to Set Isolation:**
- Financial operations (payments, transfers, refunds)
- Inventory management (stock reservations, order fulfillment)
- Booking systems (seat reservations, room bookings)
- Any operation requiring strict consistency
</overview>
<workflow>
## Standard Workflow
**Phase 1: Identify Concurrency Risk**
1. Analyze operation for concurrent access patterns
2. Determine what consistency guarantees are needed
3. Choose appropriate isolation level based on requirements
**Phase 2: Configure Isolation Level**
1. Set isolation level in transaction options
2. Implement proper error handling for conflicts
3. Add retry logic if appropriate
**Phase 3: Handle Isolation Conflicts**
1. Catch P2034 errors (transaction conflicts)
2. Retry with exponential backoff if appropriate
3. Return clear error messages to users
</workflow>
<isolation-level-guide>
## Isolation Level Quick Reference
| Level | Prevents | Use Cases | Trade-offs |
|-------|----------|-----------|------------|
| **Serializable** | All anomalies | Financial transactions, critical inventory | Highest consistency, lowest concurrency, more P2034 errors |
| **RepeatableRead** | Dirty reads, non-repeatable reads | Reports, multi-step reads | Good balance, still allows phantom reads |
| **ReadCommitted** | Dirty reads only | Standard operations, high-concurrency | Highest concurrency, allows non-repeatable/phantom reads |
| **ReadUncommitted** | Nothing | Not recommended | Almost never appropriate |
### Serializable Example
```typescript
await prisma.$transaction(
async (tx) => {
const account = await tx.account.findUnique({
where: { id: accountId }
});
if (account.balance < amount) {
throw new Error('Insufficient funds');
}
await tx.account.update({
where: { id: accountId },
data: { balance: { decrement: amount } }
});
await tx.transaction.create({
data: {
accountId,
amount: -amount,
type: 'WITHDRAWAL'
}
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable
}
);
```
### RepeatableRead Example
```typescript
await prisma.$transaction(
async (tx) => {
const user = await tx.user.findUnique({
where: { id: userId },
include: { orders: true }
});
const totalSpent = user.orders.reduce(
(sum, order) => sum + order.amount,
0
);
await tx.user.update({
where: { id: userId },
data: {
tierLevel: calculateTier(totalSpent),
lastCalculatedAt: new Date()
}
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead
}
);
```
### ReadCommitted Example
```typescript
await prisma.$transaction(
async (tx) => {
await tx.log.create({
data: {
level: 'INFO',
message: 'User logged in',
userId
}
});
await tx.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() }
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted
}
);
```
</isolation-level-guide>
<decision-tree>
## Choosing Isolation Level
Follow this decision tree:
**Is this a financial operation (money, payments, credits)?**
- YES → Use `Serializable`
- NO → Continue
**Does the operation read data multiple times and require it to stay constant?**
- YES → Use `RepeatableRead`
- NO → Continue
**Is this a high-concurrency operation where conflicts are expensive?**
- YES → Use `ReadCommitted` (or no explicit isolation)
- NO → Continue
**Does the operation modify data based on a read within the transaction?**
- YES → Use `RepeatableRead` minimum
- NO → Use `ReadCommitted` (or no explicit isolation)
**Still unsure?**
- Start with `RepeatableRead` for safety
- Monitor P2034 error rate
- Adjust based on actual concurrency patterns
</decision-tree>
<error-handling>
## Handling Isolation Conflicts
### P2034: Transaction Conflict
When using Serializable isolation, conflicts are common under concurrency:
```typescript
async function transferWithRetry(
fromId: string,
toId: string,
amount: number,
maxRetries = 3
) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await prisma.$transaction(
async (tx) => {
const fromAccount = await tx.account.findUnique({
where: { id: fromId }
});
if (fromAccount.balance < amount) {
throw new Error('Insufficient funds');
}
await tx.account.update({
where: { id: fromId },
data: { balance: { decrement: amount } }
});
await tx.account.update({
where: { id: toId },
data: { balance: { increment: amount } }
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 10000
}
);
return { success: true };
} catch (error) {
if (error.code === 'P2034' && attempt < maxRetries - 1) {
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 100)
);
continue;
}
throw error;
}
}
throw new Error('Transaction failed after max retries');
}
```
**Key Elements:**
- Retry loop with attempt counter
- Check for P2034 error code
- Exponential backoff between retries
- maxWait and timeout configuration
- Final error if all retries exhausted
### Timeout Configuration
```typescript
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 10000
}
```
- `maxWait`: Maximum time to wait for transaction to start (milliseconds)
- `timeout`: Maximum time for transaction to complete (milliseconds)
Higher isolation levels need higher timeouts to handle conflicts.
</error-handling>
<constraints>
## Constraints and Guidelines
**MUST:**
- Use Serializable for financial operations
- Handle P2034 errors explicitly
- Set appropriate maxWait and timeout values
- Validate data before starting transaction
- Use atomic operations (increment/decrement) when possible
**SHOULD:**
- Implement retry logic with exponential backoff for Serializable
- Keep transactions as short as possible
- Read all data needed before making decisions
- Log isolation conflicts for monitoring
- Consider RepeatableRead before defaulting to Serializable
**NEVER:**
- Use ReadUncommitted in production
- Ignore P2034 errors
- Retry indefinitely without limit
- Mix isolation levels in same operation
- Assume isolation level is higher than default without setting it
</constraints>
<validation>
## Validation
After implementing isolation levels:
1. **Concurrency Testing:**
- Simulate concurrent requests to same resource
- Verify no lost updates or race conditions occur
- Expected: Conflicts detected and handled gracefully
2. **Performance Monitoring:**
- Monitor P2034 error rate
- Track transaction retry attempts
- If P2034 > 5%: Consider lowering isolation level or optimizing transaction duration
3. **Error Handling:**
- Verify P2034 errors return user-friendly messages
- Check retry logic executes correctly
- Ensure transactions eventually succeed or fail definitively
</validation>
---
## References
For additional details and advanced scenarios, see:
- [Database-Specific Defaults](./references/database-defaults.md) - PostgreSQL, MySQL, SQLite, MongoDB isolation behaviors
- [Race Condition Patterns](./references/race-conditions.md) - Lost updates, double-booking, phantom reads
- [Complete Examples](./references/complete-examples.md) - Banking transfers, inventory reservations, seat bookings

View File

@@ -0,0 +1,222 @@
# Complete Examples
## Example 1: Banking Transfer
**Input:** Transfer money between accounts with strict consistency.
**Implementation:**
```typescript
import { Prisma } from '@prisma/client';
async function transferMoney(
fromAccountId: string,
toAccountId: string,
amount: number
) {
if (amount <= 0) {
throw new Error('Amount must be positive');
}
try {
const result = await prisma.$transaction(
async (tx) => {
const fromAccount = await tx.account.findUnique({
where: { id: fromAccountId }
});
if (!fromAccount) {
throw new Error('Source account not found');
}
if (fromAccount.balance < amount) {
throw new Error('Insufficient funds');
}
const toAccount = await tx.account.findUnique({
where: { id: toAccountId }
});
if (!toAccount) {
throw new Error('Destination account not found');
}
await tx.account.update({
where: { id: fromAccountId },
data: { balance: { decrement: amount } }
});
await tx.account.update({
where: { id: toAccountId },
data: { balance: { increment: amount } }
});
const transfer = await tx.transfer.create({
data: {
fromAccountId,
toAccountId,
amount,
status: 'COMPLETED',
completedAt: new Date()
}
});
return transfer;
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 10000
}
);
return { success: true, transfer: result };
} catch (error) {
if (error.code === 'P2034') {
throw new Error('Transaction conflict - please retry');
}
throw error;
}
}
```
## Example 2: Inventory Reservation
**Input:** Reserve inventory items for an order.
**Implementation:**
```typescript
async function reserveInventory(
orderId: string,
items: Array<{ productId: string; quantity: number }>
) {
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await prisma.$transaction(
async (tx) => {
for (const item of items) {
const product = await tx.product.findUnique({
where: { id: item.productId }
});
if (!product) {
throw new Error(`Product ${item.productId} not found`);
}
if (product.stock < item.quantity) {
throw new Error(
`Insufficient stock for ${product.name}`
);
}
await tx.product.update({
where: { id: item.productId },
data: { stock: { decrement: item.quantity } }
});
await tx.reservation.create({
data: {
orderId,
productId: item.productId,
quantity: item.quantity,
reservedAt: new Date()
}
});
}
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 3000,
timeout: 8000
}
);
return { success: true };
} catch (error) {
if (error.code === 'P2034' && attempt < maxRetries - 1) {
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 200)
);
continue;
}
throw error;
}
}
throw new Error('Reservation failed after retries');
}
```
## Example 3: Seat Booking with Status Check
**Input:** Book a seat with concurrent user protection.
**Implementation:**
```typescript
async function bookSeat(
userId: string,
eventId: string,
seatNumber: string
) {
try {
const booking = await prisma.$transaction(
async (tx) => {
const seat = await tx.seat.findFirst({
where: {
eventId,
seatNumber
}
});
if (!seat) {
throw new Error('Seat not found');
}
if (seat.status !== 'AVAILABLE') {
throw new Error('Seat is no longer available');
}
await tx.seat.update({
where: { id: seat.id },
data: {
status: 'BOOKED',
bookedAt: new Date()
}
});
const booking = await tx.booking.create({
data: {
userId,
seatId: seat.id,
eventId,
status: 'CONFIRMED',
bookedAt: new Date()
}
});
return booking;
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable
}
);
return { success: true, booking };
} catch (error) {
if (error.code === 'P2034') {
throw new Error(
'Seat was just booked by another user - please select another seat'
);
}
throw error;
}
}
```

View File

@@ -0,0 +1,63 @@
# Database-Specific Defaults
## PostgreSQL
Default: `ReadCommitted`
Supported levels:
- `Serializable` (strictest)
- `RepeatableRead`
- `ReadCommitted` (default)
Notes:
- PostgreSQL uses true Serializable isolation (not snapshot isolation)
- May throw serialization errors under high concurrency
- Excellent MVCC implementation reduces conflicts
## MySQL
Default: `RepeatableRead`
Supported levels:
- `Serializable`
- `RepeatableRead` (default)
- `ReadCommitted`
- `ReadUncommitted` (not recommended)
Notes:
- InnoDB engine required for transaction support
- Uses gap locking in RepeatableRead mode
- Serializable adds locking to SELECT statements
## SQLite
Default: `Serializable`
Supported levels:
- `Serializable` (only level - database-wide lock)
Notes:
- Only one writer at a time
- No true isolation level configuration
- Best for single-user or low-concurrency applications
## MongoDB
Default: `Snapshot` (similar to RepeatableRead)
Supported levels:
- `Snapshot` (equivalent to RepeatableRead)
- `Majority` read concern
Notes:
- Different isolation model than SQL databases
- Uses write-ahead log for consistency
- Replica set required for transactions

View File

@@ -0,0 +1,124 @@
# Preventing Race Conditions
## Lost Update Problem
**Scenario:** Two transactions read the same value, both update it, one overwrites the other.
**Without Isolation:**
```typescript
const product = await prisma.product.findUnique({
where: { id: productId }
});
await prisma.product.update({
where: { id: productId },
data: { stock: product.stock - quantity }
});
```
Transaction A reads stock: 10
Transaction B reads stock: 10
Transaction A writes stock: 5 (10 - 5)
Transaction B writes stock: 8 (10 - 2)
Result: Stock is 8, but should be 3
**With Serializable Isolation:**
```typescript
await prisma.$transaction(
async (tx) => {
const product = await tx.product.findUnique({
where: { id: productId }
});
if (product.stock < quantity) {
throw new Error('Insufficient stock');
}
await tx.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } }
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable
}
);
```
One transaction succeeds, the other gets P2034 and retries with fresh data.
## Double-Booking Problem
**Scenario:** Two users try to book the same resource simultaneously.
**Solution:**
```typescript
async function bookSeat(userId: string, seatId: string) {
try {
await prisma.$transaction(
async (tx) => {
const seat = await tx.seat.findUnique({
where: { id: seatId }
});
if (seat.status !== 'AVAILABLE') {
throw new Error('Seat no longer available');
}
await tx.seat.update({
where: { id: seatId },
data: {
status: 'BOOKED',
userId,
bookedAt: new Date()
}
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable
}
);
return { success: true };
} catch (error) {
if (error.code === 'P2034') {
throw new Error('Seat was just booked by another user');
}
throw error;
}
}
```
## Phantom Read Problem
**Scenario:** Query for rows matching a condition, insert happens, re-query shows different results.
**Example with RepeatableRead:**
```typescript
await prisma.$transaction(
async (tx) => {
const activeUsers = await tx.user.findMany({
where: { status: 'ACTIVE' }
});
const count = activeUsers.length;
await tx.report.create({
data: {
type: 'USER_COUNT',
value: count,
timestamp: new Date()
}
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead
}
);
```
RepeatableRead prevents other transactions from changing existing rows, but may still allow new inserts (phantom reads) depending on database implementation.