diff --git a/CLAUDE.md b/CLAUDE.md index 7a0756ef..c5feddb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ When API changes are made, update `packages/durably/docs/llms.md` to keep it in ## Core Concepts - **Job**: Defined via `defineJob()` and registered via `jobs` option (or `.register()`), receives a step context and payload -- **Step**: Created via `step.run()`, each step's success state and return value is persisted +- **Step**: Created via `step.run()`, each step's success state and return value is persisted (cleaned up on terminal state by default, see `cleanupSteps`) - **Run**: A job execution instance, created via `trigger()`, always persisted as `pending` before execution - **Worker**: Polls for pending runs and executes them sequentially @@ -28,7 +28,7 @@ When API changes are made, update `packages/durably/docs/llms.md` to keep it in - **ESM-only**: This library is ESM-only. CommonJS is not supported. Always use top-level `await` for async initialization (e.g., `await durably.migrate()`). Do not wrap in async IIFE or Promise chains. - Single-threaded execution, no parallel run processing in minimal config -- No automatic retry - failures are immediate and explicit (`retry()` API for manual retry) +- No automatic retry - failures are immediate and explicit (`retrigger()` creates a fresh run with a new ID and returns it) - Dialect injection pattern - Kysely dialect passed to `createDurably()` to abstract SQLite implementations - Event system for extensibility (`run:start`, `run:complete`, `run:fail`, `step:*`, `log:write`) @@ -44,6 +44,7 @@ Four tables: `durably_runs`, `durably_steps`, `durably_logs`, `durably_schema_ve - `pollingInterval`: 1000ms - `heartbeatInterval`: 5000ms - `staleThreshold`: 30000ms (for detecting abandoned runs) +- `cleanupSteps`: true (deletes step output data when runs reach terminal state) ## Browser Constraints (by design) diff --git a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx index ed8dcbe9..dbe8c7c6 100644 --- a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx +++ b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx @@ -50,7 +50,7 @@ export function Dashboard() { const { cancel, - retry, + retrigger, deleteRun, getRun, getSteps, @@ -65,8 +65,8 @@ export function Dashboard() { refresh() } - const handleRetry = async (runId: string) => { - await retry(runId) + const handleRetrigger = async (runId: string) => { + await retrigger(runId) refresh() } @@ -211,11 +211,11 @@ export function Dashboard() { run.status === 'cancelled') && ( )} {(run.status === 'running' || diff --git a/examples/fullstack-react-router/app/routes/api.durably.$.ts b/examples/fullstack-react-router/app/routes/api.durably.$.ts index daa1494b..a060d54a 100644 --- a/examples/fullstack-react-router/app/routes/api.durably.$.ts +++ b/examples/fullstack-react-router/app/routes/api.durably.$.ts @@ -6,7 +6,7 @@ * GET /api/durably/runs - List runs * GET /api/durably/run?runId=xxx - Get single run * POST /api/durably/trigger - Trigger a job - * POST /api/durably/retry?runId=xxx - Retry a failed run + * POST /api/durably/retrigger?runId=xxx - Create a fresh run from a terminal run * POST /api/durably/cancel?runId=xxx - Cancel a run */ diff --git a/examples/spa-react-router/README.md b/examples/spa-react-router/README.md index df518cb3..6e2766c0 100644 --- a/examples/spa-react-router/README.md +++ b/examples/spa-react-router/README.md @@ -8,7 +8,7 @@ This example demonstrates Durably running entirely in the browser using React Ro - **SQLite WASM with OPFS** - Persistent storage in the browser - **DurablyProvider** - React context for lifecycle management - **Multiple jobs** - Image processing and data sync examples -- **Run history dashboard** - View, retry, cancel, and delete runs +- **Run history dashboard** - View, retrigger, cancel, and delete runs - **Tailwind CSS** - Modern styling ## Getting Started @@ -68,4 +68,4 @@ app/ 1. Run a job and observe the progress 2. Reload the page during execution - it resumes automatically 3. Check the dashboard for run history -4. Try retry/cancel/delete actions +4. Try retrigger/cancel/delete actions diff --git a/examples/spa-react-router/app/routes/_index/dashboard.tsx b/examples/spa-react-router/app/routes/_index/dashboard.tsx index 7a965711..f07d666d 100644 --- a/examples/spa-react-router/app/routes/_index/dashboard.tsx +++ b/examples/spa-react-router/app/routes/_index/dashboard.tsx @@ -65,9 +65,9 @@ export function Dashboard() { } } - const handleRetry = async (runId: string) => { + const handleRetrigger = async (runId: string) => { if (!durably) return - await durably.retry(runId) + await durably.retrigger(runId) refresh() } @@ -208,10 +208,10 @@ export function Dashboard() { run.status === 'cancelled') && ( )} {(run.status === 'running' || diff --git a/examples/spa-vite-react/src/components/dashboard.tsx b/examples/spa-vite-react/src/components/dashboard.tsx index 2298ba53..1c56b553 100644 --- a/examples/spa-vite-react/src/components/dashboard.tsx +++ b/examples/spa-vite-react/src/components/dashboard.tsx @@ -62,9 +62,9 @@ export function Dashboard() { } } - const handleRetry = async (runId: string) => { + const handleRetrigger = async (runId: string) => { if (!durably) return - await durably.retry(runId) + await durably.retrigger(runId) refresh() } @@ -205,10 +205,10 @@ export function Dashboard() { run.status === 'cancelled') && ( )} {(run.status === 'running' || diff --git a/packages/durably-react/docs/llms.md b/packages/durably-react/docs/llms.md index 1cbdc0f3..fe458468 100644 --- a/packages/durably-react/docs/llms.md +++ b/packages/durably-react/docs/llms.md @@ -83,7 +83,7 @@ function LogViewer({ runId }: { runId: string }) { // Cross-job hooks (built into the proxy) function Dashboard() { const { runs } = durably.useRuns({ pageSize: 10 }) - const { retry, cancel } = durably.useRunActions() + const { retrigger, cancel } = durably.useRunActions() } ``` @@ -257,13 +257,13 @@ function FilteredDashboard() { ### Fullstack useRunActions -Actions for runs (retry, cancel, delete): +Actions for runs (retrigger, cancel, delete): ```tsx import { useRunActions } from '@coji/durably-react' function RunActions({ runId, status }: { runId: string; status: string }) { - const { retry, cancel, deleteRun, getRun, getSteps, isLoading, error } = + const { retrigger, cancel, deleteRun, getRun, getSteps, isLoading, error } = useRunActions({ api: '/api/durably', }) @@ -271,8 +271,8 @@ function RunActions({ runId, status }: { runId: string; status: string }) { return (
{(status === 'failed' || status === 'cancelled') && ( - )} {(status === 'pending' || status === 'running') && ( diff --git a/packages/durably-react/src/client/create-durably.ts b/packages/durably-react/src/client/create-durably.ts index 592f5179..a618c59d 100644 --- a/packages/durably-react/src/client/create-durably.ts +++ b/packages/durably-react/src/client/create-durably.ts @@ -49,7 +49,7 @@ export type DurablyClient = { ) => UseRunsClientResult /** - * Run actions: retry, cancel, delete, getRun, getSteps (cross-job). + * Run actions: retrigger, cancel, delete, getRun, getSteps (cross-job). * The `api` option is pre-configured. */ useRunActions: () => UseRunActionsClientResult @@ -89,7 +89,7 @@ export type DurablyClient = { * // Cross-job hooks * function Dashboard() { * const { runs, nextPage } = durably.useRuns({ pageSize: 10 }) - * const { retry, cancel } = durably.useRunActions() + * const { retrigger, cancel } = durably.useRunActions() * } * ``` */ diff --git a/packages/durably-react/src/client/use-run-actions.ts b/packages/durably-react/src/client/use-run-actions.ts index 03f06ef4..7a4d2ce5 100644 --- a/packages/durably-react/src/client/use-run-actions.ts +++ b/packages/durably-react/src/client/use-run-actions.ts @@ -19,9 +19,9 @@ export interface UseRunActionsClientOptions { export interface UseRunActionsClientResult { /** - * Retry a failed or cancelled run + * Create a fresh run from a completed, failed, or cancelled run */ - retry: (runId: string) => Promise + retrigger: (runId: string) => Promise /** * Cancel a pending or running run */ @@ -49,20 +49,20 @@ export interface UseRunActionsClientResult { } /** - * Hook for run actions (retry, cancel) via server API. + * Hook for run actions via server API. * * @example * ```tsx * function RunActions({ runId, status }: { runId: string; status: string }) { - * const { retry, cancel, isLoading, error } = useRunActions({ + * const { retrigger, cancel, isLoading, error } = useRunActions({ * api: '/api/durably', * }) * * return ( *
* {status === 'failed' && ( - * * )} * {(status === 'pending' || status === 'running') && ( @@ -84,17 +84,17 @@ export function useRunActions( const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) - const retry = useCallback( + const retrigger = useCallback( async (runId: string) => { setIsLoading(true) setError(null) try { - const url = `${api}/retry?runId=${encodeURIComponent(runId)}` + const url = `${api}/retrigger?runId=${encodeURIComponent(runId)}` const response = await fetch(url, { method: 'POST' }) if (!response.ok) { - let errorMessage = `Failed to retry: ${response.statusText}` + let errorMessage = `Failed to retrigger: ${response.statusText}` try { const data = await response.json() if (data.error) { @@ -105,6 +105,11 @@ export function useRunActions( } throw new Error(errorMessage) } + const data = (await response.json()) as { runId?: string } + if (!data.runId) { + throw new Error('Failed to retrigger: missing runId in response') + } + return data.runId } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' setError(message) @@ -253,7 +258,7 @@ export function useRunActions( ) return { - retry, + retrigger, cancel, deleteRun, getRun, diff --git a/packages/durably-react/src/client/use-runs.ts b/packages/durably-react/src/client/use-runs.ts index 587051ab..65687156 100644 --- a/packages/durably-react/src/client/use-runs.ts +++ b/packages/durably-react/src/client/use-runs.ts @@ -22,7 +22,6 @@ type RunUpdateEvent = | 'run:fail' | 'run:cancel' | 'run:delete' - | 'run:retry' runId: string jobName: string } @@ -319,8 +318,7 @@ export function useRuns< data.type === 'run:complete' || data.type === 'run:fail' || data.type === 'run:cancel' || - data.type === 'run:delete' || - data.type === 'run:retry' + data.type === 'run:delete' ) { refresh() } diff --git a/packages/durably-react/src/hooks/use-job-run.ts b/packages/durably-react/src/hooks/use-job-run.ts index 305dd59d..b24c0333 100644 --- a/packages/durably-react/src/hooks/use-job-run.ts +++ b/packages/durably-react/src/hooks/use-job-run.ts @@ -1,4 +1,3 @@ -import { useEffect, useRef } from 'react' import { useDurably } from '../context' import type { LogEntry, Progress, RunStatus } from '../types' import { useRunSubscription } from './use-run-subscription' @@ -68,30 +67,19 @@ export function useJobRun( const subscription = useRunSubscription(durably, runId) - // Fetch initial state when runId changes - const fetchedRef = useRef>(new Set()) - - useEffect(() => { - if (!durably || !runId || fetchedRef.current.has(runId)) return - - // Mark as fetched to avoid duplicate fetches - fetchedRef.current.add(runId) - - // Try to fetch current run state - // Note: We need to use internal APIs or polling here - // For now, we rely on event-based updates - }, [durably, runId]) + // If we have a runId but no status yet, treat as pending + const effectiveStatus = subscription.status ?? (runId ? 'pending' : null) return { - status: subscription.status, + status: effectiveStatus, output: subscription.output, error: subscription.error, logs: subscription.logs, progress: subscription.progress, - isRunning: subscription.status === 'running', - isPending: subscription.status === 'pending', - isCompleted: subscription.status === 'completed', - isFailed: subscription.status === 'failed', - isCancelled: subscription.status === 'cancelled', + isRunning: effectiveStatus === 'running', + isPending: effectiveStatus === 'pending', + isCompleted: effectiveStatus === 'completed', + isFailed: effectiveStatus === 'failed', + isCancelled: effectiveStatus === 'cancelled', } } diff --git a/packages/durably-react/src/hooks/use-job-subscription.ts b/packages/durably-react/src/hooks/use-job-subscription.ts index c4c008ae..0d41ad29 100644 --- a/packages/durably-react/src/hooks/use-job-subscription.ts +++ b/packages/durably-react/src/hooks/use-job-subscription.ts @@ -154,13 +154,6 @@ export function useJobSubscription( }), ) - unsubscribes.push( - durably.on('run:retry', (event) => { - if (event.runId !== currentRunIdRef.current) return - dispatch({ type: 'run:retry' }) - }), - ) - unsubscribes.push( durably.on('run:progress', (event) => { if (event.runId !== currentRunIdRef.current) return diff --git a/packages/durably-react/src/hooks/use-runs.ts b/packages/durably-react/src/hooks/use-runs.ts index 0b2b8f64..2c6f61bf 100644 --- a/packages/durably-react/src/hooks/use-runs.ts +++ b/packages/durably-react/src/hooks/use-runs.ts @@ -215,7 +215,6 @@ export function useRuns< durably.on('run:fail', refresh), durably.on('run:cancel', refresh), durably.on('run:delete', refresh), - durably.on('run:retry', refresh), durably.on('run:progress', refresh), durably.on('step:start', refresh), durably.on('step:complete', refresh), diff --git a/packages/durably-react/src/shared/durably-event-subscriber.ts b/packages/durably-react/src/shared/durably-event-subscriber.ts index 0f27d96e..4bbe7179 100644 --- a/packages/durably-react/src/shared/durably-event-subscriber.ts +++ b/packages/durably-react/src/shared/durably-event-subscriber.ts @@ -43,13 +43,6 @@ export function createDurablyEventSubscriber( }), ) - unsubscribes.push( - durably.on('run:retry', (event) => { - if (event.runId !== runId) return - onEvent({ type: 'run:retry' }) - }), - ) - unsubscribes.push( durably.on('run:progress', (event) => { if (event.runId !== runId) return diff --git a/packages/durably-react/src/shared/event-subscriber.ts b/packages/durably-react/src/shared/event-subscriber.ts index 16c35308..e1b2b190 100644 --- a/packages/durably-react/src/shared/event-subscriber.ts +++ b/packages/durably-react/src/shared/event-subscriber.ts @@ -9,7 +9,6 @@ export type SubscriptionEvent = | { type: 'run:complete'; output: TOutput } | { type: 'run:fail'; error: string } | { type: 'run:cancel' } - | { type: 'run:retry' } | { type: 'run:progress'; progress: Progress } | { type: 'log:write' diff --git a/packages/durably-react/src/shared/sse-event-subscriber.ts b/packages/durably-react/src/shared/sse-event-subscriber.ts index 51d88b1b..47849619 100644 --- a/packages/durably-react/src/shared/sse-event-subscriber.ts +++ b/packages/durably-react/src/shared/sse-event-subscriber.ts @@ -35,9 +35,6 @@ export function createSSEEventSubscriber(apiBaseUrl: string): EventSubscriber { case 'run:cancel': onEvent({ type: 'run:cancel' }) break - case 'run:retry': - onEvent({ type: 'run:retry' }) - break case 'run:progress': onEvent({ type: 'run:progress', progress: data.progress }) break diff --git a/packages/durably-react/src/shared/subscription-reducer.ts b/packages/durably-react/src/shared/subscription-reducer.ts index aed0de4e..b8df488f 100644 --- a/packages/durably-react/src/shared/subscription-reducer.ts +++ b/packages/durably-react/src/shared/subscription-reducer.ts @@ -7,7 +7,6 @@ export type SubscriptionAction = | { type: 'run:complete'; output: TOutput } | { type: 'run:fail'; error: string } | { type: 'run:cancel' } - | { type: 'run:retry' } | { type: 'run:progress'; progress: Progress } | { type: 'log:write' @@ -51,9 +50,6 @@ export function subscriptionReducer( case 'run:cancel': return { ...state, status: 'cancelled' } - case 'run:retry': - return { ...state, status: 'pending', error: null } - case 'run:progress': return { ...state, progress: action.progress } diff --git a/packages/durably-react/src/shared/use-subscription.ts b/packages/durably-react/src/shared/use-subscription.ts index 9efbf69b..e4f449c5 100644 --- a/packages/durably-react/src/shared/use-subscription.ts +++ b/packages/durably-react/src/shared/use-subscription.ts @@ -64,7 +64,6 @@ export function useSubscription( switch (event.type) { case 'run:start': case 'run:cancel': - case 'run:retry': dispatch({ type: event.type }) break case 'run:complete': diff --git a/packages/durably-react/src/types.ts b/packages/durably-react/src/types.ts index 7e1abc8d..f546dbb0 100644 --- a/packages/durably-react/src/types.ts +++ b/packages/durably-react/src/types.ts @@ -75,7 +75,6 @@ export type DurablyEvent = | { type: 'run:cancel'; runId: string; jobName: string } | { type: 'run:delete'; runId: string; jobName: string } | { type: 'run:trigger'; runId: string; jobName: string; input: unknown } - | { type: 'run:retry'; runId: string; jobName: string } | { type: 'run:progress' runId: string diff --git a/packages/durably-react/tests/browser/use-job-run.test.tsx b/packages/durably-react/tests/browser/use-job-run.test.tsx index 4bbeb6ff..818d4902 100644 --- a/packages/durably-react/tests/browser/use-job-run.test.tsx +++ b/packages/durably-react/tests/browser/use-job-run.test.tsx @@ -273,11 +273,11 @@ describe('useJobRun', () => { { timeout: 3000 }, ) - // Stop worker so retry doesn't immediately re-run + // Stop worker so retrigger doesn't immediately re-run await durably.stop() - // Retry the run - await durably.retry(run.id) + const nextRun = await durably.retrigger(run.id) + result.current.setRunId(nextRun.id) await waitFor( () => { @@ -289,14 +289,14 @@ describe('useJobRun', () => { ) }) - it('tracks retry from failed through completion', async () => { + it('tracks retrigger from failed through completion', async () => { const durably = await createTestDurably({ pollingInterval: 50 }) instances.push(durably) - // Job that fails first time, succeeds on retry (use attempt counter via steps) + // Job that fails first time, succeeds on retrigger let attemptCount = 0 - const retryableJob = defineJob({ - name: 'retryable-job', + const retriggerableJob = defineJob({ + name: 'retriggerable-job', input: z.object({ input: z.string() }), output: z.object({ result: z.string() }), run: async (context, payload) => { @@ -328,7 +328,7 @@ describe('useJobRun', () => { wrapper: createWrapper(durably), }) - const d = durably.register({ _job: retryableJob }) + const d = durably.register({ _job: retriggerableJob }) const run = await d.jobs._job.trigger({ input: 'test' }) result.current.setRunId(run.id) @@ -340,8 +340,8 @@ describe('useJobRun', () => { { timeout: 3000 }, ) - // Retry the run (worker is still running, will pick it up) - await durably.retry(run.id) + const nextRun = await durably.retrigger(run.id) + result.current.setRunId(nextRun.id) // Should track through to completion await waitFor( @@ -353,7 +353,7 @@ describe('useJobRun', () => { ) }) - it('tracks retry from cancelled through completion', async () => { + it('tracks retrigger from cancelled through completion', async () => { const durably = await createTestDurably({ pollingInterval: 50, autoStart: false, @@ -397,8 +397,8 @@ describe('useJobRun', () => { { timeout: 3000 }, ) - // Retry the run - await durably.retry(run.id) + const nextRun = await durably.retrigger(run.id) + result.current.setRunId(nextRun.id) // Should see pending await waitFor( @@ -408,7 +408,7 @@ describe('useJobRun', () => { { timeout: 3000 }, ) - // Start the worker to process the retry + // Start the worker to process the retriggered run durably.start() // Should track through to completion diff --git a/packages/durably-react/tests/client/use-job-run.test.tsx b/packages/durably-react/tests/client/use-job-run.test.tsx index d5afc5ec..2503ac6b 100644 --- a/packages/durably-react/tests/client/use-job-run.test.tsx +++ b/packages/durably-react/tests/client/use-job-run.test.tsx @@ -130,9 +130,9 @@ describe('useJobRun (client)', () => { }) }) - it('resets status when run is retried', async () => { + it('keeps terminal state when a new run is triggered elsewhere', async () => { const { result } = renderHook(() => - useJobRun({ api: '/api/durably', runId: 'retry-run' }), + useJobRun({ api: '/api/durably', runId: 'failed-run' }), ) await waitFor(() => { @@ -143,7 +143,7 @@ describe('useJobRun (client)', () => { act(() => { mockEventSource.emit({ type: 'run:fail', - runId: 'retry-run', + runId: 'failed-run', error: 'Something went wrong', }) }) @@ -153,19 +153,18 @@ describe('useJobRun (client)', () => { expect(result.current.error).toBe('Something went wrong') }) - // Then retry it + // Then trigger a different run for the same job family act(() => { mockEventSource.emit({ - type: 'run:retry', - runId: 'retry-run', + type: 'run:trigger', + runId: 'new-run', + jobName: 'job', + input: {}, }) }) - await waitFor(() => { - expect(result.current.status).toBe('pending') - expect(result.current.error).toBeNull() - expect(result.current.isPending).toBe(true) - }) + expect(result.current.status).toBe('failed') + expect(result.current.error).toBe('Something went wrong') }) it('tracks progress updates', async () => { diff --git a/packages/durably-react/tests/client/use-run-actions.test.tsx b/packages/durably-react/tests/client/use-run-actions.test.tsx index 9bd05ef2..eff15e73 100644 --- a/packages/durably-react/tests/client/use-run-actions.test.tsx +++ b/packages/durably-react/tests/client/use-run-actions.test.tsx @@ -1,7 +1,7 @@ /** * Client mode useRunActions tests * - * Test retry and cancel actions via fetch + * Test retrigger and cancel actions via fetch */ import { act, renderHook } from '@testing-library/react' @@ -20,11 +20,11 @@ describe('useRunActions (client)', () => { vi.restoreAllMocks() }) - describe('retry', () => { - it('calls retry endpoint with runId', async () => { + describe('retrigger', () => { + it('calls retrigger endpoint with runId and returns new runId', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ success: true }), + json: () => Promise.resolve({ success: true, runId: 'new-run-123' }), }) globalThis.fetch = fetchMock @@ -32,12 +32,14 @@ describe('useRunActions (client)', () => { useRunActions({ api: '/api/durably' }), ) + let nextRunId: string | undefined await act(async () => { - await result.current.retry('run-123') + nextRunId = await result.current.retrigger('run-123') }) + expect(nextRunId).toBe('new-run-123') expect(fetchMock).toHaveBeenCalledWith( - '/api/durably/retry?runId=run-123', + '/api/durably/retrigger?runId=run-123', { method: 'POST' }, ) }) @@ -45,7 +47,7 @@ describe('useRunActions (client)', () => { it('encodes runId in URL', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ success: true }), + json: () => Promise.resolve({ success: true, runId: 'new-run-456' }), }) globalThis.fetch = fetchMock @@ -54,11 +56,11 @@ describe('useRunActions (client)', () => { ) await act(async () => { - await result.current.retry('run/with/special&chars') + await result.current.retrigger('run/with/special&chars') }) expect(fetchMock).toHaveBeenCalledWith( - '/api/durably/retry?runId=run%2Fwith%2Fspecial%26chars', + '/api/durably/retrigger?runId=run%2Fwith%2Fspecial%26chars', { method: 'POST' }, ) }) @@ -71,7 +73,7 @@ describe('useRunActions (client)', () => { const fetchMock = vi.fn().mockImplementation(() => fetchPromise.then(() => ({ ok: true, - json: () => Promise.resolve({ success: true }), + json: () => Promise.resolve({ success: true, runId: 'new-run-789' }), })), ) globalThis.fetch = fetchMock @@ -82,16 +84,16 @@ describe('useRunActions (client)', () => { expect(result.current.isLoading).toBe(false) - let retryPromise: Promise + let retriggerPromise: Promise act(() => { - retryPromise = result.current.retry('run-123') + retriggerPromise = result.current.retrigger('run-123') }) expect(result.current.isLoading).toBe(true) await act(async () => { resolvePromise!() - await retryPromise + await retriggerPromise }) expect(result.current.isLoading).toBe(false) @@ -112,7 +114,7 @@ describe('useRunActions (client)', () => { let thrownError: Error | undefined await act(async () => { try { - await result.current.retry('run-123') + await result.current.retrigger('run-123') } catch (err) { thrownError = err as Error } @@ -138,17 +140,17 @@ describe('useRunActions (client)', () => { let thrownError: Error | undefined await act(async () => { try { - await result.current.retry('run-123') + await result.current.retrigger('run-123') } catch (err) { thrownError = err as Error } }) expect(thrownError?.message).toBe( - 'Failed to retry: Internal Server Error', + 'Failed to retrigger: Internal Server Error', ) expect(result.current.error).toBe( - 'Failed to retry: Internal Server Error', + 'Failed to retrigger: Internal Server Error', ) }) @@ -167,17 +169,17 @@ describe('useRunActions (client)', () => { let thrownError: Error | undefined await act(async () => { try { - await result.current.retry('run-123') + await result.current.retrigger('run-123') } catch (err) { thrownError = err as Error } }) expect(thrownError?.message).toBe( - 'Failed to retry: Internal Server Error', + 'Failed to retrigger: Internal Server Error', ) expect(result.current.error).toBe( - 'Failed to retry: Internal Server Error', + 'Failed to retrigger: Internal Server Error', ) }) @@ -191,7 +193,7 @@ describe('useRunActions (client)', () => { }) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ success: true }), + json: () => Promise.resolve({ success: true, runId: 'new-run-999' }), }) globalThis.fetch = fetchMock @@ -202,7 +204,7 @@ describe('useRunActions (client)', () => { // First call fails await act(async () => { try { - await result.current.retry('run-123') + await result.current.retrigger('run-123') } catch { // Expected } @@ -212,7 +214,7 @@ describe('useRunActions (client)', () => { // Second call succeeds await act(async () => { - await result.current.retry('run-123') + await result.current.retrigger('run-123') }) expect(result.current.error).toBeNull() @@ -382,7 +384,7 @@ describe('useRunActions (client)', () => { }) describe('shared state', () => { - it('shares isLoading between retry and cancel', async () => { + it('shares isLoading between retrigger and cancel', async () => { let resolvePromise: () => void const fetchPromise = new Promise((resolve) => { resolvePromise = resolve @@ -390,7 +392,8 @@ describe('useRunActions (client)', () => { const fetchMock = vi.fn().mockImplementation(() => fetchPromise.then(() => ({ ok: true, - json: () => Promise.resolve({ success: true }), + json: () => + Promise.resolve({ success: true, runId: 'new-run-shared' }), })), ) globalThis.fetch = fetchMock @@ -399,16 +402,16 @@ describe('useRunActions (client)', () => { useRunActions({ api: '/api/durably' }), ) - let retryPromise: Promise + let retriggerPromise: Promise act(() => { - retryPromise = result.current.retry('run-123') + retriggerPromise = result.current.retrigger('run-123') }) expect(result.current.isLoading).toBe(true) await act(async () => { resolvePromise!() - await retryPromise + await retriggerPromise }) expect(result.current.isLoading).toBe(false) diff --git a/packages/durably/docs/llms.md b/packages/durably/docs/llms.md index 46f5cbcf..a11e2060 100644 --- a/packages/durably/docs/llms.md +++ b/packages/durably/docs/llms.md @@ -35,6 +35,7 @@ const durably = createDurably({ pollingInterval: 1000, // Job polling interval (ms) heartbeatInterval: 5000, // Heartbeat update interval (ms) staleThreshold: 30000, // When to consider a job abandoned (ms) + cleanupSteps: true, // Delete step output data on terminal state (default: true) // Optional: type-safe labels with Zod schema // labels: z.object({ organizationId: z.string(), env: z.string() }), jobs: { @@ -206,10 +207,12 @@ type MyRun = Run & { const typedRuns = await durably.getRuns({ jobName: 'my-job' }) ``` -### Retry Failed Runs +### Retrigger Failed Runs ```ts -await durably.retry(runId) +// Creates a fresh run (new ID) with the same input/options +const newRun = await durably.retrigger(runId) +console.log(newRun.id) // new run ID ``` ### Cancel Runs @@ -236,7 +239,6 @@ durably.on('run:complete', (e) => console.log('Done:', e.output)) durably.on('run:fail', (e) => console.error('Failed:', e.error)) durably.on('run:cancel', (e) => console.log('Cancelled:', e.runId)) durably.on('run:delete', (e) => console.log('Deleted:', e.runId)) -durably.on('run:retry', (e) => console.log('Retried:', e.runId)) durably.on('run:progress', (e) => console.log('Progress:', e.progress.current, '/', e.progress.total), ) @@ -336,7 +338,7 @@ const handler = createDurablyHandler(durably, { } }, - // Guard before run-level operations (read, subscribe, steps, retry, cancel, delete) + // Guard before run-level operations (read, subscribe, steps, retrigger, cancel, delete) onRunAccess: async (ctx, run, { operation }) => { if (run.labels.organizationId !== ctx.orgId) { throw new Response('Forbidden', { status: 403 }) @@ -418,7 +420,7 @@ type RunOperation = | 'read' | 'subscribe' | 'steps' - | 'retry' + | 'retrigger' | 'cancel' | 'delete' diff --git a/packages/durably/src/durably.ts b/packages/durably/src/durably.ts index 3d697b3c..405b505e 100644 --- a/packages/durably/src/durably.ts +++ b/packages/durably/src/durably.ts @@ -43,6 +43,7 @@ export interface DurablyOptions< pollingInterval?: number heartbeatInterval?: number staleThreshold?: number + cleanupSteps?: boolean /** * Zod schema for labels. When provided: * - Labels are type-checked at compile time @@ -69,6 +70,7 @@ const DEFAULTS = { pollingInterval: 1000, heartbeatInterval: 5000, staleThreshold: 30000, + cleanupSteps: true, } as const /** @@ -185,10 +187,10 @@ export interface Durably< stop(): Promise /** - * Retry a failed run by resetting it to pending - * @throws Error if run is not in failed status + * Create a fresh run from a completed, failed, or cancelled run + * @throws Error if run is pending, running, or does not exist */ - retry(runId: string): Promise + retrigger(runId: string): Promise> /** * Cancel a pending or running run @@ -264,6 +266,7 @@ interface DurablyState { jobRegistry: JobRegistry worker: Worker labelsSchema: z.ZodType | undefined + cleanupSteps: boolean migrating: Promise | null migrated: boolean } @@ -356,7 +359,6 @@ function createDurablyInstance< 'run:fail', 'run:cancel', 'run:delete', - 'run:retry', 'run:progress', 'step:start', 'step:complete', @@ -392,32 +394,37 @@ function createDurablyInstance< }) }, - async retry(runId: string): Promise { + async retrigger(runId: string): Promise> { const run = await storage.getRun(runId) if (!run) { throw new Error(`Run not found: ${runId}`) } - if (run.status === 'completed') { - throw new Error(`Cannot retry completed run: ${runId}`) - } if (run.status === 'pending') { - throw new Error(`Cannot retry pending run: ${runId}`) + throw new Error(`Cannot retrigger pending run: ${runId}`) } if (run.status === 'running') { - throw new Error(`Cannot retry running run: ${runId}`) + throw new Error(`Cannot retrigger running run: ${runId}`) } - await storage.updateRun(runId, { - status: 'pending', - error: null, + if (!jobRegistry.get(run.jobName)) { + throw new Error(`Unknown job: ${run.jobName}`) + } + + const nextRun = await storage.createRun({ + jobName: run.jobName, + input: run.input, + concurrencyKey: run.concurrencyKey ?? undefined, + labels: run.labels, }) - // Emit run:retry event eventEmitter.emit({ - type: 'run:retry', - runId, + type: 'run:trigger', + runId: nextRun.id, jobName: run.jobName, + input: run.input, labels: run.labels, }) + + return nextRun as Run }, async cancel(runId: string): Promise { @@ -434,10 +441,17 @@ function createDurablyInstance< if (run.status === 'cancelled') { throw new Error(`Cannot cancel already cancelled run: ${runId}`) } + const wasPending = run.status === 'pending' await storage.updateRun(runId, { status: 'cancelled', + completedAt: new Date().toISOString(), }) + // For pending runs, no worker will clean up steps, so do it here + if (wasPending && state.cleanupSteps) { + await storage.deleteSteps(runId) + } + // Emit run:cancel event eventEmitter.emit({ type: 'run:cancel', @@ -535,6 +549,7 @@ export function createDurably< pollingInterval: options.pollingInterval ?? DEFAULTS.pollingInterval, heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval, staleThreshold: options.staleThreshold ?? DEFAULTS.staleThreshold, + cleanupSteps: options.cleanupSteps ?? DEFAULTS.cleanupSteps, } const db = new Kysely({ dialect: options.dialect }) @@ -550,6 +565,7 @@ export function createDurably< jobRegistry, worker, labelsSchema: options.labels, + cleanupSteps: config.cleanupSteps, migrating: null, migrated: false, } diff --git a/packages/durably/src/events.ts b/packages/durably/src/events.ts index 3f79e7e5..ac4ec053 100644 --- a/packages/durably/src/events.ts +++ b/packages/durably/src/events.ts @@ -73,16 +73,6 @@ export interface RunDeleteEvent extends BaseEvent { labels: Record } -/** - * Run retry event (emitted when a failed run is retried) - */ -export interface RunRetryEvent extends BaseEvent { - type: 'run:retry' - runId: string - jobName: string - labels: Record -} - /** * Progress data reported by step.progress() */ @@ -193,7 +183,6 @@ export type DurablyEvent = | RunFailEvent | RunCancelEvent | RunDeleteEvent - | RunRetryEvent | RunProgressEvent | StepStartEvent | StepCompleteEvent @@ -233,7 +222,6 @@ export type AnyEventInput = | EventInput<'run:fail'> | EventInput<'run:cancel'> | EventInput<'run:delete'> - | EventInput<'run:retry'> | EventInput<'run:progress'> | EventInput<'step:start'> | EventInput<'step:complete'> diff --git a/packages/durably/src/index.ts b/packages/durably/src/index.ts index 38974407..54b624fe 100644 --- a/packages/durably/src/index.ts +++ b/packages/durably/src/index.ts @@ -26,7 +26,6 @@ export type { RunDeleteEvent, RunFailEvent, RunProgressEvent, - RunRetryEvent, RunStartEvent, RunTriggerEvent, StepCancelEvent, diff --git a/packages/durably/src/server.ts b/packages/durably/src/server.ts index 259ad6a5..664f5704 100644 --- a/packages/durably/src/server.ts +++ b/packages/durably/src/server.ts @@ -24,7 +24,7 @@ export type RunOperation = | 'read' | 'subscribe' | 'steps' - | 'retry' + | 'retrigger' | 'cancel' | 'delete' @@ -108,7 +108,7 @@ export interface DurablyHandler { * - GET {basePath}/run?runId=xxx - Get single run * - GET {basePath}/steps?runId=xxx - Get steps * - POST {basePath}/trigger - Trigger a job - * - POST {basePath}/retry?runId=xxx - Retry a failed run + * - POST {basePath}/retrigger?runId=xxx - Create a fresh run from a terminal run * - POST {basePath}/cancel?runId=xxx - Cancel a run * - DELETE {basePath}/run?runId=xxx - Delete a run */ @@ -397,16 +397,16 @@ export function createDurablyHandler< }) } - async function handleRetry( + async function handleRetrigger( url: URL, ctx: TContext | undefined, ): Promise { return withErrorHandling(async () => { - const result = await requireRunAccess(url, ctx, 'retry') + const result = await requireRunAccess(url, ctx, 'retrigger') if (result instanceof Response) return result - await durably.retry(result.runId) - return successResponse() + const run = await durably.retrigger(result.runId) + return jsonResponse({ success: true, runId: run.id }) }) } @@ -563,17 +563,6 @@ export function createDurablyHandler< } }), - durably.on('run:retry', (event) => { - if (matchesFilter(event.jobName, event.labels)) { - ctrl.enqueue({ - type: 'run:retry', - runId: event.runId, - jobName: event.jobName, - labels: event.labels, - }) - } - }), - durably.on('run:progress', (event) => { if (matchesFilter(event.jobName, event.labels)) { ctrl.enqueue({ @@ -696,7 +685,7 @@ export function createDurablyHandler< // POST routes if (method === 'POST') { if (path === '/trigger') return await handleTrigger(request, ctx) - if (path === '/retry') return await handleRetry(url, ctx) + if (path === '/retrigger') return await handleRetrigger(url, ctx) if (path === '/cancel') return await handleCancel(url, ctx) } diff --git a/packages/durably/src/storage.ts b/packages/durably/src/storage.ts index 304aba6d..19e20883 100644 --- a/packages/durably/src/storage.ts +++ b/packages/durably/src/storage.ts @@ -167,6 +167,7 @@ export interface Storage { // Step operations createStep(input: CreateStepInput): Promise + deleteSteps(runId: string): Promise getSteps(runId: string): Promise getCompletedStep(runId: string, name: string): Promise @@ -525,6 +526,10 @@ export function createKyselyStorage(db: Kysely): Storage { return rowToStep(step) }, + async deleteSteps(runId: string): Promise { + await db.deleteFrom('durably_steps').where('run_id', '=', runId).execute() + }, + async getSteps(runId: string): Promise { const rows = await db .selectFrom('durably_steps') diff --git a/packages/durably/src/worker.ts b/packages/durably/src/worker.ts index 04237784..3d3dd2db 100644 --- a/packages/durably/src/worker.ts +++ b/packages/durably/src/worker.ts @@ -12,6 +12,7 @@ export interface WorkerConfig { pollingInterval: number heartbeatInterval: number staleThreshold: number + cleanupSteps: boolean } /** @@ -210,6 +211,14 @@ export function createWorker( } catch (error) { await handleRunFailure(run.id, run.jobName, error) } finally { + if (config.cleanupSteps) { + try { + await storage.deleteSteps(run.id) + } catch { + // Best-effort cleanup — don't block worker teardown + } + } + dispose() // Stop heartbeat interval if (heartbeatInterval) { diff --git a/packages/durably/tests/shared/recovery.shared.ts b/packages/durably/tests/shared/recovery.shared.ts index 44e9ce80..af629b23 100644 --- a/packages/durably/tests/shared/recovery.shared.ts +++ b/packages/durably/tests/shared/recovery.shared.ts @@ -197,11 +197,11 @@ export function createRecoveryTests(createDialect: () => Dialect) { }) }) - describe('retry() API', () => { - it('resets failed run to pending', async () => { + describe('retrigger() API', () => { + it('creates a fresh run from a failed run', async () => { const d = durably.register({ job: defineJob({ - name: 'retry-test', + name: 'retrigger-test', input: z.object({ shouldFail: z.boolean() }), run: async (_step, input) => { if (input.shouldFail) { @@ -222,18 +222,16 @@ export function createRecoveryTests(createDialect: () => Dialect) { { timeout: 1000 }, ) - // Retry the failed run - await d.retry(run.id) - - const retried = await d.jobs.job.getRun(run.id) - expect(retried?.status).toBe('pending') - expect(retried?.error).toBeNull() + const retriggered = await d.retrigger(run.id) + expect(retriggered.id).not.toBe(run.id) + expect(retriggered.status).toBe('pending') + expect(retriggered.input).toEqual({ shouldFail: true }) }) - it('throws when retrying completed run', async () => { + it('creates a fresh run from a completed run', async () => { const d = durably.register({ job: defineJob({ - name: 'retry-completed-test', + name: 'retrigger-completed-test', input: z.object({}), run: async () => {}, }), @@ -250,13 +248,15 @@ export function createRecoveryTests(createDialect: () => Dialect) { { timeout: 1000 }, ) - await expect(d.retry(run.id)).rejects.toThrow(/completed|cannot retry/i) + const retriggered = await d.retrigger(run.id) + expect(retriggered.id).not.toBe(run.id) + expect(retriggered.status).toBe('pending') }) - it('throws when retrying pending run', async () => { + it('throws when retriggering pending run', async () => { const d = durably.register({ job: defineJob({ - name: 'retry-pending-test', + name: 'retrigger-pending-test', input: z.object({}), run: async () => {}, }), @@ -265,13 +265,15 @@ export function createRecoveryTests(createDialect: () => Dialect) { const run = await d.jobs.job.trigger({}) // Don't start worker - run stays pending - await expect(d.retry(run.id)).rejects.toThrow(/pending|cannot retry/i) + await expect(d.retrigger(run.id)).rejects.toThrow( + /pending|cannot retrigger/i, + ) }) - it('throws when retrying running run', async () => { + it('throws when retriggering running run', async () => { const d = durably.register({ job: defineJob({ - name: 'retry-running-test', + name: 'retrigger-running-test', input: z.object({}), run: async (step) => { await step.run('long-step', async () => { @@ -293,7 +295,9 @@ export function createRecoveryTests(createDialect: () => Dialect) { { timeout: 500 }, ) - await expect(d.retry(run.id)).rejects.toThrow(/running|cannot retry/i) + await expect(d.retrigger(run.id)).rejects.toThrow( + /running|cannot retrigger/i, + ) }) }) @@ -541,9 +545,9 @@ export function createRecoveryTests(createDialect: () => Dialect) { { timeout: 1000 }, ) - // Verify steps and logs exist + // Step data is cleaned up once the run reaches a terminal state const steps = await d.storage.getSteps(run.id) - expect(steps.length).toBeGreaterThan(0) + expect(steps).toHaveLength(0) // Delete the run await d.deleteRun(run.id) diff --git a/packages/durably/tests/shared/server.shared.ts b/packages/durably/tests/shared/server.shared.ts index 713fb196..143f6068 100644 --- a/packages/durably/tests/shared/server.shared.ts +++ b/packages/durably/tests/shared/server.shared.ts @@ -97,10 +97,10 @@ export function createServerTests(createDialect: () => Dialect) { expect(response.status).toBe(200) }) - it('routes POST /retry to retry handler', async () => { + it('routes POST /retrigger to retrigger handler', async () => { const d = durably.register({ job: defineJob({ - name: 'retry-route-test', + name: 'retrigger-route-test', input: z.object({}), run: async () => { throw new Error('fail') @@ -120,7 +120,7 @@ export function createServerTests(createDialect: () => Dialect) { ) const request = new Request( - `http://localhost/api/durably/retry?runId=${run.id}`, + `http://localhost/api/durably/retrigger?runId=${run.id}`, { method: 'POST' }, ) const response = await handler.handle(request, '/api/durably') @@ -505,11 +505,11 @@ export function createServerTests(createDialect: () => Dialect) { }) }) - describe('retry', () => { - it('retries a failed run', async () => { + describe('retrigger', () => { + it('creates a fresh run from a failed run', async () => { const d = durably.register({ job: defineJob({ - name: 'retry-test', + name: 'retrigger-test', input: z.object({}), run: async () => { throw new Error('fail') @@ -528,7 +528,7 @@ export function createServerTests(createDialect: () => Dialect) { ) const request = new Request( - `http://localhost/api/durably/retry?runId=${run.id}`, + `http://localhost/api/durably/retrigger?runId=${run.id}`, { method: 'POST' }, ) @@ -537,13 +537,19 @@ export function createServerTests(createDialect: () => Dialect) { expect(response.status).toBe(200) expect(body.success).toBe(true) + expect(body.runId).not.toBe(run.id) - const updated = await d.getRun(run.id) - expect(updated?.status).toBe('pending') + await vi.waitFor( + async () => { + const updated = await d.getRun(body.runId) + expect(updated?.status).toBe('failed') + }, + { timeout: 1000 }, + ) }) it('returns 400 when runId is missing', async () => { - const request = new Request('http://localhost/api/durably/retry', { + const request = new Request('http://localhost/api/durably/retrigger', { method: 'POST', }) @@ -554,10 +560,10 @@ export function createServerTests(createDialect: () => Dialect) { expect(body.error).toBe('runId query parameter is required') }) - it('returns 500 when retrying non-failed run', async () => { + it('returns 500 when retriggering a pending run', async () => { const d = durably.register({ job: defineJob({ - name: 'retry-pending-test', + name: 'retrigger-pending-test', input: z.object({}), run: async () => {}, }), @@ -565,7 +571,7 @@ export function createServerTests(createDialect: () => Dialect) { const run = await d.jobs.job.trigger({}) const request = new Request( - `http://localhost/api/durably/retry?runId=${run.id}`, + `http://localhost/api/durably/retrigger?runId=${run.id}`, { method: 'POST' }, ) @@ -809,10 +815,10 @@ export function createServerTests(createDialect: () => Dialect) { expect(allEvents).toContain('run:cancel') }) - it('streams run:retry when job is retried', async () => { + it('streams run:trigger when job is retriggered', async () => { const d = durably.register({ job: defineJob({ - name: 'runs-subscribe-retry-test', + name: 'runs-subscribe-retrigger-test', input: z.object({}), run: async () => { throw new Error('test error') @@ -840,11 +846,11 @@ export function createServerTests(createDialect: () => Dialect) { const { done, value } = await reader.read() if (done) break events.push(decoder.decode(value)) - if (events.some((e) => e.includes('run:retry'))) break + if (events.some((e) => e.includes('run:trigger'))) break } })() - await d.retry(run.id) + await d.retrigger(run.id) await Promise.race([ readPromise, @@ -852,7 +858,7 @@ export function createServerTests(createDialect: () => Dialect) { ]) const allEvents = events.join('') - expect(allEvents).toContain('run:retry') + expect(allEvents).toContain('run:trigger') }) it('streams run lifecycle events', async () => { @@ -1222,15 +1228,18 @@ export function createServerTests(createDialect: () => Dialect) { }, }) - // retry + // retrigger await authHandler.handle( - new Request(`http://localhost/api/durably/retry?runId=${run.id}`, { - method: 'POST', - }), + new Request( + `http://localhost/api/durably/retrigger?runId=${run.id}`, + { + method: 'POST', + }, + ), '/api/durably', ) - expect(operations).toContain('retry') + expect(operations).toContain('retrigger') }) it('onRunAccess can reject with thrown Response', async () => { diff --git a/packages/durably/tests/shared/step.shared.ts b/packages/durably/tests/shared/step.shared.ts index faa173df..5695669a 100644 --- a/packages/durably/tests/shared/step.shared.ts +++ b/packages/durably/tests/shared/step.shared.ts @@ -19,6 +19,7 @@ export function createStepTests(createDialect: () => Dialect) { durably = createDurably({ dialect: createDialect(), pollingInterval: 50, + cleanupSteps: false, }) await durably.migrate() }) @@ -82,6 +83,41 @@ export function createStepTests(createDialect: () => Dialect) { ) }) + it('deletes persisted steps after terminal runs by default', async () => { + const cleanupDurably = createDurably({ + dialect: createDialect(), + pollingInterval: 50, + }) + await cleanupDurably.migrate() + + try { + const cleanupTestDef = defineJob({ + name: 'step-cleanup-test', + input: z.object({}), + run: async (step) => { + await step.run('step1', () => 'result1') + await step.run('step2', () => 'result2') + }, + }) + const d = cleanupDurably.register({ job: cleanupTestDef }) + + const run = await d.jobs.job.trigger({}) + d.start() + + await vi.waitFor( + async () => { + const updated = await d.jobs.job.getRun(run.id) + expect(updated?.status).toBe('completed') + expect(await d.storage.getSteps(run.id)).toHaveLength(0) + }, + { timeout: 1000 }, + ) + } finally { + await cleanupDurably.stop() + await cleanupDurably.db.destroy() + } + }) + it('transitions run to failed when step throws', async () => { const stepFailTestDef = defineJob({ name: 'step-fail-test', @@ -152,7 +188,7 @@ export function createStepTests(createDialect: () => Dialect) { expect(step1Calls).toBe(1) expect(step2Calls).toBe(1) - // Reset run to pending for retry (simulate retry behavior) + // Reset run to pending (simulating internal state rewind) await d.storage.updateRun(run1.id, { status: 'pending' }) // Second run - step1 should be skipped diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index c0a48449..3284c2ad 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -131,7 +131,10 @@ export default defineConfig({ { text: 'register', link: '/api/create-durably#register' }, { text: 'on (events)', link: '/api/create-durably#on' }, { text: 'stop', link: '/api/create-durably#stop' }, - { text: 'retry / cancel', link: '/api/create-durably#retry' }, + { + text: 'retrigger / cancel', + link: '/api/create-durably#retrigger', + }, { text: 'deleteRun', link: '/api/create-durably#deleterun', diff --git a/website/api/create-durably.md b/website/api/create-durably.md index 80b5c4c4..13ff8b35 100644 --- a/website/api/create-durably.md +++ b/website/api/create-durably.md @@ -27,6 +27,7 @@ interface DurablyOptions< pollingInterval?: number heartbeatInterval?: number staleThreshold?: number + cleanupSteps?: boolean labels?: z.ZodType jobs?: TJobs } @@ -39,6 +40,7 @@ interface DurablyOptions< | `heartbeatInterval` | `number` | `5000` | How often to update heartbeat (ms) | | `staleThreshold` | `number` | `30000` | Time until a job is considered stale (ms) | | `labels` | `z.ZodType` | — | Zod schema for labels. Enables type-safe labels and runtime validation on `trigger()` | +| `cleanupSteps` | `boolean` | `true` | Delete step output data when runs reach terminal state (completed/failed/cancelled) | | `jobs` | `TJobs` | — | Job definitions to register. Shorthand for calling `.register()` after creation | ## Returns @@ -122,13 +124,13 @@ durably.on( Subscribes to an event. Returns an unsubscribe function. See [Events](/api/events). -### `retry()` +### `retrigger()` ```ts -await durably.retry(runId: string): Promise +await durably.retrigger(runId: string): Promise ``` -Retries a failed or cancelled run by resetting its status to pending. +Retriggers a failed or cancelled run by creating a fresh run with the same input, options, and labels. Returns the new `Run` object. ### `cancel()` diff --git a/website/api/durably-react/fullstack.md b/website/api/durably-react/fullstack.md index ec52620d..f9d87bd2 100644 --- a/website/api/durably-react/fullstack.md +++ b/website/api/durably-react/fullstack.md @@ -96,7 +96,7 @@ function LogViewer({ runId }: { runId: string }) { ```tsx function Dashboard() { const { runs, nextPage, hasMore } = durably.useRuns({ pageSize: 10 }) - const { retry, cancel, deleteRun } = durably.useRunActions() + const { retrigger, cancel, deleteRun } = durably.useRunActions() return ( @@ -107,7 +107,7 @@ function Dashboard() {
{run.status} {run.status === 'failed' && ( - + )} {run.status === 'running' && ( @@ -280,7 +280,7 @@ List and paginate job runs with real-time updates on the first page. The first page (page 0) automatically subscribes to SSE for real-time updates. It listens to: -- `run:trigger`, `run:start`, `run:complete`, `run:fail`, `run:cancel`, `run:delete`, `run:retry` - refresh list +- `run:trigger`, `run:start`, `run:complete`, `run:fail`, `run:cancel`, `run:delete` - refresh list - `run:progress` - update progress in place - `step:start`, `step:complete`, `step:fail` - refresh for step updates @@ -415,20 +415,20 @@ useRuns(options) ## useRunActions -Perform actions on runs (retry, cancel, delete). +Perform actions on runs (retrigger, cancel, delete). ```tsx import { useRunActions } from '@coji/durably-react' function RunActions({ runId, status }: { runId: string; status: string }) { - const { retry, cancel, deleteRun, getRun, getSteps, isLoading, error } = + const { retrigger, cancel, deleteRun, getRun, getSteps, isLoading, error } = useRunActions({ api: '/api/durably' }) return (
{(status === 'failed' || status === 'cancelled') && ( - )} {(status === 'pending' || status === 'running') && ( @@ -457,12 +457,12 @@ function RunActions({ runId, status }: { runId: string; status: string }) { ### Return Type -| Property | Type | Description | -| ----------- | ----------------------------------------------- | -------------------- | -| `retry` | `(runId: string) => Promise` | Retry a failed run | -| `cancel` | `(runId: string) => Promise` | Cancel a running job | -| `deleteRun` | `(runId: string) => Promise` | Delete a run | -| `getRun` | `(runId: string) => Promise` | Get run details | -| `getSteps` | `(runId: string) => Promise` | Get step details | -| `isLoading` | `boolean` | Loading state | -| `error` | `string \| null` | Error message | +| Property | Type | Description | +| ----------- | ----------------------------------------------- | ------------------------------------------- | +| `retrigger` | `(runId: string) => Promise` | Retrigger a failed run (returns new run ID) | +| `cancel` | `(runId: string) => Promise` | Cancel a running job | +| `deleteRun` | `(runId: string) => Promise` | Delete a run | +| `getRun` | `(runId: string) => Promise` | Get run details | +| `getSteps` | `(runId: string) => Promise` | Get step details | +| `isLoading` | `boolean` | Loading state | +| `error` | `string \| null` | Error message | diff --git a/website/api/durably-react/index.md b/website/api/durably-react/index.md index 657f1368..c975ecc7 100644 --- a/website/api/durably-react/index.md +++ b/website/api/durably-react/index.md @@ -152,9 +152,9 @@ function ImportButton() { ### Fullstack Mode Only -| Hook | Description | -| --------------- | -------------------------- | -| `useRunActions` | Retry, cancel, delete runs | +| Hook | Description | +| --------------- | ------------------------------ | +| `useRunActions` | Retrigger, cancel, delete runs | ## Common Patterns @@ -205,7 +205,7 @@ function Dashboard() { const { runs, page, hasMore, nextPage, prevPage } = durably.useRuns({ pageSize: 10, }) - const { retry, cancel, deleteRun } = durably.useRunActions() + const { retrigger, cancel, deleteRun } = durably.useRunActions() return ( @@ -223,7 +223,7 @@ function Dashboard() {
{run.status} {run.status === 'failed' && ( - + )} {run.status === 'running' && ( diff --git a/website/api/durably-react/spa.md b/website/api/durably-react/spa.md index 94348a80..b38bfe63 100644 --- a/website/api/durably-react/spa.md +++ b/website/api/durably-react/spa.md @@ -255,7 +255,7 @@ List runs with optional filtering, pagination, and real-time updates. The hook automatically subscribes to Durably events and refreshes the list when runs change. It listens to: -- `run:trigger`, `run:start`, `run:complete`, `run:fail`, `run:cancel`, `run:delete`, `run:retry` - refresh list +- `run:trigger`, `run:start`, `run:complete`, `run:fail`, `run:cancel`, `run:delete` - refresh list - `run:progress` - update progress in place - `step:start`, `step:complete` - refresh for step count updates diff --git a/website/api/events.md b/website/api/events.md index e1e28ab3..9d09983a 100644 --- a/website/api/events.md +++ b/website/api/events.md @@ -138,23 +138,6 @@ durably.on('run:delete', (event) => { }) ``` -#### `run:retry` - -Fired when a failed or cancelled run is retried via `retry()` API. - -```ts -durably.on('run:retry', (event) => { - // event: { - // type: 'run:retry', - // runId: string, - // jobName: string, - // labels: Record, - // timestamp: string, - // sequence: number - // } -}) -``` - ### Step Events #### `step:start` @@ -305,7 +288,6 @@ type DurablyEvent = | RunCompleteEvent | RunFailEvent | RunCancelEvent - | RunRetryEvent | RunProgressEvent | StepStartEvent | StepCompleteEvent diff --git a/website/api/http-handler.md b/website/api/http-handler.md index 9ba54094..fe60ae9e 100644 --- a/website/api/http-handler.md +++ b/website/api/http-handler.md @@ -110,17 +110,17 @@ const clientRun = toClientRun(run) // strips internal fields The handler provides these endpoints: -| Method | Path | Description | -| -------- | ---------------------- | ------------------------------- | -| `POST` | `/trigger` | Trigger a job | -| `GET` | `/subscribe?runId=xxx` | SSE stream for run events | -| `GET` | `/runs` | List runs with filtering | -| `GET` | `/run?runId=xxx` | Get single run | -| `GET` | `/steps?runId=xxx` | Get steps for a run | -| `GET` | `/runs/subscribe` | SSE stream for run list updates | -| `POST` | `/retry?runId=xxx` | Retry a failed run | -| `POST` | `/cancel?runId=xxx` | Cancel a running job | -| `DELETE` | `/run?runId=xxx` | Delete a run | +| Method | Path | Description | +| -------- | ---------------------- | ---------------------------------------- | +| `POST` | `/trigger` | Trigger a job | +| `GET` | `/subscribe?runId=xxx` | SSE stream for run events | +| `GET` | `/runs` | List runs with filtering | +| `GET` | `/run?runId=xxx` | Get single run | +| `GET` | `/steps?runId=xxx` | Get steps for a run | +| `GET` | `/runs/subscribe` | SSE stream for run list updates | +| `POST` | `/retrigger?runId=xxx` | Retrigger a failed run (creates new run) | +| `POST` | `/cancel?runId=xxx` | Cancel a running job | +| `DELETE` | `/run?runId=xxx` | Delete a run | ## Trigger Request @@ -265,7 +265,7 @@ type RunOperation = | 'read' | 'subscribe' | 'steps' - | 'retry' + | 'retrigger' | 'cancel' | 'delete' ``` diff --git a/website/api/index.md b/website/api/index.md index 60372eb6..764fd4d2 100644 --- a/website/api/index.md +++ b/website/api/index.md @@ -208,14 +208,14 @@ function ImportButton() { ### Instance Methods -| Method | Description | -| -------------------- | --------------------------------- | -| `init()` | Migrate database and start worker | -| `register(jobs)` | Register job definitions | -| `on(event, handler)` | Subscribe to events | -| `stop()` | Stop worker gracefully | -| `retry(runId)` | Retry failed run | -| `cancel(runId)` | Cancel running job | +| Method | Description | +| -------------------- | -------------------------------------- | +| `init()` | Migrate database and start worker | +| `register(jobs)` | Register job definitions | +| `on(event, handler)` | Subscribe to events | +| `stop()` | Stop worker gracefully | +| `retrigger(runId)` | Retrigger failed run (creates new run) | +| `cancel(runId)` | Cancel running job | ### Step Context @@ -227,13 +227,13 @@ function ImportButton() { ### React Hooks (@coji/durably-react) -| Hook | Mode | Description | -| --------------- | --------- | -------------------------- | -| `useJob` | Both | Trigger and monitor jobs | -| `useJobRun` | Both | Subscribe to existing run | -| `useRuns` | Both | List runs with pagination | -| `useRunActions` | Fullstack | Retry, cancel, delete runs | -| `useDurably` | SPA | Access Durably instance | +| Hook | Mode | Description | +| --------------- | --------- | ------------------------------ | +| `useJob` | Both | Trigger and monitor jobs | +| `useJobRun` | Both | Subscribe to existing run | +| `useRuns` | Both | List runs with pagination | +| `useRunActions` | Fullstack | Retrigger, cancel, delete runs | +| `useDurably` | SPA | Access Durably instance | ## Type Exports diff --git a/website/guide/auth.md b/website/guide/auth.md index a3276c39..df31c658 100644 --- a/website/guide/auth.md +++ b/website/guide/auth.md @@ -55,10 +55,10 @@ auth: { } }, - // Guard before read/retry/cancel/delete + // Guard before read/retrigger/cancel/delete onRunAccess: async (ctx, run, { operation }) => { // Everyone can read, only admins can mutate - const writeOps = ['retry', 'cancel', 'delete'] + const writeOps = ['retrigger', 'cancel', 'delete'] if (writeOps.includes(operation) && ctx.role !== 'admin') { throw new Response('Forbidden', { status: 403 }) } @@ -70,14 +70,14 @@ auth: { `onRunAccess` receives the operation type: -| Operation | Endpoint | -| ----------- | ---------------- | -| `read` | `GET /run` | -| `subscribe` | `GET /subscribe` | -| `steps` | `GET /steps` | -| `retry` | `POST /retry` | -| `cancel` | `POST /cancel` | -| `delete` | `DELETE /run` | +| Operation | Endpoint | +| ----------- | ----------------- | +| `read` | `GET /run` | +| `subscribe` | `GET /subscribe` | +| `steps` | `GET /steps` | +| `retrigger` | `POST /retrigger` | +| `cancel` | `POST /cancel` | +| `delete` | `DELETE /run` | ## Execution Order diff --git a/website/guide/error-handling.md b/website/guide/error-handling.md index 63f07d04..d718dbd6 100644 --- a/website/guide/error-handling.md +++ b/website/guide/error-handling.md @@ -1,4 +1,4 @@ -# Error Handling & Retry +# Error Handling & Retrigger Durably doesn't auto-retry failures. This is intentional — you decide what to do when something goes wrong. @@ -16,34 +16,42 @@ run: async (step) => { } ``` -After retry, step-1 returns its cached result and step-2 runs again. +Retriggering creates a fresh new run with the same input — the original run stays as-is and previous steps are not reused. -## Retry Patterns +## Retrigger Patterns -### Server-Side Retry +### Server-Side Retrigger ```ts -// Check and retry a failed run +// Check and retrigger a failed run const run = await durably.getRun(runId) if (run?.status === 'failed') { - await durably.retry(runId) // Resets to pending, worker picks it up + const newRun = await durably.retrigger(runId) // Creates a fresh run with the same input + console.log(`New run: ${newRun.id}`) } ``` -### Fullstack Retry (React) +### Fullstack Retrigger (React) ```tsx import { durablyClient } from '~/lib/durably' function FailedRunActions({ runId }: { runId: string }) { - const { retry, cancel } = durablyClient.useRunActions() + const { retrigger, cancel } = durablyClient.useRunActions() const { status, error } = durablyClient.importCsv.useRun(runId) if (status === 'failed') { return (

Failed: {error}

- +
) } @@ -56,9 +64,9 @@ function FailedRunActions({ runId }: { runId: string }) { } ``` -### SPA Retry +### SPA Retrigger -In SPA mode, trigger the same job again — Durably doesn't expose a direct `retry()` in the browser hooks. +In SPA mode, trigger the same job again — Durably doesn't expose a direct `retrigger()` in the browser hooks. ```tsx import { useJob } from '@coji/durably-react/spa' @@ -99,7 +107,7 @@ await step.run('charge', () => }), ) -// Bad: duplicate insert on retry +// Bad: duplicate insert on re-execution await step.run('save-user', () => db.insert(user)) ``` diff --git a/website/guide/fullstack-mode.md b/website/guide/fullstack-mode.md index fa31a12f..31758c93 100644 --- a/website/guide/fullstack-mode.md +++ b/website/guide/fullstack-mode.md @@ -1,6 +1,6 @@ # Fullstack Mode -Server-side jobs with a React UI. Real-time progress via SSE, type-safe hooks, run dashboard with retry/cancel/delete. +Server-side jobs with a React UI. Real-time progress via SSE, type-safe hooks, run dashboard with retrigger/cancel/delete. **Example code:** [fullstack-react-router](https://github.com/coji/durably/tree/main/examples/fullstack-react-router) @@ -211,7 +211,7 @@ import { durablyClient } from '~/lib/durably' function Dashboard() { const { runs, hasMore, nextPage } = durablyClient.useRuns({ pageSize: 10 }) - const { retry, cancel, deleteRun } = durablyClient.useRunActions() + const { retrigger, cancel, deleteRun } = durablyClient.useRunActions() return ( @@ -229,7 +229,7 @@ function Dashboard() {
{run.status} {run.status === 'failed' && ( - + )} {run.status === 'running' && ( diff --git a/website/guide/multi-tenant.md b/website/guide/multi-tenant.md index c11d921d..4fc57e14 100644 --- a/website/guide/multi-tenant.md +++ b/website/guide/multi-tenant.md @@ -145,7 +145,7 @@ auth: { throw new Response('Forbidden', { status: 403 }) } // Role check for mutations - const writeOps = ['retry', 'cancel', 'delete'] + const writeOps = ['retrigger', 'cancel', 'delete'] if (writeOps.includes(operation) && ctx.role === 'viewer') { throw new Response('Forbidden', { status: 403 }) } diff --git a/website/guide/quick-start.md b/website/guide/quick-start.md index ccd8ae40..569154bb 100644 --- a/website/guide/quick-start.md +++ b/website/guide/quick-start.md @@ -103,7 +103,7 @@ const riskyJob = defineJob({ ``` 1. Run with the error uncommented — step 1 succeeds, step 2 fails -2. Comment out the error, retry the run — step 1 returns cached result, step 2 runs fresh +2. Comment out the error, retrigger the run — a fresh run (new ID) starts with the same input ## Next Steps diff --git a/website/guide/server-mode.md b/website/guide/server-mode.md index 8f06a2be..52333898 100644 --- a/website/guide/server-mode.md +++ b/website/guide/server-mode.md @@ -190,13 +190,14 @@ await durably.jobs.processImage.trigger( ## Error Handling -Durably doesn't auto-retry. Check status and retry manually: +Durably doesn't auto-retry. Check status and retrigger manually: ```ts const run = await durably.getRun(runId) if (run?.status === 'failed') { - await durably.retry(runId) + const newRun = await durably.retrigger(runId) // Creates a fresh run + console.log(`New run: ${newRun.id}`) } if (run?.status === 'running') { @@ -204,7 +205,7 @@ if (run?.status === 'running') { } ``` -See [Error Handling & Retry](/guide/error-handling) for more patterns. +See [Error Handling & Retrigger](/guide/error-handling) for more patterns. ## Next Steps diff --git a/website/public/llms.txt b/website/public/llms.txt index 46732e89..741aafdb 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -35,6 +35,7 @@ const durably = createDurably({ pollingInterval: 1000, // Job polling interval (ms) heartbeatInterval: 5000, // Heartbeat update interval (ms) staleThreshold: 30000, // When to consider a job abandoned (ms) + cleanupSteps: true, // Delete step output data on terminal state (default: true) // Optional: type-safe labels with Zod schema // labels: z.object({ organizationId: z.string(), env: z.string() }), jobs: { @@ -206,10 +207,12 @@ type MyRun = Run & { const typedRuns = await durably.getRuns({ jobName: 'my-job' }) ``` -### Retry Failed Runs +### Retrigger Failed Runs ```ts -await durably.retry(runId) +// Creates a fresh run (new ID) with the same input/options +const newRun = await durably.retrigger(runId) +console.log(newRun.id) // new run ID ``` ### Cancel Runs @@ -236,7 +239,6 @@ durably.on('run:complete', (e) => console.log('Done:', e.output)) durably.on('run:fail', (e) => console.error('Failed:', e.error)) durably.on('run:cancel', (e) => console.log('Cancelled:', e.runId)) durably.on('run:delete', (e) => console.log('Deleted:', e.runId)) -durably.on('run:retry', (e) => console.log('Retried:', e.runId)) durably.on('run:progress', (e) => console.log('Progress:', e.progress.current, '/', e.progress.total), ) @@ -336,7 +338,7 @@ const handler = createDurablyHandler(durably, { } }, - // Guard before run-level operations (read, subscribe, steps, retry, cancel, delete) + // Guard before run-level operations (read, subscribe, steps, retrigger, cancel, delete) onRunAccess: async (ctx, run, { operation }) => { if (run.labels.organizationId !== ctx.orgId) { throw new Response('Forbidden', { status: 403 }) @@ -418,7 +420,7 @@ type RunOperation = | 'read' | 'subscribe' | 'steps' - | 'retry' + | 'retrigger' | 'cancel' | 'delete' @@ -701,7 +703,7 @@ function LogViewer({ runId }: { runId: string }) { // Cross-job hooks (built into the proxy) function Dashboard() { const { runs } = durably.useRuns({ pageSize: 10 }) - const { retry, cancel } = durably.useRunActions() + const { retrigger, cancel } = durably.useRunActions() } ``` @@ -875,13 +877,13 @@ function FilteredDashboard() { ### Fullstack useRunActions -Actions for runs (retry, cancel, delete): +Actions for runs (retrigger, cancel, delete): ```tsx import { useRunActions } from '@coji/durably-react' function RunActions({ runId, status }: { runId: string; status: string }) { - const { retry, cancel, deleteRun, getRun, getSteps, isLoading, error } = + const { retrigger, cancel, deleteRun, getRun, getSteps, isLoading, error } = useRunActions({ api: '/api/durably', }) @@ -889,8 +891,8 @@ function RunActions({ runId, status }: { runId: string; status: string }) { return (
{(status === 'failed' || status === 'cancelled') && ( - )} {(status === 'pending' || status === 'running') && (