Skip to content

Commit cd6f096

Browse files
actions-userMastra Code (openai/gpt-5.6-sol)
andcommitted
fix(libsql): serialize factory writes on shared clients
Route Factory transactions and autocommit operations through the existing per-client write queue. This prevents concurrent dispatcher claims and other shared-client writes from colliding with open LibSQL transactions or being rolled back with unrelated work. Add file-backed regression coverage for concurrent Factory transactions and writes racing a rollback. Co-Authored-By: Mastra Code (openai/gpt-5.6-sol) <noreply@mastra.ai>
1 parent bf2a288 commit cd6f096

3 files changed

Lines changed: 219 additions & 42 deletions

File tree

‎.changeset/rich-rats-admire.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/libsql': patch
3+
---
4+
5+
Fixed Factory database lock errors by serializing writes that share a LibSQL connection.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,162 @@
1+
import * as fs from 'node:fs';
2+
import * as os from 'node:os';
3+
import * as path from 'node:path';
14
import { describeFactoryStorageContract } from '@internal/storage-test-utils';
5+
import type { CollectionSchema } from '@mastra/core/storage';
6+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
27

38
import { LibSQLFactoryStorage } from './factory-storage';
49

510
describeFactoryStorageContract('libsql', async () => {
611
const storage = new LibSQLFactoryStorage({ url: ':memory:' });
712
return { storage, close: () => storage.close() };
813
});
14+
15+
const recordsSchema = {
16+
name: 'factory_write_lock_records',
17+
columns: {
18+
id: { type: 'uuid-pk' },
19+
value: { type: 'text' },
20+
},
21+
} satisfies CollectionSchema;
22+
23+
type TestRecord = {
24+
id: string;
25+
value: string;
26+
};
27+
28+
function deferred() {
29+
let resolve!: () => void;
30+
const promise = new Promise<void>(done => {
31+
resolve = done;
32+
});
33+
return { promise, resolve };
34+
}
35+
36+
describe('LibSQLFactoryStorage shared-client write lock', () => {
37+
let tmpDir: string;
38+
let storage: LibSQLFactoryStorage;
39+
40+
beforeEach(async () => {
41+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'libsql-factory-write-lock-'));
42+
storage = new LibSQLFactoryStorage({
43+
id: 'factory-write-lock-regression',
44+
url: `file:${path.join(tmpDir, 'factory.db')}`,
45+
});
46+
await storage.ensureCollections([recordsSchema]);
47+
});
48+
49+
afterEach(async () => {
50+
await storage.close();
51+
fs.rmSync(tmpDir, { recursive: true, force: true });
52+
});
53+
54+
it('serializes concurrent Factory transactions', async () => {
55+
const firstEntered = deferred();
56+
const releaseFirst = deferred();
57+
58+
const first = storage.withTransaction(async ops => {
59+
await ops.insertOne<TestRecord>(recordsSchema.name, { value: 'first' });
60+
firstEntered.resolve();
61+
await releaseFirst.promise;
62+
});
63+
await firstEntered.promise;
64+
65+
let secondEntered = false;
66+
const second = storage.withTransaction(async ops => {
67+
secondEntered = true;
68+
await ops.insertOne<TestRecord>(recordsSchema.name, { value: 'second' });
69+
});
70+
void second.catch(() => {});
71+
72+
await Promise.resolve();
73+
expect(secondEntered).toBe(false);
74+
75+
releaseFirst.resolve();
76+
await Promise.all([first, second]);
77+
78+
const rows = await storage.ops.findMany<TestRecord>(recordsSchema.name, {});
79+
expect(rows.map(row => row.value).sort()).toEqual(['first', 'second']);
80+
});
81+
82+
it('keeps a Factory autocommit write outside a rolled-back Factory transaction', async () => {
83+
const transactionEntered = deferred();
84+
const releaseTransaction = deferred();
85+
const rollback = new Error('roll back Factory transaction');
86+
87+
const transaction = storage.withTransaction(async ops => {
88+
await ops.insertOne<TestRecord>(recordsSchema.name, { value: 'rolled-back' });
89+
transactionEntered.resolve();
90+
await releaseTransaction.promise;
91+
throw rollback;
92+
});
93+
void transaction.catch(() => {});
94+
await transactionEntered.promise;
95+
96+
let autocommitSettled = false;
97+
const autocommit = storage.ops.insertOne<TestRecord>(recordsSchema.name, { value: 'kept' }).then(row => {
98+
autocommitSettled = true;
99+
return row;
100+
});
101+
void autocommit.catch(() => {});
102+
103+
await Promise.resolve();
104+
expect(autocommitSettled).toBe(false);
105+
106+
releaseTransaction.resolve();
107+
await expect(transaction).rejects.toBe(rollback);
108+
await expect(autocommit).resolves.toMatchObject({ value: 'kept' });
109+
110+
await expect(storage.ops.findOne<TestRecord>(recordsSchema.name, { value: 'rolled-back' })).resolves.toBeNull();
111+
await expect(storage.ops.findOne<TestRecord>(recordsSchema.name, { value: 'kept' })).resolves.toMatchObject({
112+
value: 'kept',
113+
});
114+
});
115+
116+
it('keeps a wrapped Mastra-domain write outside a rolled-back Factory transaction', async () => {
117+
const experiments = storage.getMastraStorage().stores?.experiments;
118+
if (!experiments) throw new Error('LibSQLStore experiments domain is unavailable');
119+
await experiments.init();
120+
121+
const transactionEntered = deferred();
122+
const releaseTransaction = deferred();
123+
const rollback = new Error('roll back Factory transaction');
124+
125+
const transaction = storage.withTransaction(async ops => {
126+
await ops.insertOne<TestRecord>(recordsSchema.name, { value: 'rolled-back' });
127+
transactionEntered.resolve();
128+
await releaseTransaction.promise;
129+
throw rollback;
130+
});
131+
void transaction.catch(() => {});
132+
await transactionEntered.promise;
133+
134+
let experimentSettled = false;
135+
const experiment = experiments
136+
.createExperiment({
137+
datasetId: 'factory-write-lock-dataset',
138+
datasetVersion: 1,
139+
targetType: 'agent',
140+
targetId: 'factory-write-lock-agent',
141+
totalItems: 1,
142+
})
143+
.then(record => {
144+
experimentSettled = true;
145+
return record;
146+
});
147+
void experiment.catch(() => {});
148+
149+
await Promise.resolve();
150+
expect(experimentSettled).toBe(false);
151+
152+
releaseTransaction.resolve();
153+
await expect(transaction).rejects.toBe(rollback);
154+
const created = await experiment;
155+
156+
const listed = await experiments.listExperiments({
157+
datasetId: 'factory-write-lock-dataset',
158+
pagination: { page: 0, perPage: 10 },
159+
});
160+
expect(listed.experiments.map(record => record.id)).toContain(created.id);
161+
});
162+
});

