Skip to content

Commit d8692af

Browse files
roaminroMastra Code (anthropic/claude-opus-4-7)
andauthored
fix(core): delegate MastraCompositeStore.init() to default and editor parents (#16786)
Fixes #16782. When you give `MastraCompositeStore` a `default` (or `editor`) store, it pulls out the inner domain instances and initializes them in parallel — but never actually calls the parent's own `init()`. So any connection setup, migrations, DDL ordering, or coalescing the parent adapter does is silently skipped. The loudest version of this is `LibSQLStore` on a local file: its `init()` is where pragmas like `busy_timeout` and `journal_mode=WAL` get applied and where domains init sequentially. Without it, the 17 parallel `CREATE TABLE IF NOT EXISTS` statements race on the same SQLite file, hit `SQLITE_BUSY`, and leave tables like `mastra_schedules` partially created — then the scheduler trips with `no such table`. Other adapters were quietly broken in the same way whenever their `init()` did real work. Before: ```ts async init(): Promise<void> { // domains init in parallel; default.init() / editor.init() never run await Promise.all(Object.values(this.stores).map(d => d?.init())); } ``` After: ```ts async init(): Promise<void> { // parents own their init contract — call it first await Promise.all([this.parentDefault?.init(), this.parentEditor?.init()]); // then only init domains not already covered by a parent await Promise.all(orphanDomains.map(d => d.init())); } ``` Subclasses that override `init()` (including `LibSQLStore` itself) aren't affected. Test plan: new `packages/core/src/storage/base.test.ts` covers default delegation, editor delegation, both-at-once, and parent-init-failure propagation. Existing storage + scheduler-integration + evented-workflow tests all pass; `tsc --noEmit` clean. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 (Explain Like I'm 5) The PR makes sure the main supervisor (parent store) sets up the database first so the workers (domain stores) don't try to create the same tables at once and break things. This prevents races that caused missing-table errors. --- ## Problem MastraCompositeStore.init() used to initialize each domain store directly and in parallel, but when parent stores (default or editor) were supplied it did not call their init(). That skipped parent-level setup (connection setup, pragmas, migrations, DDL ordering, and init coalescing). On LibSQL local files this led to pragmas not being applied and parallel CREATE TABLE statements racing (SQLITE_BUSY), leaving tables partially created and causing runtime "no such table" errors (e.g., mastra_schedules). --- ## Solution - MastraCompositeStore now stores references to the provided parent default and editor stores and delegates initialization to them first. - init() calls parentDefault?.init() and parentEditor?.init() before initializing domains. - After parent init, only orphan domain instances (not already covered by a parent) are initialized. - Identical parent instances passed to multiple slots are deduped (initialized exactly once). - Existing init guards and subclass overrides (e.g., LibSQLStore) remain unaffected; failures from parent init propagate through composite.init(). --- ## Changes 1. .changeset/gentle-garlics-clean.md — changelog entry describing the fix. 2. packages/core/src/storage/base.ts — MastraCompositeStore: - added protected parentDefault? and parentEditor? fields, - refactored init to delegate to parents first, dedupe shared parent instances, and only init remaining domain stores. 3. packages/core/src/storage/base.test.ts — new Vitest regression tests: - verifies delegation to default and editor parents, - verifies shared-parent dedupe (initialized once), - verifies parent-init failure propagates through composite.init(). --- ## Impact - Fixes scheduler/startup races where WorkflowScheduler could see uninitialized domain tables. - Prevents parallel per-domain CREATE TABLE races on the same SQLite file and associated SQLITE_BUSY / missing-table errors. - Preserves adapter-specific init behavior and sequential/coalescing semantics for parent stores. - Existing storage, scheduler-integration, and evented-workflow tests pass; TypeScript compiles clean. --- Fixes `#16782` <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/mastra-ai/mastra/pull/16786?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
1 parent c960279 commit d8692af

3 files changed

Lines changed: 199 additions & 74 deletions

File tree

.changeset/gentle-garlics-clean.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed a startup bug in `MastraCompositeStore.init()` when using `default` or `editor`.
6+
7+
Before this fix, the composite initialized inner domains directly and could skip parent store initialization. That could skip adapter setup steps and cause missing-table errors during startup (most visibly with `LibSQLStore` on a local file).
8+
9+
Now, `MastraCompositeStore.init()` runs parent `default` and `editor` initialization first, then initializes only domains not already covered by those parents. This preserves adapter-specific initialization behavior and prevents startup races.
10+
11+
Fixes #16782.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { MastraCompositeStore } from './base';
3+
import { InMemoryStore } from './mock';
4+
5+
/**
6+
* Regression for https://github.com/mastra-ai/mastra/issues/16782
7+
*
8+
* When a user passes `default: someStore` to MastraCompositeStore, the outer
9+
* composite extracts the inner domain instances at construction time (via the
10+
* `resolve()` helper) and exposes them directly as `this.stores`. The outer
11+
* composite's `init()` then iterates those domains and calls each domain's
12+
* `init()` in parallel — but it never calls `default.init()`.
13+
*
14+
* That's wrong for every adapter: a store's own `init()` is where it owns
15+
* connection setup, migrations, DDL ordering, and coalescing of concurrent
16+
* callers. Bypassing it silently skips that work.
17+
*
18+
* The loud failure happens with LibSQLStore on a local file: the parent
19+
* `init()` is where pragmas (`busy_timeout`, WAL) get applied and where local
20+
* DBs init their domains sequentially. Skipping it makes 17 parallel
21+
* `CREATE TABLE IF NOT EXISTS` statements race on the same SQLite file, hit
22+
* SQLITE_BUSY, and leave tables uncreated — which the scheduler then trips
23+
* over with `no such table: mastra_schedules`.
24+
*/
25+
describe('MastraCompositeStore — default delegation (issue #16782)', () => {
26+
it('delegates init() to the underlying `default` store', async () => {
27+
// The inner store stands in for any real adapter that does work in its
28+
// own init() (setup, migrations, sequencing). The composite must call
29+
// that init(), not iterate the inner domains itself.
30+
const inner = new InMemoryStore({ id: 'inner' });
31+
const innerInitSpy = vi.spyOn(inner, 'init');
32+
33+
const composite = new MastraCompositeStore({
34+
id: 'outer',
35+
default: inner,
36+
});
37+
38+
await composite.init();
39+
40+
expect(innerInitSpy).toHaveBeenCalledTimes(1);
41+
});
42+
43+
it('delegates init() to the underlying `editor` store', async () => {
44+
const inner = new InMemoryStore({ id: 'editor-inner' });
45+
const innerInitSpy = vi.spyOn(inner, 'init');
46+
47+
const composite = new MastraCompositeStore({
48+
id: 'outer-editor',
49+
editor: inner,
50+
});
51+
52+
await composite.init();
53+
54+
expect(innerInitSpy).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it('delegates to both default and editor when both are provided', async () => {
58+
const defaultStore = new InMemoryStore({ id: 'default-inner' });
59+
const editorStore = new InMemoryStore({ id: 'editor-inner' });
60+
const defaultInitSpy = vi.spyOn(defaultStore, 'init');
61+
const editorInitSpy = vi.spyOn(editorStore, 'init');
62+
63+
const composite = new MastraCompositeStore({
64+
id: 'outer-both',
65+
default: defaultStore,
66+
editor: editorStore,
67+
});
68+
69+
await composite.init();
70+
71+
expect(defaultInitSpy).toHaveBeenCalledTimes(1);
72+
expect(editorInitSpy).toHaveBeenCalledTimes(1);
73+
});
74+
75+
it('only init()s a shared parent once when used as both default and editor', async () => {
76+
// Defensive: if the same instance is passed as both `default` and
77+
// `editor`, dedupe by identity so we don't double-init it.
78+
const shared = new InMemoryStore({ id: 'shared-inner' });
79+
const sharedInitSpy = vi.spyOn(shared, 'init');
80+
81+
const composite = new MastraCompositeStore({
82+
id: 'outer-shared',
83+
default: shared,
84+
editor: shared,
85+
});
86+
87+
await composite.init();
88+
89+
expect(sharedInitSpy).toHaveBeenCalledTimes(1);
90+
});
91+
92+
it("treats the inner store's init() as authoritative (failure surfaces)", async () => {
93+
// If the composite bypasses the inner's init(), a thrown error from the
94+
// inner's init() would never surface. We must see it.
95+
const inner = new InMemoryStore({ id: 'failing-inner' });
96+
const failure = new Error('inner init failed');
97+
vi.spyOn(inner, 'init').mockRejectedValueOnce(failure);
98+
99+
const composite = new MastraCompositeStore({
100+
id: 'outer-failing',
101+
default: inner,
102+
});
103+
104+
await expect(composite.init()).rejects.toThrow('inner init failed');
105+
});
106+
});

packages/core/src/storage/base.ts

Lines changed: 82 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,16 @@ export class MastraCompositeStore extends MastraBase {
237237
*/
238238
disableInit: boolean = false;
239239

240+
/**
241+
* Retained references to the parent stores supplied via composition. `init()`
242+
* delegates to these so the parent's own `init()` logic (pragmas, ordered
243+
* DDL, init coalescing, etc.) runs instead of being bypassed by the
244+
* composite iterating the inner domains in parallel — which was the cause
245+
* of the SQLITE_BUSY / "no such table" races reported in issue #16782.
246+
*/
247+
protected parentDefault?: MastraCompositeStore;
248+
protected parentEditor?: MastraCompositeStore;
249+
240250
constructor(config: MastraCompositeStoreConfig) {
241251
const name = config.name ?? 'MastraCompositeStore';
242252

@@ -258,6 +268,11 @@ export class MastraCompositeStore extends MastraBase {
258268
const editorStores = config.editor?.stores;
259269
const domainOverrides = config.domains ?? {};
260270

271+
// Retain the parent store refs so init() can delegate to their own
272+
// init() — see field doc above and init() below.
273+
this.parentDefault = config.default;
274+
this.parentEditor = config.editor;
275+
261276
// Validate that at least one storage source is provided
262277
const hasDefaultDomains = defaultStores && Object.values(defaultStores).some(v => v !== undefined);
263278
const hasEditorDomains = editorStores && Object.values(editorStores).some(v => v !== undefined);
@@ -323,91 +338,84 @@ export class MastraCompositeStore extends MastraBase {
323338

324339
/**
325340
* Initialize all domain stores.
326-
* This creates necessary tables, indexes, and performs any required migrations.
341+
*
342+
* When a parent store was supplied via `default` or `editor`, delegate to
343+
* its own `init()` first. Each adapter owns its `init()` contract — it may
344+
* apply connection-level setup, run migrations, enforce DDL ordering, or
345+
* coalesce concurrent callers. Calling each domain's `init()` directly
346+
* against the parent's shared client would bypass all of that and can
347+
* corrupt or partially create schema (see issue #16782 for the SQLite
348+
* symptom).
349+
*
350+
* Any remaining domains that did NOT come from a parent (e.g. supplied via
351+
* the explicit `domains` override pointing at a different store) are then
352+
* initialized individually — but only the ones the parents didn't already
353+
* cover, so we never double-init the same domain instance.
327354
*/
328355
async init(): Promise<void> {
329356
// to prevent race conditions, await any current init
330357
if (this.shouldCacheInit && (await this.hasInitialized)) {
331358
return;
332359
}
333360

334-
// Initialize all domain stores
335-
const initTasks: Promise<void>[] = [];
336-
337-
if (this.stores?.memory) {
338-
initTasks.push(this.stores.memory.init());
339-
}
340-
341-
if (this.stores?.workflows) {
342-
initTasks.push(this.stores.workflows.init());
343-
}
344-
345-
if (this.stores?.scores) {
346-
initTasks.push(this.stores.scores.init());
347-
}
348-
349-
if (this.stores?.observability) {
350-
initTasks.push(this.stores.observability.init());
351-
}
352-
353-
if (this.stores?.agents) {
354-
initTasks.push(this.stores.agents.init());
355-
}
356-
357-
if (this.stores?.datasets) {
358-
initTasks.push(this.stores.datasets.init());
359-
}
360-
361-
if (this.stores?.experiments) {
362-
initTasks.push(this.stores.experiments.init());
363-
}
364-
365-
if (this.stores?.promptBlocks) {
366-
initTasks.push(this.stores.promptBlocks.init());
367-
}
368-
369-
if (this.stores?.scorerDefinitions) {
370-
initTasks.push(this.stores.scorerDefinitions.init());
371-
}
372-
373-
if (this.stores?.mcpClients) {
374-
initTasks.push(this.stores.mcpClients.init());
375-
}
376-
377-
if (this.stores?.mcpServers) {
378-
initTasks.push(this.stores.mcpServers.init());
379-
}
380-
381-
if (this.stores?.workspaces) {
382-
initTasks.push(this.stores.workspaces.init());
383-
}
384-
385-
if (this.stores?.skills) {
386-
initTasks.push(this.stores.skills.init());
387-
}
388-
389-
if (this.stores?.favorites) {
390-
initTasks.push(this.stores.favorites.init());
391-
}
392-
393-
if (this.stores?.blobs) {
394-
initTasks.push(this.stores.blobs.init());
395-
}
396-
397-
if (this.stores?.backgroundTasks) {
398-
initTasks.push(this.stores.backgroundTasks.init());
399-
}
361+
this.hasInitialized = this.#runInit();
362+
await this.hasInitialized;
363+
}
400364

401-
if (this.stores?.schedules) {
402-
initTasks.push(this.stores.schedules.init());
403-
}
365+
async #runInit(): Promise<boolean> {
366+
// 1. Delegate to parent stores. Each parent owns its own init contract
367+
// (setup, migrations, sequencing, coalescing). Dedupe by identity so
368+
// a store passed as both `default` and `editor` only gets init()'d once.
369+
const uniqueParents = new Set<MastraCompositeStore>();
370+
if (this.parentDefault) uniqueParents.add(this.parentDefault);
371+
if (this.parentEditor) uniqueParents.add(this.parentEditor);
372+
await Promise.all([...uniqueParents].map(parent => parent.init()));
373+
374+
// 2. Build a set of domain instances the parents already initialized so
375+
// we don't init them a second time below.
376+
const alreadyInitialized = new Set<unknown>();
377+
const addParentDomains = (parent?: MastraCompositeStore) => {
378+
if (!parent?.stores) return;
379+
for (const domain of Object.values(parent.stores)) {
380+
if (domain) alreadyInitialized.add(domain);
381+
}
382+
};
383+
addParentDomains(this.parentDefault);
384+
addParentDomains(this.parentEditor);
404385

405-
if (this.stores?.channels) {
406-
initTasks.push(this.stores.channels.init());
386+
// 3. Init any remaining domains (typically those provided via the
387+
// explicit `domains` override pointing at a different store, or those
388+
// set directly by a subclass).
389+
const initTasks: Promise<void>[] = [];
390+
const maybeInit = (domain: { init(): Promise<void> } | undefined) => {
391+
if (!domain || alreadyInitialized.has(domain)) return;
392+
initTasks.push(domain.init());
393+
alreadyInitialized.add(domain);
394+
};
395+
396+
if (this.stores) {
397+
maybeInit(this.stores.memory);
398+
maybeInit(this.stores.workflows);
399+
maybeInit(this.stores.scores);
400+
maybeInit(this.stores.observability);
401+
maybeInit(this.stores.agents);
402+
maybeInit(this.stores.datasets);
403+
maybeInit(this.stores.experiments);
404+
maybeInit(this.stores.promptBlocks);
405+
maybeInit(this.stores.scorerDefinitions);
406+
maybeInit(this.stores.mcpClients);
407+
maybeInit(this.stores.mcpServers);
408+
maybeInit(this.stores.workspaces);
409+
maybeInit(this.stores.skills);
410+
maybeInit(this.stores.favorites);
411+
maybeInit(this.stores.blobs);
412+
maybeInit(this.stores.backgroundTasks);
413+
maybeInit(this.stores.schedules);
414+
maybeInit(this.stores.channels);
407415
}
408416

409-
this.hasInitialized = Promise.all(initTasks).then(() => true);
410-
await this.hasInitialized;
417+
await Promise.all(initTasks);
418+
return true;
411419
}
412420
}
413421

0 commit comments

Comments
 (0)