Feat: ts direct child spawning (#1872)

* feat: context vars

* docs: gen

* rm test

* fix: schedule timeout handling

* release: 1.8.0

* helpful error
This commit is contained in:
Gabe Ruttner
2025-06-17 18:02:16 -07:00
committed by GitHub
parent 0544ebeae4
commit dca3cbd7e1
19 changed files with 144 additions and 41 deletions

View File

@@ -27,7 +27,7 @@ export const parent = hatchet.task({
const promises = [];
for (let i = 0; i < n; i++) {
promises.push(ctx.runChild(child, { N: i }));
promises.push(child.run({ N: i }));
}
const childRes = await Promise.all(promises);

View File

@@ -1,9 +1,10 @@
import { hatchet } from '../hatchet-client';
import { simple } from './workflow';
import { parent } from './workflow-with-child';
async function main() {
// > Running a Task
const res = await simple.run(
const res = await parent.run(
{
Message: 'HeLlO WoRlD',
},
@@ -34,11 +35,11 @@ export async function extra() {
console.log(results[1].TransformedMessage);
// > Spawning Tasks from within a Task
const parent = hatchet.task({
const parentTask = hatchet.task({
name: 'parent',
fn: async (input, ctx) => {
// Simply call ctx.runChild with the task you want to run
const child = await ctx.runChild(simple, {
// Simply the task and it will be spawned from the parent task
const child = await simple.run({
Message: 'HeLlO WoRlD',
});
@@ -50,5 +51,9 @@ export async function extra() {
}
if (require.main === module) {
main();
main()
.catch(console.error)
.finally(() => {
process.exit(0);
});
}

View File

@@ -9,8 +9,7 @@ export const sticky = hatchet.task({
sticky: StickyStrategy.SOFT,
fn: async (_, ctx) => {
// specify a child workflow to run on the same worker
const result = await ctx.runChild(
child,
const result = await child.run(
{
N: 1,
},

View File

@@ -3,7 +3,7 @@ import { Snippet } from '@/next/lib/docs/generated/snips/types';
const snippet: Snippet = {
language: 'typescript ',
content:
"// > Declaring a Child\nimport { hatchet } from '../hatchet-client';\n\ntype ChildInput = {\n N: number;\n};\n\nexport const child = hatchet.task({\n name: 'child',\n fn: (input: ChildInput) => {\n return {\n Value: input.N,\n };\n },\n});\n\n// > Declaring a Parent\n\ntype ParentInput = {\n N: number;\n};\n\nexport const parent = hatchet.task({\n name: 'parent',\n fn: async (input: ParentInput, ctx) => {\n const n = input.N;\n const promises = [];\n\n for (let i = 0; i < n; i++) {\n promises.push(ctx.runChild(child, { N: i }));\n }\n\n const childRes = await Promise.all(promises);\n const sum = childRes.reduce((acc, curr) => acc + curr.Value, 0);\n\n return {\n Result: sum,\n };\n },\n});\n",
"// > Declaring a Child\nimport { hatchet } from '../hatchet-client';\n\ntype ChildInput = {\n N: number;\n};\n\nexport const child = hatchet.task({\n name: 'child',\n fn: (input: ChildInput) => {\n return {\n Value: input.N,\n };\n },\n});\n\n// > Declaring a Parent\n\ntype ParentInput = {\n N: number;\n};\n\nexport const parent = hatchet.task({\n name: 'parent',\n fn: async (input: ParentInput, ctx) => {\n const n = input.N;\n const promises = [];\n\n for (let i = 0; i < n; i++) {\n promises.push(child.run({ N: i }));\n }\n\n const childRes = await Promise.all(promises);\n const sum = childRes.reduce((acc, curr) => acc + curr.Value, 0);\n\n return {\n Result: sum,\n };\n },\n});\n",
source: 'out/typescript/child_workflows/workflow.ts',
blocks: {
declaring_a_child: {

View File

@@ -3,20 +3,20 @@ import { Snippet } from '@/next/lib/docs/generated/snips/types';
const snippet: Snippet = {
language: 'typescript ',
content:
"import { hatchet } from '../hatchet-client';\nimport { simple } from './workflow';\n\nasync function main() {\n // > Running a Task\n const res = await simple.run(\n {\n Message: 'HeLlO WoRlD',\n },\n {\n additionalMetadata: {\n test: 'test',\n },\n }\n );\n\n // 👀 Access the results of the Task\n console.log(res.TransformedMessage);\n}\n\nexport async function extra() {\n // > Running Multiple Tasks\n const res1 = simple.run({\n Message: 'HeLlO WoRlD',\n });\n\n const res2 = simple.run({\n Message: 'Hello MoOn',\n });\n\n const results = await Promise.all([res1, res2]);\n\n console.log(results[0].TransformedMessage);\n console.log(results[1].TransformedMessage);\n\n // > Spawning Tasks from within a Task\n const parent = hatchet.task({\n name: 'parent',\n fn: async (input, ctx) => {\n // Simply call ctx.runChild with the task you want to run\n const child = await ctx.runChild(simple, {\n Message: 'HeLlO WoRlD',\n });\n\n return {\n result: child.TransformedMessage,\n };\n },\n });\n}\n\nif (require.main === module) {\n main();\n}\n",
"import { hatchet } from '../hatchet-client';\nimport { simple } from './workflow';\nimport { parent } from './workflow-with-child';\n\nasync function main() {\n // > Running a Task\n const res = await parent.run(\n {\n Message: 'HeLlO WoRlD',\n },\n {\n additionalMetadata: {\n test: 'test',\n },\n }\n );\n\n // 👀 Access the results of the Task\n console.log(res.TransformedMessage);\n}\n\nexport async function extra() {\n // > Running Multiple Tasks\n const res1 = simple.run({\n Message: 'HeLlO WoRlD',\n });\n\n const res2 = simple.run({\n Message: 'Hello MoOn',\n });\n\n const results = await Promise.all([res1, res2]);\n\n console.log(results[0].TransformedMessage);\n console.log(results[1].TransformedMessage);\n\n // > Spawning Tasks from within a Task\n const parentTask = hatchet.task({\n name: 'parent',\n fn: async (input, ctx) => {\n // Simply the task and it will be spawned from the parent task\n const child = await simple.run({\n Message: 'HeLlO WoRlD',\n });\n\n return {\n result: child.TransformedMessage,\n };\n },\n });\n}\n\nif (require.main === module) {\n main()\n .catch(console.error)\n .finally(() => {\n process.exit(0);\n });\n}\n",
source: 'out/typescript/simple/run.ts',
blocks: {
running_a_task: {
start: 6,
stop: 18,
start: 7,
stop: 19,
},
running_multiple_tasks: {
start: 23,
stop: 34,
start: 24,
stop: 35,
},
spawning_tasks_from_within_a_task: {
start: 37,
stop: 49,
start: 38,
stop: 50,
},
},
highlights: {},

View File

@@ -3,12 +3,12 @@ import { Snippet } from '@/next/lib/docs/generated/snips/types';
const snippet: Snippet = {
language: 'typescript ',
content:
"import { StickyStrategy } from '@hatchet-dev/typescript-sdk/protoc/workflows';\nimport { hatchet } from '../hatchet-client';\nimport { child } from '../child_workflows/workflow';\n\n// > Sticky Task\nexport const sticky = hatchet.task({\n name: 'sticky',\n retries: 3,\n sticky: StickyStrategy.SOFT,\n fn: async (_, ctx) => {\n // specify a child workflow to run on the same worker\n const result = await ctx.runChild(\n child,\n {\n N: 1,\n },\n { sticky: true }\n );\n\n return {\n result,\n };\n },\n});\n",
"import { StickyStrategy } from '@hatchet-dev/typescript-sdk/protoc/workflows';\nimport { hatchet } from '../hatchet-client';\nimport { child } from '../child_workflows/workflow';\n\n// > Sticky Task\nexport const sticky = hatchet.task({\n name: 'sticky',\n retries: 3,\n sticky: StickyStrategy.SOFT,\n fn: async (_, ctx) => {\n // specify a child workflow to run on the same worker\n const result = await child.run(\n {\n N: 1,\n },\n { sticky: true }\n );\n\n return {\n result,\n };\n },\n});\n",
source: 'out/typescript/sticky/workflow.ts',
blocks: {
sticky_task: {
start: 6,
stop: 24,
stop: 23,
},
},
highlights: {},

View File

@@ -2,7 +2,7 @@ import { Snippet } from '@/lib/generated/snips/types';
const snippet: Snippet = {
"language": "typescript ",
"content": "// > Declaring a Child\nimport { hatchet } from '../hatchet-client';\n\ntype ChildInput = {\n N: number;\n};\n\nexport const child = hatchet.task({\n name: 'child',\n fn: (input: ChildInput) => {\n return {\n Value: input.N,\n };\n },\n});\n\n// > Declaring a Parent\n\ntype ParentInput = {\n N: number;\n};\n\nexport const parent = hatchet.task({\n name: 'parent',\n fn: async (input: ParentInput, ctx) => {\n const n = input.N;\n const promises = [];\n\n for (let i = 0; i < n; i++) {\n promises.push(ctx.runChild(child, { N: i }));\n }\n\n const childRes = await Promise.all(promises);\n const sum = childRes.reduce((acc, curr) => acc + curr.Value, 0);\n\n return {\n Result: sum,\n };\n },\n});\n",
"content": "// > Declaring a Child\nimport { hatchet } from '../hatchet-client';\n\ntype ChildInput = {\n N: number;\n};\n\nexport const child = hatchet.task({\n name: 'child',\n fn: (input: ChildInput) => {\n return {\n Value: input.N,\n };\n },\n});\n\n// > Declaring a Parent\n\ntype ParentInput = {\n N: number;\n};\n\nexport const parent = hatchet.task({\n name: 'parent',\n fn: async (input: ParentInput, ctx) => {\n const n = input.N;\n const promises = [];\n\n for (let i = 0; i < n; i++) {\n promises.push(child.run({ N: i }));\n }\n\n const childRes = await Promise.all(promises);\n const sum = childRes.reduce((acc, curr) => acc + curr.Value, 0);\n\n return {\n Result: sum,\n };\n },\n});\n",
"source": "out/typescript/child_workflows/workflow.ts",
"blocks": {
"declaring_a_child": {

View File

@@ -2,20 +2,20 @@ import { Snippet } from '@/lib/generated/snips/types';
const snippet: Snippet = {
"language": "typescript ",
"content": "import { hatchet } from '../hatchet-client';\nimport { simple } from './workflow';\n\nasync function main() {\n // > Running a Task\n const res = await simple.run(\n {\n Message: 'HeLlO WoRlD',\n },\n {\n additionalMetadata: {\n test: 'test',\n },\n }\n );\n\n // 👀 Access the results of the Task\n console.log(res.TransformedMessage);\n}\n\nexport async function extra() {\n // > Running Multiple Tasks\n const res1 = simple.run({\n Message: 'HeLlO WoRlD',\n });\n\n const res2 = simple.run({\n Message: 'Hello MoOn',\n });\n\n const results = await Promise.all([res1, res2]);\n\n console.log(results[0].TransformedMessage);\n console.log(results[1].TransformedMessage);\n\n // > Spawning Tasks from within a Task\n const parent = hatchet.task({\n name: 'parent',\n fn: async (input, ctx) => {\n // Simply call ctx.runChild with the task you want to run\n const child = await ctx.runChild(simple, {\n Message: 'HeLlO WoRlD',\n });\n\n return {\n result: child.TransformedMessage,\n };\n },\n });\n}\n\nif (require.main === module) {\n main();\n}\n",
"content": "import { hatchet } from '../hatchet-client';\nimport { simple } from './workflow';\nimport { parent } from './workflow-with-child';\n\nasync function main() {\n // > Running a Task\n const res = await parent.run(\n {\n Message: 'HeLlO WoRlD',\n },\n {\n additionalMetadata: {\n test: 'test',\n },\n }\n );\n\n // 👀 Access the results of the Task\n console.log(res.TransformedMessage);\n}\n\nexport async function extra() {\n // > Running Multiple Tasks\n const res1 = simple.run({\n Message: 'HeLlO WoRlD',\n });\n\n const res2 = simple.run({\n Message: 'Hello MoOn',\n });\n\n const results = await Promise.all([res1, res2]);\n\n console.log(results[0].TransformedMessage);\n console.log(results[1].TransformedMessage);\n\n // > Spawning Tasks from within a Task\n const parentTask = hatchet.task({\n name: 'parent',\n fn: async (input, ctx) => {\n // Simply the task and it will be spawned from the parent task\n const child = await simple.run({\n Message: 'HeLlO WoRlD',\n });\n\n return {\n result: child.TransformedMessage,\n };\n },\n });\n}\n\nif (require.main === module) {\n main()\n .catch(console.error)\n .finally(() => {\n process.exit(0);\n });\n}\n",
"source": "out/typescript/simple/run.ts",
"blocks": {
"running_a_task": {
"start": 6,
"stop": 18
"start": 7,
"stop": 19
},
"running_multiple_tasks": {
"start": 23,
"stop": 34
"start": 24,
"stop": 35
},
"spawning_tasks_from_within_a_task": {
"start": 37,
"stop": 49
"start": 38,
"stop": 50
}
},
"highlights": {}

View File

@@ -2,12 +2,12 @@ import { Snippet } from '@/lib/generated/snips/types';
const snippet: Snippet = {
"language": "typescript ",
"content": "import { StickyStrategy } from '@hatchet-dev/typescript-sdk/protoc/workflows';\nimport { hatchet } from '../hatchet-client';\nimport { child } from '../child_workflows/workflow';\n\n// > Sticky Task\nexport const sticky = hatchet.task({\n name: 'sticky',\n retries: 3,\n sticky: StickyStrategy.SOFT,\n fn: async (_, ctx) => {\n // specify a child workflow to run on the same worker\n const result = await ctx.runChild(\n child,\n {\n N: 1,\n },\n { sticky: true }\n );\n\n return {\n result,\n };\n },\n});\n",
"content": "import { StickyStrategy } from '@hatchet-dev/typescript-sdk/protoc/workflows';\nimport { hatchet } from '../hatchet-client';\nimport { child } from '../child_workflows/workflow';\n\n// > Sticky Task\nexport const sticky = hatchet.task({\n name: 'sticky',\n retries: 3,\n sticky: StickyStrategy.SOFT,\n fn: async (_, ctx) => {\n // specify a child workflow to run on the same worker\n const result = await child.run(\n {\n N: 1,\n },\n { sticky: true }\n );\n\n return {\n result,\n };\n },\n});\n",
"source": "out/typescript/sticky/workflow.ts",
"blocks": {
"sticky_task": {
"start": 6,
"stop": 24
"stop": 23
}
},
"highlights": {}

View File

@@ -232,6 +232,7 @@ func (s *DispatcherImpl) taskEventsToWorkflowRunEvent(tenantId string, finalized
case sqlcv1.V1TaskEventTypeFAILED:
res.Error = &event.ErrorMessage
case sqlcv1.V1TaskEventTypeCANCELLED:
//FIXME: this should be more specific for schedule timeouts
res.Error = &event.ErrorMessage
}

View File

@@ -1,6 +1,6 @@
{
"name": "@hatchet-dev/typescript-sdk",
"version": "1.7.1",
"version": "1.8.0",
"description": "Background task orchestration & visibility for developers",
"types": "dist/index.d.ts",
"files": [

View File

@@ -105,8 +105,13 @@ export default class WorkflowRunRef<T> {
(async () => {
for await (const event of streamable.stream()) {
if (event.eventType === WorkflowRunEventType.WORKFLOW_RUN_EVENT_TYPE_FINISHED) {
if (event.results.some((r) => !!r.error)) {
reject(event.results);
if (event.results.some((r) => r.error !== undefined)) {
// HACK: this might replace intentional empty errors but this is the more common case
const errors = event.results.map((r) =>
r.error !== '' ? r.error : 'task was cancelled'
);
reject(errors);
return;
}

View File

@@ -36,6 +36,7 @@ import { WorkerLabels } from '@hatchet/clients/dispatcher/dispatcher-client';
import { CreateStep, mapRateLimit, StepRunFunction } from '@hatchet/step';
import { applyNamespace } from '@hatchet/util/apply-namespace';
import { Context, DurableContext } from './context';
import { parentRunContextManager } from '../../parent-run-context-vars';
export type ActionRegistry = Record<Action['actionId'], Function>;
@@ -467,6 +468,12 @@ export class V1Worker {
}
const run = async () => {
parentRunContextManager.setContext({
parentId: action.workflowRunId,
parentRunId: action.stepRunId,
childIndex: 0,
desiredWorkerId: this.workerId || '',
});
return step(context);
};

View File

@@ -23,6 +23,7 @@ import { Duration } from './client/duration';
import { MetricsClient } from './client/features/metrics';
import { InputType, OutputType, UnknownInputType, JsonObject } from './types';
import { Context, DurableContext } from './client/worker/context';
import { parentRunContextManager } from './parent-run-context-vars';
const UNBOUND_ERR = new Error('workflow unbound to hatchet client, hint: use client.run instead');
@@ -53,6 +54,18 @@ export type RunOpts = {
* values: Priority.LOW, Priority.MEDIUM, Priority.HIGH (1, 2, or 3 )
*/
priority?: Priority;
/**
* (optional) if the task run should be run on the same worker.
* only used if spawned from within a parent task.
*/
sticky?: boolean;
/**
* (optional) the child key for the workflow run.
* only used if spawned from within a parent task.
*/
childKey?: string;
};
/**
@@ -289,6 +302,25 @@ export class BaseWorkflowDeclaration<
throw UNBOUND_ERR;
}
// set the parent run context
const parentRunContext = parentRunContextManager.getContext();
parentRunContextManager.incrementChildIndex(Array.isArray(input) ? input.length : 1);
if (!parentRunContext && (options?.childKey || options?.sticky)) {
this.client.admin.logger.warn(
'ignoring childKey or sticky because run is not being spawned from a parent task'
);
}
const runOpts = {
...options,
parentId: parentRunContext?.parentId,
parentStepRunId: parentRunContext?.parentRunId,
childIndex: parentRunContext?.childIndex,
sticky: options?.sticky ? parentRunContext?.desiredWorkerId : undefined,
childKey: options?.childKey,
};
if (Array.isArray(input)) {
let resp: WorkflowRunRef<O>[] = [];
for (let i = 0; i < input.length; i += 500) {
@@ -297,7 +329,10 @@ export class BaseWorkflowDeclaration<
batch.map((inp) => ({
workflowName: this.definition.name,
input: inp,
options,
options: {
...runOpts,
childIndex: (runOpts.childIndex ?? 0) + i, // increment from initial child index state
},
}))
);
resp = resp.concat(batchResp);
@@ -319,7 +354,7 @@ export class BaseWorkflowDeclaration<
return res;
}
const res = await this.client.admin.runWorkflow<I, O>(this.definition.name, input, options);
const res = await this.client.admin.runWorkflow<I, O>(this.definition.name, input, runOpts);
if (_standaloneTaskName) {
res._standaloneTaskName = _standaloneTaskName;

View File

@@ -29,7 +29,7 @@ export const parent = hatchet.task({
const promises = [];
for (let i = 0; i < n; i++) {
promises.push(ctx.runChild(child, { N: i }));
promises.push(child.run({ N: i }));
}
const childRes = await Promise.all(promises);

View File

@@ -1,10 +1,11 @@
/* eslint-disable no-console */
import { hatchet } from '../hatchet-client';
import { simple } from './workflow';
import { parent } from './workflow-with-child';
async function main() {
// > Running a Task
const res = await simple.run(
const res = await parent.run(
{
Message: 'HeLlO WoRlD',
},
@@ -37,11 +38,11 @@ export async function extra() {
// !!
// > Spawning Tasks from within a Task
const parent = hatchet.task({
const parentTask = hatchet.task({
name: 'parent',
fn: async (input, ctx) => {
// Simply call ctx.runChild with the task you want to run
const child = await ctx.runChild(simple, {
// Simply the task and it will be spawned from the parent task
const child = await simple.run({
Message: 'HeLlO WoRlD',
});
@@ -54,5 +55,9 @@ export async function extra() {
}
if (require.main === module) {
main();
main()
.catch(console.error)
.finally(() => {
process.exit(0);
});
}

View File

@@ -10,8 +10,7 @@ export const sticky = hatchet.task({
sticky: StickyStrategy.SOFT,
fn: async (_, ctx) => {
// specify a child workflow to run on the same worker
const result = await ctx.runChild(
child,
const result = await child.run(
{
N: 1,
},

View File

@@ -0,0 +1,46 @@
import { AsyncLocalStorage } from 'async_hooks';
export interface ParentRunContext {
parentId: string;
parentRunId: string;
desiredWorkerId: string;
childIndex?: number;
}
export class ParentRunContextManager {
private storage: AsyncLocalStorage<ParentRunContext>;
constructor() {
this.storage = new AsyncLocalStorage<ParentRunContext>();
}
setContext(opts: ParentRunContext): void {
this.storage.enterWith({
...opts,
});
}
setParentRunIdAndIncrementChildIndex(opts: ParentRunContext): void {
const parentRunContext = this.getContext();
if (parentRunContext) {
parentRunContext.parentId = opts.parentId;
parentRunContext.childIndex = (parentRunContext.childIndex ?? 0) + 1;
this.setContext(parentRunContext);
}
}
incrementChildIndex(n: number): void {
const parentRunContext = this.getContext();
if (parentRunContext) {
parentRunContext.childIndex = (parentRunContext.childIndex ?? 0) + n;
this.setContext(parentRunContext);
}
}
getContext(): ParentRunContext | undefined {
return this.storage.getStore();
}
}
// Export a default instance for backward compatibility and convenience
export const parentRunContextManager = new ParentRunContextManager();

View File

@@ -47,6 +47,7 @@
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": [
"jest",
"node",
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */