Skip to content

wip: feat(engine): dags-as-durable-tasks#4266

Draft
mrkaye97 wants to merge 85 commits into
belanger/operatorsfrom
mk/durable-tasks-as-operator
Draft

wip: feat(engine): dags-as-durable-tasks#4266
mrkaye97 wants to merge 85 commits into
belanger/operatorsfrom
mk/durable-tasks-as-operator

Conversation

@mrkaye97

@mrkaye97 mrkaye97 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Description

WIP

Initial implementation of the dag operator, which basically creates an internal durable task that processes the dag via child spawning

to do:

  • render preview of dag even if nothing has run yet in the dash
  • make sure all the existing apis work (check everything that returns dags)
  • write more tests for all the different cases of conditions, skipping behavior, cancelling behavior, and concurrency
  • make sure that a dag that's registered with the flag enabled still works as expected after the flag is disabled, and vice versa
  • figure out what happens to running dags when this ships
  • fix the dashboard / runs list api's pagination when we're listing runs that include dag orchestrators (since they and their children get collapsed)
  • add link from task to orchestrator run in the dashboard like we have for dags
  • figure out how to enable replaying from a specific place in the DAG
  • trace view is broken since it tries to fetch the dag run by its external id
  • Make sure the workflow_run_id for the dag children correspond's to the durable task external id
  • There's a bug (need to reproduce) where if the first task in a dag has a wait_for (sleep), we get a bunch of "could not send task to worker'

Fixes # (issue)

Type of change

  • New feature (non-breaking change which adds functionality)

What's Changed

  • Add a list of tasks or features here...

Checklist

Changes have been:

  • Tested (unit, integration, or manually with steps specified)
  • Linted and formatted
  • Documented (where applicable)
  • Added to CHANGELOG (where applicable) -- see Keep a Changelog

🤖 AI Disclosure
  • I acknowledge that an LLM was used in the creation of this Pull Request, in accordance with Hatchet's AI_POLICY.md.
  • Details: [e.g. generating tests, writing docs]

@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hatchet-docs Ready Ready Preview, Comment Jul 15, 2026 6:52pm

Request Review