‎stores/libsql/src/storage/factory-storage.ts‎

Lines changed: 60 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
} from '@mastra/core/storage';
1717

1818
import { DEFAULT_CONNECTION_TIMEOUT_MS } from './db';
19+
import { withClientWriteLock } from './db/write-lock';
1920
import { LibSQLStore } from './index';
2021

2122
export interface LibSQLFactoryStorageConfig {
@@ -99,27 +100,20 @@ function serializeDefault(value: string | number | boolean): string {
99100
return String(value);
100101
}
101102

102-
/** Simple FIFO in-process mutex serializing the single-writer paths. */
103-
class Mutex {
104-
#tail: Promise<unknown> = Promise.resolve();
105-
106-
run<T>(fn: () => Promise<T>): Promise<T> {
107-
const result = this.#tail.then(fn, fn);
108-
this.#tail = result.catch(() => {});
109-
return result;
110-
}
111-
}
112-
113103
type LibSQLExecutor = Pick<Client, 'execute'>;
104+
type WriteGate = <T>(fn: () => Promise<T>) => Promise<T>;
105+
106+
const runWithoutWriteLock: WriteGate = fn => fn();
114107

115108
class LibSQLFactoryStorageOps implements FactoryStorageOps {
116109
readonly #client: LibSQLExecutor;
117110
readonly #schemas: Map<string, CollectionSchema>;
118-
readonly #writeMutex = new Mutex();
111+
readonly #withWriteLock: WriteGate;
119112

120-
constructor(client: LibSQLExecutor, schemas: Map<string, CollectionSchema>) {
113+
constructor(client: LibSQLExecutor, schemas: Map<string, CollectionSchema>, withWriteLock: WriteGate) {
121114
this.#client = client;
122115
this.#schemas = schemas;
116+
this.#withWriteLock = withWriteLock;
123117
}
124118

125119
#schema(collection: string): CollectionSchema {
@@ -291,7 +285,7 @@ class LibSQLFactoryStorageOps implements FactoryStorageOps {
291285
return this.#select<T>(collection, where, opts);
292286
}
293287

294-
async insertOne<T extends Record<string, unknown>>(collection: string, row: Partial<T>): Promise<T> {
288+
async #insertOne<T extends Record<string, unknown>>(collection: string, row: Partial<T>): Promise<T> {
295289
const schema = this.#schema(collection);
296290
const pk = primaryKeyOf(schema);
297291

@@ -320,7 +314,11 @@ class LibSQLFactoryStorageOps implements FactoryStorageOps {
320314
return inserted;
321315
}
322316

323-
async upsertOne<T extends Record<string, unknown>>(
317+
async insertOne<T extends Record<string, unknown>>(collection: string, row: Partial<T>): Promise<T> {
318+
return this.#withWriteLock(() => this.#insertOne<T>(collection, row));
319+
}
320+
321+
async #upsertOne<T extends Record<string, unknown>>(
324322
collection: string,
325323
conflictKeys: string[],
326324
row: Partial<T>,
@@ -340,14 +338,14 @@ class LibSQLFactoryStorageOps implements FactoryStorageOps {
340338
),
341339
);
342340
if (Object.keys(set).length > 0) {
343-
await this.updateMany(collection, { [pk]: existing[pk] as CollectionValue }, set);
341+
await this.#updateMany(collection, { [pk]: existing[pk] as CollectionValue }, set);
344342
}
345343
const updated = await this.findOne<T>(collection, { [pk]: existing[pk] as CollectionValue });
346344
if (!updated) continue; // deleted concurrently; retry
347345
return updated;
348346
}
349347
try {
350-
return await this.insertOne<T>(collection, row);
348+
return await this.#insertOne<T>(collection, row);
351349
} catch (error) {
352350
if (!(error instanceof UniqueViolationError)) throw error;
353351
lastError = error; // lost an insert race; retry as update
@@ -356,7 +354,15 @@ class LibSQLFactoryStorageOps implements FactoryStorageOps {
356354
throw lastError ?? new Error(`LibSQLFactoryStorage: upsert into '${collection}' did not converge`);
357355
}
358356

359-
async updateMany(collection: string, where: CollectionWhere, set: Record<string, unknown>): Promise<number> {
357+
async upsertOne<T extends Record<string, unknown>>(
358+
collection: string,
359+
conflictKeys: string[],
360+
row: Partial<T>,
361+
): Promise<T> {
362+
return this.#withWriteLock(() => this.#upsertOne<T>(collection, conflictKeys, row));
363+
}
364+
365+
async #updateMany(collection: string, where: CollectionWhere, set: Record<string, unknown>): Promise<number> {
360366
const schema = this.#schema(collection);
361367
const columns = Object.keys(set).filter(column => set[column] !== undefined);
362368
if (columns.length === 0) return 0;
@@ -367,7 +373,11 @@ class LibSQLFactoryStorageOps implements FactoryStorageOps {
367373
return result.rowsAffected;
368374
}
369375

370-
async deleteMany(collection: string, where: CollectionWhere): Promise<number> {
376+
async updateMany(collection: string, where: CollectionWhere, set: Record<string, unknown>): Promise<number> {
377+
return this.#withWriteLock(() => this.#updateMany(collection, where, set));
378+
}
379+
380+
async #deleteMany(collection: string, where: CollectionWhere): Promise<number> {
371381
const schema = this.#schema(collection);
372382
const filter = this.#buildWhere(schema, where);
373383
const result = await this.#client.execute({
@@ -377,22 +387,24 @@ class LibSQLFactoryStorageOps implements FactoryStorageOps {
377387
return result.rowsAffected;
378388
}
379389

390+
async deleteMany(collection: string, where: CollectionWhere): Promise<number> {
391+
return this.#withWriteLock(() => this.#deleteMany(collection, where));
392+
}
393+
380394
async updateAtomic<T extends Record<string, unknown>>(
381395
collection: string,
382396
where: CollectionWhere,
383397
fn: (row: T) => Partial<T> | null | Promise<Partial<T> | null>,
384398
): Promise<T | null> {
385-
const schema = this.#schema(collection);
386-
const pk = primaryKeyOf(schema);
387-
// libsql local is single-writer; serializing read-modify-write in process
388-
// gives the same "no lost updates" guarantee pg gets from FOR UPDATE.
389-
return this.#writeMutex.run(async () => {
399+
return this.#withWriteLock(async () => {
400+
const schema = this.#schema(collection);
401+
const pk = primaryKeyOf(schema);
390402
const row = await this.findOne<T>(collection, where);
391403
if (!row) return null;
392404
const patch = await fn(row);
393405
if (patch === null) return row;
394406
const pkWhere = { [pk]: row[pk] as CollectionValue } as CollectionWhere;
395-
await this.updateMany(collection, pkWhere, patch);
407+
await this.#updateMany(collection, pkWhere, patch);
396408
return this.findOne<T>(collection, pkWhere);
397409
});
398410
}
@@ -420,7 +432,7 @@ export class LibSQLFactoryStorage extends FactoryStorage {
420432
...(config.authToken ? { authToken: config.authToken } : {}),
421433
...(isLocalDb ? { timeout: DEFAULT_CONNECTION_TIMEOUT_MS } : {}),
422434
});
423-
this.ops = new LibSQLFactoryStorageOps(this.#client, this.#schemas);
435+
this.ops = new LibSQLFactoryStorageOps(this.#client, this.#schemas, fn => withClientWriteLock(this.#client, fn));
424436
}
425437

426438
getMastraStorage(): MastraCompositeStore {
@@ -437,25 +449,31 @@ export class LibSQLFactoryStorage extends FactoryStorage {
437449
}
438450

439451
async withTransaction<T>(fn: (ops: FactoryStorageOps) => Promise<T>): Promise<T> {
440-
if (this.#config.url.includes(':memory:')) return fn(this.ops);
441-
const transaction = await this.#client.transaction('write');
442-
try {
443-
const result = await fn(new LibSQLFactoryStorageOps(transaction, this.#schemas));
444-
await transaction.commit();
445-
return result;
446-
} catch (error) {
447-
await transaction.rollback();
448-
throw error;
449-
} finally {
450-
transaction.close();
451-
}
452+
return withClientWriteLock(this.#client, async () => {
453+
if (this.#config.url.includes(':memory:')) {
454+
return fn(new LibSQLFactoryStorageOps(this.#client, this.#schemas, runWithoutWriteLock));
455+
}
456+
const transaction = await this.#client.transaction('write');
457+
try {
458+
const result = await fn(new LibSQLFactoryStorageOps(transaction, this.#schemas, runWithoutWriteLock));
459+
await transaction.commit();
460+
return result;
461+
} catch (error) {
462+
await transaction.rollback();
463+
throw error;
464+
} finally {
465+
transaction.close();
466+
}
467+
});
452468
}
453469

454470
async ensureCollections(schemas: CollectionSchema[]): Promise<void> {
455-
for (const schema of schemas) {
456-
await this.#ensureCollection(schema);
457-
this.#schemas.set(schema.name, schema);
458-
}
471+
await withClientWriteLock(this.#client, async () => {
472+
for (const schema of schemas) {
473+
await this.#ensureCollection(schema);
474+
this.#schemas.set(schema.name, schema);
475+
}
476+
});
459477
}
460478

461479
async close(): Promise<void> {

0 commit comments

Comments
 (0)