Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/correlation-id-run-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@workflow/world': major
'@workflow/world-local': major
'@workflow/world-postgres': major
'@workflow/world-vercel': major
'@workflow/web': minor
---

**Breaking:** `events.listByCorrelationId` and `analytics.events.listByCorrelationId` now require a `runId`. A correlation id is unique within its run, not across runs, so an unscoped lookup answered with one event per run that numbered a step or wait the same.
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Run-scoped listings mirroring their [Storage](/docs/api-reference/workflow-runti
```typescript lineNumbers
const steps = await world.analytics.steps.list({ runId });
const events = await world.analytics.events.list({ runId, eventType: "step_failed" });
const related = await world.analytics.events.listByCorrelationId({ correlationId });
const related = await world.analytics.events.listByCorrelationId({ runId, correlationId });
const hooks = await world.analytics.hooks.list({ runId });
const waits = await world.analytics.waits.list({ runId, status: "waiting" });
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,20 @@ const result = await world.events.list({ runId, pagination: { cursor } }); // [!

### events.listByCorrelationId()

List events that share a correlation ID, useful for tracing related events across runs.
List one run's events that share a correlation ID, useful for tracing a single step, hook or wait through its lifecycle.

A correlation ID is unique within its run, not across runs: two runs can each hold a `step_…`, `hook_…` or `wait_…` ID that reads the same. `runId` is therefore required, and it is also what makes the pagination cursor unambiguous.

```typescript lineNumbers
const result = await world.events.listByCorrelationId({ // [!code highlight]
runId,
correlationId: "order-123",
}); // [!code highlight]
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `params.runId` | `string` | The run the correlation ID belongs to |
| `params.correlationId` | `string` | The correlation ID to filter by |
| `params.pagination.cursor` | `string` | Cursor for the next page |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export function useEventsListData(
sortOrder,
limit: 100,
withData: false,
runId,
})
);
if (fetchError) {
Expand Down
2 changes: 2 additions & 0 deletions packages/web/app/lib/rpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ export async function fetchEventsByCorrelationId(
sortOrder?: 'asc' | 'desc';
limit?: number;
withData?: boolean;
/** The run the correlation id belongs to; it is unique per run, not globally. */
runId: string;
}
): Promise<ServerActionResult<PaginatedResult<Event>>> {
return rpc('fetchEventsByCorrelationId', {
Expand Down
16 changes: 15 additions & 1 deletion packages/web/app/server/workflow-server-actions.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,9 +896,21 @@ export async function fetchEventsByCorrelationId(
sortOrder?: 'asc' | 'desc';
limit?: number;
withData?: boolean;
/**
* The run the correlation id belongs to. A correlation id is unique per
* run, not globally — a slot-numbered run numbers its own steps — so the
* search is always made from a run's page and names it.
*/
runId: string;
}
): Promise<ServerActionResult<PaginatedResult<Event>>> {
const { cursor, sortOrder = 'asc', limit = 100, withData = false } = params;
const {
cursor,
sortOrder = 'asc',
limit = 100,
withData = false,
runId,
} = params;
try {
const world = await getWorldFromEnv(worldEnv);
// Prefer the metadata-only analytics read path when the backend provides one
Expand All @@ -908,6 +920,7 @@ export async function fetchEventsByCorrelationId(
if (world.analytics && !withData) {
const result = await world.analytics.events.listByCorrelationId({
correlationId,
runId,
pagination: { cursor, limit, sortOrder },
});
return createResponse({
Expand All @@ -919,6 +932,7 @@ export async function fetchEventsByCorrelationId(
}
const result = await world.events.listByCorrelationId({
correlationId,
runId,
pagination: { cursor, limit, sortOrder },
resolveData: withData ? 'all' : 'none',
});
Expand Down
41 changes: 33 additions & 8 deletions packages/world-local/src/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,7 @@ describe('Storage', () => {

const events = await storage.events.listByCorrelationId({
correlationId: 'lazy_step_2',
runId: testRunId,
});
const types = events.data.map((e) => e.eventType);
// Both a step_created (synthetic) and a step_started must be present:
Expand Down Expand Up @@ -1654,6 +1655,7 @@ describe('Storage', () => {

const result = await storage.events.listByCorrelationId({
correlationId,
runId: testRunId,
pagination: {},
});

Expand All @@ -1668,7 +1670,11 @@ describe('Storage', () => {
expect(result.data[2].correlationId).toBe(correlationId);
});

it('should list events across multiple runs with same correlation ID', async () => {
it('returns only the named run when two runs share a correlation ID', async () => {
// A correlation id names a hook, step or wait within its run. Two runs
// can hold the same one — a slot-numbered run counts its own steps, so
// `step_…001` is the first step of every such run — and the query
// answers for the run it was given, not for both.
const correlationId = 'hook-xyz789';

// Create another run
Expand Down Expand Up @@ -1705,16 +1711,27 @@ describe('Storage', () => {

const result = await storage.events.listByCorrelationId({
correlationId,
runId: testRunId,
pagination: {},
});

expect(result.data).toHaveLength(3);
expect(result.data[0].eventId).toBe(event1.eventId);
expect(result.data[0].runId).toBe(testRunId);
expect(result.data[1].eventId).toBe(event2.eventId);
expect(result.data[1].runId).toBe(run2.runId);
expect(result.data[2].eventId).toBe(event3.eventId);
expect(result.data[2].runId).toBe(testRunId);
expect(result.data.map((event) => event.eventId)).toEqual([
event1.eventId,
event3.eventId,
]);
expect(result.data.every((event) => event.runId === testRunId)).toBe(
true
);

// The other run's event is not lost, it belongs to the other run.
const other = await storage.events.listByCorrelationId({
correlationId,
runId: run2.runId,
pagination: {},
});
expect(other.data.map((event) => event.eventId)).toEqual([
event2.eventId,
]);
});

it('should return empty list for non-existent correlation ID', async () => {
Expand All @@ -1732,6 +1749,7 @@ describe('Storage', () => {

const result = await storage.events.listByCorrelationId({
correlationId: 'non-existent-correlation-id',
runId: testRunId,
pagination: {},
});

Expand Down Expand Up @@ -1782,6 +1800,7 @@ describe('Storage', () => {
// Get first page (step_created + step_started = 2)
const page1 = await storage.events.listByCorrelationId({
correlationId,
runId: testRunId,
pagination: { limit: 2 },
});

Expand All @@ -1792,6 +1811,7 @@ describe('Storage', () => {
// Get second page (step_retrying + step_started + step_completed = 3)
const page2 = await storage.events.listByCorrelationId({
correlationId,
runId: testRunId,
pagination: { limit: 3, cursor: page1.cursor || undefined },
});

Expand All @@ -1817,6 +1837,7 @@ describe('Storage', () => {

const result = await storage.events.listByCorrelationId({
correlationId,
runId: testRunId,
pagination: {},
resolveData: 'none',
});
Expand Down Expand Up @@ -1861,6 +1882,7 @@ describe('Storage', () => {

const result = await storage.events.listByCorrelationId({
correlationId,
runId: testRunId,
pagination: {},
});

Expand Down Expand Up @@ -1902,6 +1924,7 @@ describe('Storage', () => {

const result = await storage.events.listByCorrelationId({
correlationId,
runId: testRunId,
pagination: { sortOrder: 'desc' },
});

Expand Down Expand Up @@ -1951,6 +1974,7 @@ describe('Storage', () => {

const result = await storage.events.listByCorrelationId({
correlationId: hookId,
runId: testRunId,
pagination: {},
});

Expand Down Expand Up @@ -2039,6 +2063,7 @@ describe('Storage', () => {

const events = await storage.events.listByCorrelationId({
correlationId: stepId,
runId: testRunId,
pagination: {},
});

Expand Down
7 changes: 6 additions & 1 deletion packages/world-local/src/storage/events-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2536,12 +2536,17 @@ export function createEventsStorage(
async listByCorrelationId(params) {
const correlationId = params.correlationId;
assertSafeEntityId('correlationId', correlationId);
assertSafeEntityId('runId', params.runId);
const resolveData = params.resolveData ?? DEFAULT_RESOLVE_DATA_OPTION;
const result = await paginatedFileSystemQuery({
directory: path.join(basedir, 'events'),
schema: EventSchema,
cachedItems: eventCache,
// No filePrefix - search all events
// Scoped to the run's own event files, since a correlation id
// identifies a step or wait only within its run: a slot-numbered
// `step_…001` names the first step of every such run, so an unscoped
// scan would answer with one event per run.
filePrefix: `${params.runId}-`,
filter: (event) => event.correlationId === correlationId,
// Events in chronological order (oldest first) by default,
// different from the default for other list calls.
Expand Down
5 changes: 5 additions & 0 deletions packages/world-postgres/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1916,6 +1916,11 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] {
.where(
and(
eq(events.correlationId, params.correlationId),
// A correlation id names a step or wait within its run, so an
// unscoped query matches one event per run that allocated the same
// id — and the cursor, an event id, cannot tell two such rows
// apart. Scoped, `(run_id, id)` is the primary key, so it can.
eq(events.runId, params.runId),
map(params.pagination?.cursor, (c) =>
order.compare(events.eventId, c)
)
Expand Down
Loading
Loading