Database Transactions
Database transactions in Adonis ODM provide ACID (Atomicity, Consistency, Isolation, Durability) properties for MongoDB operations. Transactions ensure that multiple operations either all succeed or all fail, maintaining data integrity across your application.
Understanding MongoDB Transactions
MongoDB transactions work with:
- Replica Sets (MongoDB 4.0+)
- Sharded Clusters (MongoDB 4.2+)
- Single Documents (always atomic)
Transactions are essential for operations that span multiple documents or collections.
Basic Transaction Usage
Simple Transaction
import db from "adonis-odm/services/db";
import User from "#models/user";
import UserProfile from "#models/user_profile";
// Basic transaction
const trx = await db.transaction();
try {
// Create user
const user = await User.create(
{
name: "John Doe",
email: "[email protected]",
},
{ client: trx }
);
// Create profile
const profile = await UserProfile.create(
{
userId: user._id,
bio: "Software developer",
avatar: "default.jpg",
},
{ client: trx }
);
// Commit transaction
await trx.commit();
console.log("User and profile created successfully");
} catch (error) {
// Rollback on error
await trx.rollback();
throw error;
}
Using Transaction Callback
// Transaction with callback (auto-commit/rollback)
const result = await db.transaction(async (trx) => {
const user = await User.create(
{
name: "Jane Smith",
email: "[email protected]",
},
{ client: trx }
);
const profile = await UserProfile.create(
{
userId: user._id,
bio: "Product manager",
},
{ client: trx }
);
return { user, profile };
});
// Transaction automatically committed if no errors
console.log("Created user:", result.user._id);
Advanced Transaction Patterns
Nested Transactions
// Outer transaction
await db.transaction(async (outerTrx) => {
const user = await User.create(
{
name: "Alice Johnson",
email: "[email protected]",
},
{ client: outerTrx }
);
// Inner transaction (savepoint)
await db.transaction(
async (innerTrx) => {
const profile = await UserProfile.create(
{
userId: user._id,
bio: "Designer",
},
{ client: innerTrx }
);
const preferences = await UserPreferences.create(
{
userId: user._id,
theme: "dark",
notifications: true,
},
{ client: innerTrx }
);
return { profile, preferences };
},
{ client: outerTrx }
);
return user;
});
Conditional Transactions
async function transferCredits(
fromUserId: string,
toUserId: string,
amount: number
) {
return await Database.transaction(async (trx) => {
// Get users with lock
const fromUser = await User.query({ client: trx })
.where("_id", fromUserId)
.forUpdate()
.firstOrFail();
const toUser = await User.query({ client: trx })
.where("_id", toUserId)
.forUpdate()
.firstOrFail();
// Check sufficient balance
if (fromUser.credits < amount) {
throw new Error("Insufficient credits");
}
// Update balances
fromUser.credits -= amount;
toUser.credits += amount;
await fromUser.save({ client: trx });
await toUser.save({ client: trx });
// Log transaction
await TransactionLog.create(
{
fromUserId,
toUserId,
amount,
type: "credit_transfer",
},
{ client: trx }
);
return { fromUser, toUser };
});
}
Transaction Options
Read Concerns
// Read concern options
const trx = await Database.transaction({
readConcern: {
level: "majority", // 'local', 'available', 'majority', 'linearizable', 'snapshot'
},
});
try {
const users = await User.query({ client: trx })
.where("status", "active")
.all();
await trx.commit();
} catch (error) {
await trx.rollback();
throw error;
}
Write Concerns
// Write concern options
const trx = await Database.transaction({
writeConcern: {
w: "majority",
j: true,
wtimeout: 5000,
},
});
try {
await User.create(
{
name: "Important User",
email: "[email protected]",
},
{ client: trx }
);
await trx.commit();
} catch (error) {
await trx.rollback();
throw error;
}
Session Options
// Custom session options
const trx = await Database.transaction({
maxCommitTimeMS: 30000,
defaultTransactionOptions: {
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" },
},
});
Error Handling
Transaction Error Types
try {
await Database.transaction(async (trx) => {
// Operations that might fail
await User.create(
{
email: "[email protected]", // Might violate unique constraint
},
{ client: trx }
);
});
} catch (error) {
if (error.code === 11000) {
// Duplicate key error
console.log("Email already exists");
} else if (error.errorLabels?.includes("TransientTransactionError")) {
// Retry transaction
console.log("Transaction failed, retrying...");
} else if (error.errorLabels?.includes("UnknownTransactionCommitResult")) {
// Commit result unknown
console.log("Transaction commit result unknown");
} else {
// Other errors
throw error;
}
}
Retry Logic
async function withRetry<T>(
operation: () => Promise<T>,
maxRetries: number = 3
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
// Check if error is retryable
if (
error.errorLabels?.includes("TransientTransactionError") ||
error.errorLabels?.includes("UnknownTransactionCommitResult")
) {
if (attempt < maxRetries) {
// Wait before retry
await new Promise((resolve) => setTimeout(resolve, attempt * 100));
continue;
}
}
throw error;
}
}
throw lastError!;
}
// Usage
const result = await withRetry(async () => {
return await Database.transaction(async (trx) => {
// Your transaction operations
return await performComplexOperation(trx);
});
});
Performance Considerations
Transaction Scope
// Good: Keep transactions short
await Database.transaction(async (trx) => {
const user = await User.create(userData, { client: trx });
const profile = await UserProfile.create(profileData, { client: trx });
return { user, profile };
});
// Avoid: Long-running transactions
await Database.transaction(async (trx) => {
// Don't do this - external API calls in transactions
const externalData = await fetch("https://api.example.com/data");
// Don't do this - heavy computations
const processedData = await heavyDataProcessing(data);
const user = await User.create(userData, { client: trx });
});
Batch Operations
// Efficient batch operations in transactions
await Database.transaction(async (trx) => {
// Create multiple users efficiently
const users = await User.createMany(
[
{ name: "User 1", email: "[email protected]" },
{ name: "User 2", email: "[email protected]" },
{ name: "User 3", email: "[email protected]" },
],
{ client: trx }
);
// Bulk update
await User.query({ client: trx })
.whereIn(
"_id",
users.map((u) => u._id)
)
.update({ status: "active" });
return users;
});
Transaction Isolation
Read Phenomena
// Prevent dirty reads
await Database.transaction(
async (trx) => {
// Read with transaction isolation
const user = await User.query({ client: trx })
.where("_id", userId)
.firstOrFail();
// Modifications are isolated
user.balance += 100;
await user.save({ client: trx });
},
{
readConcern: { level: "snapshot" },
}
);
Optimistic Concurrency Control
// Using version fields for optimistic locking
export default class Account extends BaseModel {
@column()
declare balance: number;
@column()
declare version: number;
async updateBalance(amount: number, trx?: TransactionClient) {
const currentVersion = this.version;
this.balance += amount;
this.version += 1;
// Update with version check
const updated = await Account.query({ client: trx })
.where("_id", this._id)
.where("version", currentVersion)
.update({
balance: this.balance,
version: this.version,
});
if (updated === 0) {
throw new Error("Concurrent modification detected");
}
}
}
Testing Transactions
Transaction Testing
import { test } from "@japa/runner";
import Database from "@ioc:Adonis/Lucid/Database";
test.group("User transactions", (group) => {
group.each.setup(async () => {
// Setup test data
});
group.each.teardown(async () => {
// Cleanup
});
test("should create user and profile atomically", async ({ assert }) => {
const result = await Database.transaction(async (trx) => {
const user = await User.create(
{
name: "Test User",
email: "[email protected]",
},
{ client: trx }
);
const profile = await UserProfile.create(
{
userId: user._id,
bio: "Test bio",
},
{ client: trx }
);
return { user, profile };
});
assert.exists(result.user._id);
assert.exists(result.profile._id);
assert.equal(result.profile.userId, result.user._id);
});
test("should rollback on error", async ({ assert }) => {
await assert.rejects(async () => {
await Database.transaction(async (trx) => {
await User.create(
{
name: "Test User",
email: "[email protected]",
},
{ client: trx }
);
// This will cause rollback
throw new Error("Simulated error");
});
});
// Verify no user was created
const userCount = await User.query().count();
assert.equal(userCount, 0);
});
});
Best Practices
Transaction Guidelines
// 1. Keep transactions short and focused
async function createUserWithProfile(userData: any, profileData: any) {
return await Database.transaction(async (trx) => {
const user = await User.create(userData, { client: trx });
const profile = await UserProfile.create(
{
...profileData,
userId: user._id,
},
{ client: trx }
);
return { user, profile };
});
}
// 2. Handle errors appropriately
async function safeTransfer(fromId: string, toId: string, amount: number) {
try {
return await Database.transaction(async (trx) => {
// Transfer logic
});
} catch (error) {
// Log error
console.error("Transfer failed:", error);
// Re-throw for caller to handle
throw new Error("Transfer could not be completed");
}
}
// 3. Use appropriate isolation levels
async function generateReport() {
return await Database.transaction(
async (trx) => {
// Use snapshot isolation for consistent reads
const data = await aggregateData(trx);
return generateReportFromData(data);
},
{
readConcern: { level: "snapshot" },
}
);
}
Next Steps
- Connection Management - Manage database connections
- Model Lifecycle - Understand transaction hooks
- Transaction Client API - Complete API reference
- Examples: Transactions - Practical transaction examples