@github-actions github-actions Bot added the engine Related to the core Hatchet engine label Jun 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-online-migrate job failed on this PR. This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions github-actions Bot added the dashboard Related to the Hatchet dashboard label Jun 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-online-migrate job failed on this PR. This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-online-migrate job failed on this PR. This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-online-migrate job failed on this PR. This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The old-engine-new-sdk (Python) job failed on this PR. This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-online-migrate job failed on this PR. This check is non-mandatory and does not block merging, but may be worth investigating. View logs

),
cell: ({ row }) => {
if (row.getCanExpand()) {
if (row.original.type === 'DAG') {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this lets us open the workflow run detail page for an operator DAG before any of the tasks have actually been spawned

[taskRuns],
() =>
taskRuns
.filter((t) => t.isDurable && t.taskExternalId !== id)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's a bunch of stuff like this - basically removing the orchestrator from the ids

Comment on lines +408 to +410
if e.isDagOrchestrator {
continue
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't want to render spans for the orchestrator task since it messes up nesting and is "internal" / not user facing

d.l.Warn().Ctx(ctx).Err(err).Msg("failed to unmarshal input")
continue
if len(currInput.DagParentTaskRunIds) > 0 {
dagParentOutputs, err := d.repov1.Tasks().GetDagParentOutputs(ctx, tenantId, currInput.DagParentTaskRunIds)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure what to do about this - seems very suboptimal to need a db lookup here, will circle back

continue
}
} else {
childEvents, err := s.repov1.Tasks().ListDurableOrchestratorChildOutputEvents(ctx, tenantId, event.TaskExternalId)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here - really not ideal to need the db lookup, will circle back

return nil
}

func (d *DispatcherServiceImpl) TriggerDAGStep(ctx context.Context, tenantId uuid.UUID, req *operator.DAGStepTriggerRequest) (*operator.DAGStepTriggerResult, error) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is one of the important methods, since it's what gets called by the operator to trigger tasks

Comment on lines +1283 to +1297
TriggerTaskData: &v1.TriggerTaskData{
WorkflowName: req.WorkflowName,
TargetActionId: &req.ActionId,
UserMessage: &stepLabel,
Data: []byte(req.Input),
AdditionalMetadata: task.AdditionalMetadata,
ParentExternalId: &task.ExternalID,
ParentTaskId: &task.ID,
ParentTaskInsertedAt: &task.InsertedAt.Time,
ChildIndex: &childIndex,
DagParentTaskRunIds: req.DagParentTaskRunIds,
IsSkipped: req.IsSkipped,
IsCancelled: req.IsCancelled,
DesiredWorkerLabels: req.DesiredWorkerLabels,
WorkflowRunId: &orchestratorWorkflowRunId,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important stuff here:

  1. we basically pass most things along (e.g. the metadata, the input, etc.)
  2. we tie the workflow run id manually to the orchestrator's workflow run id

if t.output != nil {
b, err := json.Marshal(t.output)
if err == nil {
output[t.readableId] = json.RawMessage(b)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this results in a DAG output that maps step ids to outputs, similar to what we see in the dash and over the API

Comment thread pkg/repository/sqlcv1/olap-overwrite.go Outdated
Comment on lines +136 to +144
-- fixme: this might be really slow
AND NOT EXISTS (
SELECT 1
FROM v1_tasks_olap t
WHERE
t.external_id = r.parent_task_external_id
AND COALESCE(t.is_dag_orchestrator, FALSE)
AND t.tenant_id = r.tenant_id
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, this makes me very nervous, but I'm not sure how else to do this. Basically, we need to remove any dag children from the runs list query, since they'll be double counted (we should only include dag parents, dags, and standalone tasks)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably a broader conversation, but should we not be treating the OLAP side of things as static, so we just insert data as if we're inserting DAGs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, I didn't even think about that... 🤔 it would make all the API surface stuff a lot easier

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the negative is we'd still need v1_dags_olap, v1_dag_to_task_olap, etc.?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah - I'm not sure it's a long-term fix, but short-term it would make our lives easier (obviously just moves complexity somewhere else)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lemme see if I can get it to work. maybe when we eventually do V2, we can do that second push for OLAP since we potentially won't need to support as much old API surface

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-online-migrate job failed on this PR. This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The old-engine-new-sdk (Python) job failed on this PR. This check is non-mandatory and does not block merging, but may be worth investigating. View logs

Comment thread pkg/repository/sqlcv1/olap-overwrite.go Outdated
Comment on lines 224 to 232
-- fixme: this might be really slow
AND NOT EXISTS (
SELECT 1
FROM v1_tasks_olap t
WHERE
lt.tenant_id = $1::uuid
AND lt.external_id = $11::UUID
t.external_id = r.parent_task_external_id
AND COALESCE(t.is_dag_orchestrator, FALSE)
AND t.tenant_id = r.tenant_id
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

type dag struct {
requestCh chan<- *v1contracts.DurableTaskRequest
requestCh chan<- *v1contracts.DurableTaskRequest
matchRepo repository.MatchRepository

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think pulling out the EvaluateBoolExpr into a subpackage with essentially no dependencies (particularly no DB dependencies) would make it much easier to test this file.

This also seems like a good candidate for table-test unit testing with a set of comprehensive examples; in particular, we'd likely want code coverage on all of the feature paths (wait conditions, etc).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm aware that building out testing for this file would require the test to sort of act like its own mini workflow engine, since it needs to be able to receive and send durable task req/responses over the channels...I'm assuming that hardcoding when we send data over these channels for each test is simple enough that this is doable, but I could also see a world where it's complex enough that we don't want to do this

if !ok {
return fmt.Errorf("durable task session closed")
}
d.taskConsumer(resp)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: did we ever merge that "stable callback ordering" PR? That's required for this to work

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not yet, but yeah, agreed - there are some open comments on that one still

isTriggered bool
isSkipped bool
errorMessage string
output map[string]interface{}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is tractable, I think we need to find a way to not keep the output of the task in memory for longer than necessary. Perhaps we can invert the expression evaluation to evaluate the output when the parent completes instead of evaluating it on the child.

for _, parent := range task.parents {
if !parent.isCompleted {
areParentsSatisfied = false
for _, t := range d.tasks {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's more beneficial to keep a list of pendingTasks versus finalizedTasks so that we only loop through pendingTasks which could theoretically still emit an event

}
}

result, err := d.triggerStep(ctx, t.actionId, t.workflowName, t.index, parentTaskRunIds, skip, cancelled)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be synchronous / blocking the loop, or can we add to a waitgroup and evaluate at the bottom of taskEmitter?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this wouldn't get us anything though, because we need to guard the event log which I imagine we touch when we call this method, so we serialize it anyway

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: a quick pass adding spans/traces throughout this file would be awesome

Comment thread pkg/repository/sqlcv1/olap-overwrite.go Outdated
Comment on lines +136 to +144
-- fixme: this might be really slow
AND NOT EXISTS (
SELECT 1
FROM v1_tasks_olap t
WHERE
t.external_id = r.parent_task_external_id
AND COALESCE(t.is_dag_orchestrator, FALSE)
AND t.tenant_id = r.tenant_id
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably a broader conversation, but should we not be treating the OLAP side of things as static, so we just insert data as if we're inserting DAGs?

return fmt.Errorf("could not marshal DAG operator config: %w", err)
}

_, err = a.repo.Operators().CreateOperator(ctx, tenantId, v1.CreateOperatorOpts{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like we only call this on PutWorkflow. Should we be calling this from other places, or is the thinking that this only really enables when a user restarts their workers?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking it was fine to only do this here since the isDagOperator flag is tied to the WorkflowVersion, so until someone registers a new workflow version with a dag, we'd never end up needing to call this, which I think is fine. We could also call it on startup I guess?

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-online-migrate job failed on this PR. This check is non-mandatory and does not block merging, but may be worth investigating. View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dashboard Related to the Hatchet dashboard documentation Improvements or additions to documentation engine Related to the core Hatchet engine sdk-py Related to the Python sdk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants