Skip to content

Commit 18c3a90

Browse files
YujohnNattrassYJMastra Code (anthropic/claude-opus-4-6)
authored
fix: prevent experiments from getting stuck in pending on empty datasets (#14031)
Experiments triggered on datasets with zero items would silently fail, leaving the experiment record stuck in "pending" status forever. The fire-and-forget `.catch(() => {})` swallowed the error from `runExperiment()` before it could update the status. Three-layer fix: **Core** — `startExperimentAsync` now validates item count before creating the experiment record. Throws `EXPERIMENT_NO_ITEMS` on empty datasets. The catch handler marks experiments as `failed` and logs the error instead of silently discarding. **Server** — Maps `EXPERIMENT_NO_ITEMS` to HTTP 400. Returns actual `totalItems` from experiment instead of hardcoded `0`. **UI** — Disables the Run Experiment button with a tooltip when dataset has no items. Also added test coverage for the empty dataset paths in both `dataset.test.ts` and `runExperiment.test.ts`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Triggering an experiment on an empty dataset now errors immediately (HTTP 400) instead of staying pending. * Setup failures for async experiments are logged and mark the experiment as failed. * Experiment responses now report an accurate totalItems count. * **New Features** * Run Experiment button is disabled for empty datasets with a tooltip: "Add items to the dataset before running an experiment". * UI checks the unfiltered dataset so searches won't incorrectly disable the trigger. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: YJ <yj@example.com> Co-authored-by: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai>
1 parent 05f8d90 commit 18c3a90

9 files changed

Lines changed: 345 additions & 89 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@mastra/core': patch
3+
'@mastra/server': patch
4+
---
5+
6+
Experiments now fail immediately with a clear error when triggered on a dataset with zero items, instead of getting stuck in "pending" status forever. The experiment trigger API returns HTTP 400 for empty datasets. Unexpected errors during async experiment setup are now logged and mark the experiment as failed.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/playground-ui': patch
3+
---
4+
5+
The "Run Experiment" button is now disabled when a dataset has no items, with a tooltip explaining that items must be added first.

packages/core/src/datasets/__tests__/dataset.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ describe('Dataset', () => {
7070
getScorerById: vi.fn(),
7171
getWorkflowById: vi.fn(),
7272
getWorkflow: vi.fn(),
73+
getLogger: vi.fn().mockReturnValue({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }),
7374
} as unknown as Mastra;
7475

7576
// Create a dataset for tests that need one
@@ -306,6 +307,98 @@ describe('Dataset', () => {
306307
await new Promise(r => setTimeout(r, 500));
307308
});
308309

310+
it('startExperimentAsync throws EXPERIMENT_NO_ITEMS on empty dataset', async () => {
311+
await expect(
312+
ds.startExperimentAsync({
313+
task: async () => 'ok',
314+
scorers: [],
315+
}),
316+
).rejects.toThrow('has no items');
317+
318+
try {
319+
await ds.startExperimentAsync({ task: async () => 'ok', scorers: [] });
320+
} catch (err) {
321+
expect(err).toBeInstanceOf(MastraError);
322+
expect((err as MastraError).id).toBe('EXPERIMENT_NO_ITEMS');
323+
}
324+
325+
// Verify no experiment record was created
326+
const { experiments } = await ds.listExperiments();
327+
expect(experiments).toHaveLength(0);
328+
});
329+
330+
it('startExperimentAsync returns totalItems matching dataset item count', async () => {
331+
await ds.addItem({ input: { prompt: 'Hello' } });
332+
await ds.addItem({ input: { prompt: 'World' } });
333+
334+
const result = await ds.startExperimentAsync({
335+
task: async () => 'ok',
336+
scorers: [],
337+
});
338+
339+
expect(result.totalItems).toBe(2);
340+
341+
// Wait for fire-and-forget to complete
342+
await new Promise(r => setTimeout(r, 500));
343+
});
344+
345+
it('startExperimentAsync records the resolved version in experiment record', async () => {
346+
// Add two items → dataset version becomes 2
347+
await ds.addItem({ input: { prompt: 'A' } });
348+
await ds.addItem({ input: { prompt: 'B' } });
349+
350+
// Run experiment pinned to version 1 (only first item visible)
351+
const result = await ds.startExperimentAsync({
352+
task: async () => 'ok',
353+
scorers: [],
354+
version: 1,
355+
});
356+
357+
const experiment = await experimentsStorage.getExperimentById({ id: result.experimentId });
358+
expect(experiment).not.toBeNull();
359+
expect(experiment!.datasetVersion).toBe(1);
360+
expect(result.totalItems).toBe(1);
361+
362+
// Wait for fire-and-forget to complete
363+
await new Promise(r => setTimeout(r, 500));
364+
});
365+
366+
// 23b. startExperimentAsync — throws on empty dataset
367+
it('startExperimentAsync throws EXPERIMENT_NO_ITEMS when dataset has no items', async () => {
368+
// Dataset has no items — do NOT add any
369+
try {
370+
await ds.startExperimentAsync({
371+
task: async () => 'ok',
372+
scorers: [],
373+
});
374+
expect.fail('should have thrown');
375+
} catch (err) {
376+
expect(err).toBeInstanceOf(MastraError);
377+
expect((err as MastraError).id).toBe('EXPERIMENT_NO_ITEMS');
378+
}
379+
380+
// Verify no experiment record was created
381+
const result = await experimentsStorage.listExperiments({
382+
datasetId,
383+
pagination: { page: 0, perPage: 10 },
384+
});
385+
expect(result.experiments.length).toBe(0);
386+
});
387+
388+
// 23c. startExperiment — throws on empty dataset (sync path)
389+
it('startExperiment throws on empty dataset', async () => {
390+
try {
391+
await ds.startExperiment({
392+
task: async () => 'ok',
393+
scorers: [],
394+
});
395+
expect.fail('should have thrown');
396+
} catch (err) {
397+
expect(err).toBeInstanceOf(MastraError);
398+
expect((err as MastraError).id).toBe('EXPERIMENT_NO_ITEMS');
399+
}
400+
});
401+
309402
// 24. listExperiments
310403
it('listExperiments returns runs for this dataset', async () => {
311404
await ds.addItem({ input: { prompt: 'Hello' } });

packages/core/src/datasets/dataset.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ export class Dataset {
289289
*/
290290
async startExperimentAsync<I = unknown, O = unknown, E = unknown>(
291291
config: StartExperimentConfig<I, O, E>,
292-
): Promise<{ experimentId: string; status: 'pending' }> {
292+
): Promise<{ experimentId: string; status: 'pending'; totalItems: number }> {
293293
const experimentsStore = await this.#getExperimentsStore();
294294
const datasetsStore = await this.#getDatasetsStore();
295295

@@ -303,27 +303,51 @@ export class Dataset {
303303
});
304304
}
305305

306+
// Validate that dataset has items before creating experiment record
307+
const targetVersion = config.version ?? dataset.version;
308+
const items = await datasetsStore.getItemsByVersion({
309+
datasetId: this.id,
310+
version: targetVersion,
311+
});
312+
if (items.length === 0) {
313+
throw new MastraError({
314+
id: 'EXPERIMENT_NO_ITEMS',
315+
text: `Cannot run experiment: dataset "${this.id}" has no items at version ${targetVersion}`,
316+
domain: 'STORAGE',
317+
category: 'USER',
318+
});
319+
}
320+
306321
const run = await experimentsStore.createExperiment({
307322
datasetId: this.id,
308-
datasetVersion: dataset.version,
323+
datasetVersion: targetVersion,
309324
targetType: config.targetType ?? 'agent',
310325
targetId: config.targetId ?? 'inline',
311-
totalItems: 0,
326+
totalItems: items.length,
312327
name: config.name,
313328
description: config.description,
314329
metadata: config.metadata,
315330
});
316331

317332
const experimentId = run.id;
318333

319-
// Fire-and-forget — errors are silently caught
334+
// Fire-and-forget — update experiment to failed on unexpected errors
320335
void runExperiment(this.#mastra, {
321336
datasetId: this.id,
322337
experimentId,
323338
...config,
324-
} as ExperimentConfig).catch(() => {});
339+
} as ExperimentConfig).catch(async err => {
340+
await experimentsStore
341+
.updateExperiment({
342+
id: experimentId,
343+
status: 'failed',
344+
completedAt: new Date(),
345+
})
346+
.catch(() => {});
347+
this.#mastra.getLogger()?.error(`Experiment ${experimentId} failed: ${err?.message ?? err}`);
348+
});
325349

326-
return { experimentId, status: 'pending' as const };
350+
return { experimentId, status: 'pending' as const, totalItems: items.length };
327351
}
328352

329353
/**

packages/core/src/datasets/experiment/__tests__/runExperiment.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,4 +673,54 @@ describe('runExperiment', () => {
673673
);
674674
});
675675
});
676+
677+
describe('empty dataset handling', () => {
678+
it('marks pre-created experiment as failed when dataset has no items', async () => {
679+
// Create an empty dataset
680+
const emptyDs = await datasetsStorage.createDataset({ name: 'Empty DS' });
681+
682+
// Pre-create experiment record (simulates async trigger path)
683+
const experiment = await experimentsStorage.createExperiment({
684+
datasetId: emptyDs.id,
685+
datasetVersion: emptyDs.version,
686+
targetType: 'agent',
687+
targetId: 'test-agent',
688+
totalItems: 0,
689+
});
690+
691+
// Run experiment with pre-created ID — should throw and mark as failed
692+
await expect(
693+
runExperiment(mastra, {
694+
datasetId: emptyDs.id,
695+
experimentId: experiment.id,
696+
targetType: 'agent',
697+
targetId: 'test-agent',
698+
}),
699+
).rejects.toThrow('No items in dataset');
700+
701+
// Verify experiment was marked as failed (not stuck in pending)
702+
const updated = await experimentsStorage.getExperimentById({ id: experiment.id });
703+
expect(updated?.status).toBe('failed');
704+
expect(updated?.completedAt).toBeDefined();
705+
});
706+
707+
it('throws without creating experiment record when no pre-created ID', async () => {
708+
const emptyDs = await datasetsStorage.createDataset({ name: 'Empty DS 2' });
709+
710+
await expect(
711+
runExperiment(mastra, {
712+
datasetId: emptyDs.id,
713+
targetType: 'agent',
714+
targetId: 'test-agent',
715+
}),
716+
).rejects.toThrow('No items in dataset');
717+
718+
// No experiment record should exist for this dataset
719+
const result = await experimentsStorage.listExperiments({
720+
datasetId: emptyDs.id,
721+
pagination: { page: 0, perPage: 10 },
722+
});
723+
expect(result.experiments.length).toBe(0);
724+
});
725+
});
676726
});

0 commit comments

Comments
 (0)