Skip to content

Commit 8cdde58

Browse files
Aayush-engineerabhiaiyer91Mastra Code (anthropic/claude-opus-4-8)
authored
fix(libsql): add public close() to LibSQLStore and wire into Mastra.shutdown() (#17306)
Co-authored-by: Abhi Aiyer <abhiaiyer91@gmail.com> Co-authored-by: Mastra Code (anthropic/claude-opus-4-8) <noreply@mastra.ai>
1 parent 0f7f06b commit 8cdde58

6 files changed

Lines changed: 207 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@mastra/libsql": patch
3+
---
4+
5+
Added a public `close()` method to `LibSQLStore` that releases SQLite file handles and cleans up the WAL/shm sidecar files. Previously these handles stayed open until the process exited, which on Windows caused `EBUSY` errors when removing the storage directory after shutdown. `Mastra.shutdown()` now calls `close()` automatically, so you no longer need to reach into private fields.
6+
7+
```typescript
8+
const storage = new LibSQLStore({ id: 'my-store', url: 'file:./dev.db' });
9+
10+
// Release all file handles, including WAL/shm sidecar files
11+
await storage.close();
12+
13+
// Now safe to remove the storage directory on all platforms, including Windows
14+
await fs.rm('./dev.db', { recursive: true, force: true });
15+
```
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@mastra/core": patch
3+
---
4+
5+
`Mastra.shutdown()` now releases storage resources automatically. Stores that expose a `close()` lifecycle hook (such as `LibSQLStore`) are closed during shutdown, so file handles are freed and the storage directory can be removed cleanly afterward, including on Windows.
6+
7+
```typescript
8+
const mastra = new Mastra({ storage });
9+
10+
// Storage is closed for you — no manual cleanup needed
11+
await mastra.shutdown();
12+
```

packages/core/src/mastra/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4294,6 +4294,11 @@ export class Mastra<
42944294
async shutdown(): Promise<void> {
42954295
// SchedulerWorker is stopped as part of stopWorkers().
42964296
await this.stopWorkers();
4297+
// Close storage to release OS file handles (critical on Windows: open WAL/shm
4298+
// handles cause EBUSY when callers try to fs.rm the storage dir after shutdown).
4299+
if (this.#storage?.close) {
4300+
await this.#storage.close();
4301+
}
42974302
// Shutdown observability registry, exporters, etc...
42984303
await this.#observability.shutdown();
42994304

packages/core/src/storage/base.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,13 @@ export class MastraCompositeStore extends MastraBase {
460460
await Promise.all(initTasks);
461461
return true;
462462
}
463+
/**
464+
* Optional lifecycle hook: release underlying client/connection handles.
465+
* Implementations (e.g. LibSQLStore) override this to checkpoint WAL files
466+
* and close the database client so OS handles are freed synchronously.
467+
* Called automatically by Mastra.shutdown().
468+
*/
469+
close?(): Promise<void>;
463470
}
464471

465472
/**
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { createClient } from '@libsql/client';
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
7+
import { LibSQLStore } from './index';
8+
9+
type TestClient = ReturnType<typeof createClient>;
10+
11+
const getClient = (store: LibSQLStore): TestClient => (store as unknown as { client: TestClient }).client;
12+
13+
const executedSqlFrom = (spy: ReturnType<typeof vi.spyOn>): string[] =>
14+
(spy.mock.calls as unknown as unknown[][]).map(call => {
15+
const arg = call[0];
16+
return typeof arg === 'string' ? arg : (arg as { sql: string }).sql;
17+
});
18+
19+
describe('LibSQLStore.close()', () => {
20+
let tmpDir: string;
21+
22+
beforeEach(() => {
23+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'libsql-close-test-'));
24+
});
25+
26+
afterEach(() => {
27+
fs.rmSync(tmpDir, { recursive: true, force: true });
28+
});
29+
30+
it('checkpoints/truncates the WAL and closes the client for local file DBs', async () => {
31+
const dbPath = path.join(tmpDir, 'mastra.db');
32+
const store = new LibSQLStore({ id: 'close-local', url: `file:${dbPath}` });
33+
await store.init();
34+
35+
const client = getClient(store);
36+
const executeSpy = vi.spyOn(client, 'execute');
37+
const closeSpy = vi.spyOn(client, 'close');
38+
39+
await store.close();
40+
41+
const executedSql = executedSqlFrom(executeSpy);
42+
expect(executedSql).toContain('PRAGMA wal_checkpoint(TRUNCATE);');
43+
expect(executedSql).toContain('PRAGMA journal_mode=DELETE;');
44+
expect(closeSpy).toHaveBeenCalledTimes(1);
45+
expect(client.closed).toBe(true);
46+
});
47+
48+
it('is idempotent — a second close() is a no-op', async () => {
49+
const dbPath = path.join(tmpDir, 'mastra.db');
50+
const store = new LibSQLStore({ id: 'close-idempotent', url: `file:${dbPath}` });
51+
await store.init();
52+
53+
const client = getClient(store);
54+
55+
await store.close();
56+
57+
const executeSpy = vi.spyOn(client, 'execute');
58+
const closeSpy = vi.spyOn(client, 'close');
59+
60+
await expect(store.close()).resolves.toBeUndefined();
61+
62+
expect(executeSpy).not.toHaveBeenCalled();
63+
expect(closeSpy).not.toHaveBeenCalled();
64+
});
65+
66+
it('runs WAL cleanup for an injected client that points at a local file', async () => {
67+
const dbPath = path.join(tmpDir, 'injected.db');
68+
const client = createClient({ url: `file:${dbPath}` });
69+
const store = new LibSQLStore({ id: 'close-injected-local', client });
70+
await store.init();
71+
72+
expect(client.protocol).toBe('file');
73+
74+
const executeSpy = vi.spyOn(client, 'execute');
75+
const closeSpy = vi.spyOn(client, 'close');
76+
77+
await store.close();
78+
79+
expect(executedSqlFrom(executeSpy)).toContain('PRAGMA wal_checkpoint(TRUNCATE);');
80+
expect(closeSpy).toHaveBeenCalledTimes(1);
81+
});
82+
83+
it('skips WAL pragmas for non-file (remote) clients', async () => {
84+
const client = createClient({ url: `file:${path.join(tmpDir, 'remote.db')}` });
85+
// Pretend this is a remote connection without touching the underlying file logic.
86+
Object.defineProperty(client, 'protocol', { value: 'https', configurable: true });
87+
88+
const store = new LibSQLStore({ id: 'close-remote', client });
89+
await store.init();
90+
91+
const executeSpy = vi.spyOn(client, 'execute');
92+
const closeSpy = vi.spyOn(client, 'close');
93+
94+
await store.close();
95+
96+
const executedSql = executedSqlFrom(executeSpy);
97+
expect(executedSql).not.toContain('PRAGMA wal_checkpoint(TRUNCATE);');
98+
expect(executedSql).not.toContain('PRAGMA journal_mode=DELETE;');
99+
expect(closeSpy).toHaveBeenCalledTimes(1);
100+
});
101+
102+
it('still closes the client and warns when WAL checkpoint fails', async () => {
103+
const dbPath = path.join(tmpDir, 'wal-fail.db');
104+
const store = new LibSQLStore({ id: 'close-wal-fail', url: `file:${dbPath}` });
105+
await store.init();
106+
107+
const client = getClient(store);
108+
vi.spyOn(client, 'execute').mockRejectedValue(new Error('boom'));
109+
const closeSpy = vi.spyOn(client, 'close');
110+
const warnSpy = vi.spyOn((store as unknown as { logger: { warn: (...args: unknown[]) => void } }).logger, 'warn');
111+
112+
await expect(store.close()).resolves.toBeUndefined();
113+
114+
expect(warnSpy).toHaveBeenCalled();
115+
expect(closeSpy).toHaveBeenCalledTimes(1);
116+
});
117+
118+
it('removes WAL sidecar files so the storage dir can be deleted', async () => {
119+
const dbPath = path.join(tmpDir, 'sidecars.db');
120+
const store = new LibSQLStore({ id: 'close-sidecars', url: `file:${dbPath}` });
121+
await store.init();
122+
123+
// Force some writes so the WAL sidecar files exist.
124+
const client = getClient(store);
125+
await client.execute('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY);');
126+
await client.execute('INSERT INTO t (id) VALUES (1);');
127+
128+
await store.close();
129+
130+
expect(fs.existsSync(`${dbPath}-wal`)).toBe(false);
131+
expect(fs.existsSync(`${dbPath}-shm`)).toBe(false);
132+
});
133+
});

stores/libsql/src/storage/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,41 @@ export class LibSQLStore extends MastraCompositeStore {
296296

297297
await this.initDomainsSequentially();
298298
}
299+
300+
/**
301+
* Closes the underlying libsql client, releasing all OS file handles.
302+
*
303+
* For local file databases, first runs PRAGMA wal_checkpoint(TRUNCATE) and
304+
* switches back to journal_mode=DELETE so that Windows releases the -wal
305+
* and -shm sidecar files promptly. Without this, the handles stay open
306+
* until process exit, causing EBUSY errors when callers try to fs.rm the
307+
* storage directory after Mastra.shutdown().
308+
*
309+
* Remote (Turso) databases skip the WAL pragmas and just close the client.
310+
*
311+
* Safe to call more than once; subsequent calls are no-ops.
312+
*/
313+
async close(): Promise<void> {
314+
if (this.client.closed) {
315+
return;
316+
}
317+
318+
// A store built from an injected client may still point at a local file even
319+
// though `isLocalDb` (derived from the url config) is false, so also trust the
320+
// client's own protocol to decide whether WAL cleanup is needed.
321+
const isLocalFileDb = this.isLocalDb || this.client.protocol === 'file';
322+
323+
if (isLocalFileDb) {
324+
try {
325+
await this.client.execute('PRAGMA wal_checkpoint(TRUNCATE);');
326+
await this.client.execute('PRAGMA journal_mode=DELETE;');
327+
} catch (err) {
328+
this.logger.warn('LibSQLStore: Failed to checkpoint WAL before close.', err);
329+
}
330+
}
331+
332+
this.client.close();
333+
}
299334
}
300335

301336
export { LibSQLStore as DefaultStorage };

0 commit comments

Comments
 (0)