monSQLize wraps MongoDB driver sessions with withTransaction() and startSession() helpers. The ACID boundary is MongoDB's transaction/session boundary; monSQLize adds retry, timeout, statistics, and cache-invalidation coordination around that driver behavior.
Managed transaction writes stop being accepted as soon as commit or abort begins, including lazy aggregate writes and batch subwrites. An unknown commit result retries commit without rerunning the business callback; errors after a successful commit also do not rerun it. Native sessions passed directly to the MongoDB driver keep their native behavior.
Core Features
- ✅ Automatic Transaction Management (withTransaction - Recommended)
- ✅ Manual transaction management (startSession - Advanced usage)
- ✅ Transaction-aware cache policy (session reads bypass shared query cache; writes record post-commit invalidations)
- ✅ Process-local cache barrier/lock (short-lived protection around cache invalidation)
- ✅ Auto-Retry (Auto-retry on transient errors)
- ✅ Timeout processing (automatically interrupt long transactions)
- ✅ Monitoring indicators (execution time, success rate, etc.)
- ✅ Read attention/read preference/causal consistency
Prerequisites
- ✅ MongoDB 4.0+ replica set or sharded cluster
- ✅ Node.js 18+
Quick start
1. Initialization and configuration
import MonSQLize from 'monsqlize';
const msq = new MonSQLize({
type: 'mongodb',
databaseName: 'mydb',
config: {
uri: 'mongodb://localhost:27017?replicaSet=rs0', // Must be a replica set
options: {}
},
cache: {
defaultTtl: 60000, //Cache for 60 seconds
maxEntries: 1000 //Up to 1000 items
}
});
await msq.connect();
const { collection } = await msq.connect();
2. Use automatic transactions (recommended⭐)
The simplest way to automatically manage commits, rollbacks and retries:
//Example 1: Transfer
await msq.withTransaction(async (tx) => {
const accounts = collection('accounts');
//Debit Alice
await accounts.updateOne(
{ userId: 'alice' },
{ $inc: { balance: -100 } },
{ session: tx.session } //🔑 Pass in session
);
//Add money to Bob
await accounts.updateOne(
{ userId: 'bob' },
{ $inc: { balance: 100 } },
{ session: tx.session }
);
//✅ Success: Automatic submission
//❌ Failure: automatic rollback
});
//Example 2: Inventory deduction + create order
const orderId = await msq.withTransaction(async (tx) => {
const inventory = collection('inventory');
const orders = collection('orders');
//Check inventory
const product = await inventory.findOne(
{ productId: 'SKU123' },
{ session: tx.session }
);
if (product.stock < 10) {
throw new Error('Insufficient stock');
}
//deduction inventory
await inventory.updateOne(
{ productId: 'SKU123' },
{ $inc: { stock: -10 } },
{ session: tx.session }
);
//Create order
const order = {
orderId: 'ORDER001',
productId: 'SKU123',
quantity: 10,
createdAt: new Date()
};
await orders.insertOne(order, { session: tx.session });
return order.orderId;
});
console.log('Order created successfully:', orderId);
3. Use manual transactions (advanced usage)
Use when you need fine control over the transaction life cycle:
const tx = await msq.startSession();
try {
await tx.start();
//perform operations
await collection('accounts').updateOne(
{ userId: 'alice' },
{ $inc: { balance: -100 } },
{ session: tx.session }
);
//Manual submission
await tx.commit();
} catch (error) {
//Manual rollback
await tx.abort();
throw error;
} finally {
//Release resources
await tx.end();
}
Configuration options
Global configuration (constructor)
const msq = new MonSQLize({
//...basic configuration
transaction: {
//⭐ Important: Caching is not used by default within transactions (to ensure data consistency)
//If you want to use cache within a transaction, you need to explicitly specify the cache parameter when querying
//Whether to enable automatic retry (default: true)
enableRetry: true,
//Maximum number of retries (default: 3)
maxRetries: 3,
//Retry delay (ms, default: 100)
retryDelay: 100,
//Retry backoff factor (default: 2)
retryBackoff: 2,
//Default timeout (milliseconds, default: 30000)
defaultTimeout: 30000,
//Default read following (optional)
defaultReadConcern: { level: 'majority' },
//Write follow by default (optional)
defaultWriteConcern: { w: 'majority' },
//Cache lock maximum duration (ms, default: 300000)
lockMaxDuration: 300000,
//Cache lock cleanup interval (milliseconds, default: 10000)
lockCleanupInterval: 10000
}
});
Single transaction configuration
await msq.withTransaction(async (tx) => {
//transaction logic
}, {
//Read attention level
readConcern: { level: 'snapshot' },
//write level of concern
writeConcern: { w: 'majority' },
//Read preferences
readPreference: 'primary',
//Causal consistency (default: true)
causalConsistency: true,
//Timeout (milliseconds)
timeout: 60000,
//Maximum number of retries (overrides global configuration)
maxRetries: 5
});
API Reference
msq.withTransaction(callback, options)
Automatically manage transactions (recommended).
Parameters:
callback(tx): transaction callback function
tx.session: MongoDB session object
tx.id: transaction unique identifier
tx.state: Transaction status ('pending' | 'active' | 'committed' | 'aborted')
options: transaction options (optional)
Returns: Promise - returns the return value of callback
Example:
const result = await msq.withTransaction(async (tx) => {
//Operations within a transaction must be passed into the session
await collection('users').updateOne(
{ _id: 1 },
{ $set: { name: 'Alice' } },
{ session: tx.session }
);
return { success: true };
});
msq.startSession(options)
Create a manual transaction session.
Parameters:
options: Transaction options (same as withTransaction)
Return: Promise
Transaction instance method:
start(): Start transaction
commit(): Commit transaction
abort(): rollback transaction
end(): Release resources
Example:
const tx = await msq.startSession({
readConcern: { level: 'majority' },
timeout: 60000
});
try {
await tx.start();
//perform operations
await collection('accounts').updateOne(
{ _id: 1 },
{ $inc: { balance: -100 } },
{ session: tx.session }
);
await tx.commit();
} catch (error) {
await tx.abort();
throw error;
} finally {
await tx.end();
}
Caching strategy
⭐Default policy: no caching within transactions (recommended)
Design Concept: Transactions pursue data consistency, and caching pursues performance. By default, intra-transaction operations do not use caching, ensuring data accuracy.
await msq.withTransaction(async (tx) => {
//✅ Intra-transaction query: read directly from the database, without using cache
const user = await collection('users').findOne(
{ _id: 1 },
{ session: tx.session }
//No need to specify cache: 0, no caching by default
);
//✅ Intra-transaction writing: record a cache invalidation intent only when configured
await collection('users').updateOne(
{ _id: 1 },
{ $set: { balance: 100 } },
{
session: tx.session,
cache: {
invalidate: [{
operation: 'findOne',
query: { _id: 1 },
options: { cache: 5000 }
}]
}
}
);
});
// Cache invalidation is flushed only after the transaction commits successfully.
// If the transaction aborts, the pending invalidation intent is discarded.
// Post-commit cache invalidation is best-effort; a cache failure does not roll back the database commit.
//✅ Query outside transaction: use cache normally
const user = await collection('users').findOne(
{ _id: 1 },
{ cache: 60000 } //Cache for 60 seconds
);
Session reads and cache
When an operation receives session: tx.session, monSQLize forwards the session to the MongoDB driver and skips the shared query-result cache. There is no separate transaction-cache isolation flag in the public API. Keep repeated transaction reads inside the driver snapshot, or move cacheable reads outside the transaction when that is safe for your workflow.
Function: Record explicit cache invalidation intents during the transaction and keep a short-lived, process-local barrier until those intents are flushed after commit or discarded on abort.
await msq.withTransaction(async (tx) => {
//1. Update data
await collection('users').updateOne(
{ _id: 1 },
{ $set: { balance: 100 } },
{
session: tx.session,
cache: {
invalidate: [{
operation: 'findOne',
query: { _id: 1 },
options: { cache: 5000 }
}]
}
}
);
//🔒 Records the configured cache invalidation intent in this process
//2. Other reads in this process bypass or avoid refilling affected cache keys
//3. Transaction submission
await tx.commit();
//🔓 Release process-local lock + flush recorded invalidations
});
The cache lock manager is process-local. Cross-instance cache coherence depends on post-commit distributed invalidation when configured, and remains best-effort/eventual rather than an atomic cache/DB commit.
Cache strategy comparison
Best Practices
1. Idempotent design ⭐
Important: Transaction callbacks must be idempotent as automatic retries may be possible.
//✅ Good design: use unique identifiers
await msq.withTransaction(async (tx) => {
await collection('orders').insertOne({
orderId: 'ORDER_' + Date.now(), //Unique ID
status: 'pending'
}, { session: tx.session });
});
//❌ Bad design: relying on external state
let counter = 0;
await msq.withTransaction(async (tx) => {
counter++; //Retrying will cause the counter to be incremented multiple times
await collection('logs').insertOne({
logId: counter //Not idempotent
}, { session: tx.session });
});
2. Timeout setting
//Short transactions (recommended)
await msq.withTransaction(async (tx) => {
//Simple operation
}, { timeout: 5000 });
//Long transactions (use with caution)
await msq.withTransaction(async (tx) => {
//Complex operations
}, { timeout: 60000 }); //MongoDB default limit is 60 seconds
3. Error handling
try {
await msq.withTransaction(async (tx) => {
//business logic
const user = await collection('users').findOne(
{ _id: 1 },
{ session: tx.session }
);
if (!user) {
throw new Error('User does not exist');
}
//More actions...
});
} catch (error) {
if (error.message === 'User does not exist') {
//business error
console.error('Business error:', error.message);
} else if (error.errorLabels?.includes('TransientTransactionError')) {
//MongoDB transient error (automatically retried)
console.error('Transaction failed:', error.message);
} else {
//Other errors
console.error('Unknown error:', error);
}
}
//✅ Good practice: Verify first, then transaction
async function transfer(fromId, toId, amount) {
//1. Pre-check outside the transaction (fail fast)
const fromUser = await collection('users').findOne({ _id: fromId });
if (!fromUser || fromUser.balance < amount) {
throw new Error('Insufficient balance');
}
//2. Execution within a transaction
await msq.withTransaction(async (tx) => {
await collection('users').updateOne(
{ _id: fromId },
{ $inc: { balance: -amount } },
{ session: tx.session }
);
await collection('users').updateOne(
{ _id: toId },
{ $inc: { balance: amount } },
{ session: tx.session }
);
});
}
//❌ Bad practice: all logic within transactions
await msq.withTransaction(async (tx) => {
//Complex business logic
//multiple network calls
//...(transaction occupied for a long time)
});
5. Monitoring and logging
const tx = await msq.startSession();
try {
await tx.start();
console.log('Transaction starts:', tx.id);
//business logic
await collection('users').updateOne(
{ _id: 1 },
{ $set: { lastLogin: new Date() } },
{ session: tx.session }
);
await tx.commit();
console.log('Transaction submitted successfully:', tx.id);
} catch (error) {
await tx.abort();
console.error('Transaction rollback:', tx.id, error);
throw error;
} finally {
await tx.end();
}
FAQ
Q1: Why does intra-transaction query not use cache?
A: This is the default behavior by design. Reason:
- Data consistency first - Transactions pursue accuracy, cache may have delays
- Avoid dirty reads - The latest data should be read within the transaction
- Simplified use - Users do not need to consider caching issues
If performance optimization is required, the cache option can be enabled explicitly.
Q2: When to use manual transactions?
A: Use withTransaction (auto) in most cases. Consider manual operation in the following situations:
- Need to make complex judgments before starting a transaction
- Additional verification is required before committing
- Need for fine-grained control of transaction life cycle
Q3: How to debug transaction failure?
A: Check the following points:
- **Is MongoDB a replica set? ** - Single node does not support transactions
- **Is the connection string correct? ** - requires
?replicaSet=rs0
- **Is the timeout reasonable? ** - Default 30 seconds
- **Is the callback idempotent? ** - may retry multiple times
withTransaction() retries transient transaction errors and retries commitTransaction() when the driver marks the result as UnknownTransactionCommitResult.
A: The impact is minimal. Reason:
- The lock is only in effect for the duration of the transaction (usually a short period of time)
- The lock is process-local memory (no network I/O)
- Checking of locks is very fast (O(1) hash lookup)
The cache lock is process-local. Cross-instance cache coherence is handled by distributed invalidation after commit on a best-effort basis; use application/framework-level coordination when a critical section must be mutually exclusive across processes.
Q5: Can multiple databases be operated within a transaction?
A: Yes, but there are restrictions:
- ✅ Multiple databases within the same MongoDB cluster
- ❌ Across MongoDB clusters (not supported)
await msq.withTransaction(async (tx) => {
//Operation db1.users
await collection('users').updateOne(
{ _id: 1 },
{ $set: { status: 'active' } },
{ session: tx.session }
);
//Operate db2.logs (you need to obtain the collection of db2 first)
const db2 = msq.db('db2');
await db2.collection('logs').insertOne({
action: 'user_activated',
userId: 1
}, { session: tx.session });
});
Q6: Will concurrent transactions cause deadlock?
A: MongoDB automatically detects and throws WriteConflict errors. monSQLize will automatically retry if enableRetry is enabled.
1. Reduce intra-transaction operations
//✅ Good practice
const validated = await preValidate(); //Outside affairs
if (validated) {
await msq.withTransaction(async (tx) => {
//Core operations only
});
}
//❌ Bad practice
await msq.withTransaction(async (tx) => {
await validate(); //within affairs
await doWork(); //within affairs
});
2. Batch operation
//✅ Good Practice: Use Bulk API
await msq.withTransaction(async (tx) => {
await collection('users').updateMany(
{ status: 'inactive' },
{ $set: { status: 'deleted' } },
{ session: tx.session }
);
});
//❌ Bad practice: looping single updates
await msq.withTransaction(async (tx) => {
for (const user of users) {
await collection('users').updateOne(
{ _id: user._id },
{ $set: { status: 'deleted' } },
{ session: tx.session }
);
}
});
3. Proper use of indexes
//✅ Make sure the query field is indexed
await collection('users').createIndex({ userId: 1 });
await msq.withTransaction(async (tx) => {
//Query using indexed fields (fast)
const user = await collection('users').findOne(
{ userId: 'alice' }, //There is an index
{ session: tx.session }
);
});
//Get transaction statistics
const stats = msq.getTransactionStats();
if (stats) {
console.log('Transaction statistics:', {
total: stats.totalTransactions,
successful: stats.successfulTransactions,
failed: stats.failedTransactions,
averageDuration: `${stats.averageDuration}ms`
});
}