Lifecycle Phases
What It Is
Lifecycle phases are DAG placements that run around the main graph walk. A pre phase runs before the entrypoint; a post phase runs after the main loop exits. They are useful for setup and teardown work that belongs to the execution but should not participate in routing.
Phase placements are authored in the DAG, executed in declaration order, and observed by onPhaseEnter / onPhaseExit hooks.
How It Works
Phase placements are registered beside normal nodes. The dispatcher runs pre phases before the entrypoint and post phases after the main loop has a lifecycle outcome. Phase node return values do not route the graph; side effects and observability hooks are the point.
PhaseNode placements run around the main DAG loop rather than inside it. They are registered like any other placement, mutate state, can throw, and are observed by the dispatcher's protected on* hooks.
Diagrams, Examples, and Outputs
Example 19 builds a phase-demo DAG with a pre setup placement, one routed main node, and a post audit placement. The JSON-LD contains all three placement types; the Mermaid view shows how phases sit beside the routed main path.
export const dagIri = 'urn:noocodec:dag:phase-demo' as const;
const placement = (placementIdentifier: string): string => DAGIdentity.placementId(dagIri, placementIdentifier);
const preSetupNode = new PreSetupNode();
const computeNode = new ComputeNode();
const postAuditNode = new PostAuditNode();
export const dag = new DAGBuilder(dagIri, '1')
// 'pre' phase: runs before the entrypoint in declaration order.
.phase(placement('setup'), 'pre', preSetupNode)
// Main loop: compute is the entrypoint (first .node() call).
.node(placement('compute'), computeNode, { 'done': placement('end') })
.terminal(placement('end'))
// 'post' phase: runs after the main loop drains on every exit path.
.phase(placement('audit'), 'post', postAuditNode)
.build();Example 19 phase DAG
4 placements{
"@context": {
"@version": 1.1,
"name": {
"@id": "https://noocodec.dev/ontology/dag/name"
},
"version": {
"@id": "https://noocodec.dev/ontology/dag/version"
},
"entrypoints": {
"@id": "https://noocodec.dev/ontology/dag/entrypoints",
"@container": "@index"
},
"nodes": {
"@id": "https://noocodec.dev/ontology/dag/nodes",
"@container": "@set"
},
"outputs": {
"@id": "https://noocodec.dev/ontology/dag/outputs"
},
"node": {
"@id": "https://noocodec.dev/ontology/dag/node"
},
"dag": {
"@id": "https://noocodec.dev/ontology/dag/dag"
},
"body": {
"@id": "https://noocodec.dev/ontology/dag/body"
},
"source": {
"@id": "https://noocodec.dev/ontology/dag/source"
},
"sources": {
"@id": "https://noocodec.dev/ontology/dag/sources",
"@container": "@index"
},
"itemKey": {
"@id": "https://noocodec.dev/ontology/dag/itemKey"
},
"execution": {
"@id": "https://noocodec.dev/ontology/dag/execution"
},
"concurrency": {
"@id": "https://noocodec.dev/ontology/dag/concurrency"
},
"throttle": {
"@id": "https://noocodec.dev/ontology/dag/throttle"
},
"reservoir": {
"@id": "https://noocodec.dev/ontology/dag/reservoir"
},
"gather": {
"@id": "https://noocodec.dev/ontology/dag/gather"
},
"dagReference": {
"@id": "https://noocodec.dev/ontology/dag/dagReference",
"@type": "@id"
},
"DagReference": {
"@id": "https://noocodec.dev/ontology/dag/DagReference"
},
"from": {
"@id": "https://noocodec.dev/ontology/dag/from"
},
"path": {
"@id": "https://noocodec.dev/ontology/dag/path"
},
"candidates": {
"@id": "https://noocodec.dev/ontology/dag/candidates",
"@container": "@set"
},
"candidateDag": {
"@id": "https://noocodec.dev/ontology/dag/candidateDag",
"@type": "@id"
},
"selectedDag": {
"@id": "https://noocodec.dev/ontology/dag/selectedDag",
"@type": "@id"
},
"resultField": {
"@id": "https://noocodec.dev/ontology/dag/resultField"
},
"policy": {
"@id": "https://noocodec.dev/ontology/dag/policy"
},
"reducer": {
"@id": "https://noocodec.dev/ontology/dag/reducer"
},
"outcome": {
"@id": "https://noocodec.dev/ontology/dag/outcome"
},
"phase": {
"@id": "https://noocodec.dev/ontology/dag/phase"
},
"stateMapping": {
"@id": "https://noocodec.dev/ontology/dag/stateMapping"
},
"container": {
"@id": "https://noocodec.dev/ontology/dag/container"
},
"DAG": {
"@id": "https://noocodec.dev/ontology/dag/DAG"
},
"Placement": {
"@id": "https://noocodec.dev/ontology/dag/Placement"
},
"SingleNode": {
"@id": "https://noocodec.dev/ontology/dag/SingleNode"
},
"ScatterNode": {
"@id": "https://noocodec.dev/ontology/dag/ScatterNode"
},
"EmbeddedDAGNode": {
"@id": "https://noocodec.dev/ontology/dag/EmbeddedDAGNode"
},
"GatherNode": {
"@id": "https://noocodec.dev/ontology/dag/GatherNode"
},
"TerminalNode": {
"@id": "https://noocodec.dev/ontology/dag/TerminalNode"
},
"PhaseNode": {
"@id": "https://noocodec.dev/ontology/dag/PhaseNode"
}
},
"@id": "urn:noocodec:dag:phase-demo",
"@type": "DAG",
"name": "dag:phase-demo",
"version": "1",
"entrypoints": {
"main": "urn:noocodec:dag:phase-demo/node/compute"
},
"nodes": [
{
"@id": "urn:noocodec:dag:phase-demo/node/setup",
"@type": "PhaseNode",
"name": "dag:phase-demo/node/setup",
"node": "urn:noocodec:node:pre-setup",
"phase": "pre"
},
{
"@id": "urn:noocodec:dag:phase-demo/node/compute",
"@type": "SingleNode",
"name": "dag:phase-demo/node/compute",
"node": "urn:noocodec:node:compute",
"outputs": {
"done": "urn:noocodec:dag:phase-demo/node/end"
}
},
{
"@id": "urn:noocodec:dag:phase-demo/node/end",
"@type": "TerminalNode",
"name": "dag:phase-demo/node/end",
"outcome": "completed"
},
{
"@id": "urn:noocodec:dag:phase-demo/node/audit",
"@type": "PhaseNode",
"name": "dag:phase-demo/node/audit",
"node": "urn:noocodec:node:post-audit",
"phase": "post"
}
]
}Mermaid source
%%{init: {"flowchart":{"nodeSpacing":92,"rankSpacing":104,"padding":28}}}%%
flowchart TB
%% dag:phase-demo (v1)
entry_main(["main"])
entry_main --> urn_noocodec_dag_phase-demo/node/compute
urn_noocodec_dag_phase-demo/node/setup(["dag:phase-demo/node/setup (pre)"])
urn_noocodec_dag_phase-demo/node/compute["dag:phase-demo/node/compute"]
urn_noocodec_dag_phase-demo/node/compute -->|done| urn_noocodec_dag_phase-demo/node/end
urn_noocodec_dag_phase-demo/node/end((("dag:phase-demo/node/end")))
urn_noocodec_dag_phase-demo/node/audit(["dag:phase-demo/node/audit (post)"])Open Example 19: Phase Nodes for the runnable output and execution-order assertions. See Observability for phase hooks in dispatcher subclasses.
What It Lets You Do
Use when
Use lifecycle phases when setup or cleanup belongs around the flow but should not participate in output routing. Pre phases seed or validate state before the entrypoint; post phases flush or release resources after every exit path.
Code Samples
API surface
| Symbol | Source | Role |
|---|---|---|
PhaseNode | @studnicky/dagonizer/entities | JSON Schema-derived placement type |
PhaseNodeSchema | @studnicky/dagonizer/entities | The JSON Schema |
DAGBuilder.phase(name, phase, nodeRef) | @studnicky/dagonizer/builder | Fluent registration |
Dagonizer.onPhaseEnter | @studnicky/dagonizer | Protected hook — fires before each phase placement |
Dagonizer.onPhaseExit | @studnicky/dagonizer | Protected hook — fires after each phase placement |
Details for Nerds
Two arms
phase: 'pre': runs before the entrypoint, in DAG declaration order.phase: 'post': runs after the main loop drains, in DAG declaration order, on every exit path.
Phase placements have no outputs field. They never route to other placements. They are never the main-loop entrypoint.
When to reach for it
Pre/post phases fit the bootstrap/teardown shape:
- Pre: warm a cache, attach an observability span, validate environment, bind a request-scoped logger to
state.metadata. - Post: flush metrics, close a database handle, persist a final checkpoint, emit a
flow-finishedevent.
These are real units of work that participate in the DAG. They are not the same as the onFlowStart / onFlowEnd observer hooks; those are observability-only, do not register as nodes, do not show up in executedNodes, and cannot mutate state through the NodeInterface contract.
Failure semantics
| Path | Pre-phase throws | Main loop throws | Post-phase throws |
|---|---|---|---|
| Lifecycle | failed | already-set (failed, cancelled, timed_out) | unchanged |
| Main loop executes | no | partially | n/a |
| Post-phases execute | yes | yes | yes (errors collected as warnings) |
Pre-phase failures abort the run before the entrypoint sees state. Post-phase failures never overwrite the lifecycle; they are collected as warnings on state with code POST_PHASE_FAILED and the loop continues with the next post-phase. This matches the best-effort teardown shape: a failed log flush does not flip a successful run into a failure.
ExecutionResult.executedNodes
- Pre-phase names appear at the START (every pre-phase that ran without throwing).
- Main-loop nodes appear in the MIDDLE.
- Post-phase names appear at the END (every post-phase that completed without throwing).
A pre-phase that threw is not appended. A post-phase that threw is not appended.
Authoring
The fluent surface lives on DAGBuilder:
export const dagIri = 'urn:noocodec:dag:phase-demo' as const;
const placement = (placementIdentifier: string): string => DAGIdentity.placementId(dagIri, placementIdentifier);
const preSetupNode = new PreSetupNode();
const computeNode = new ComputeNode();
const postAuditNode = new PostAuditNode();
export const dag = new DAGBuilder(dagIri, '1')
// 'pre' phase: runs before the entrypoint in declaration order.
.phase(placement('setup'), 'pre', preSetupNode)
// Main loop: compute is the entrypoint (first .node() call).
.node(placement('compute'), computeNode, { 'done': placement('end') })
.terminal(placement('end'))
// 'post' phase: runs after the main loop drains on every exit path.
.phase(placement('audit'), 'post', postAuditNode)
.build();Phase placements are recorded in DAG declaration order. Order matters: warm-cache runs strictly before ingest; flush-logs runs strictly before close-db.
The hand-written JSON form is also accepted:
{
"@id": "urn:noocodec:dag:pipeline/node/warm-cache",
"@type": "PhaseNode",
"name": "warm-cache",
"node": "warm-cache-node",
"phase": "pre"
}Validation
At registerDAG time the engine verifies that every PhaseNode.node resolves to a registered node. A missing reference raises DAGError. The schema rejects an outputs field (no routing) and rejects any phase value outside 'pre' | 'post'.
Observability hooks
For every phase placement the dispatcher calls the protected hooks on the Dagonizer subclass:
class PhaseObserver extends Dagonizer<PhaseState> {
readonly #events: string[] = [];
get events(): readonly string[] { return this.#events; }
protected override onPhaseEnter(_dagName: string, phase: 'pre' | 'post', placementName: string, _state: PhaseState, _placementPath: readonly string[]): void {
this.#events.push(`enter:${phase}:${placementName}`);
}
protected override onPhaseExit(_dagName: string, phase: 'pre' | 'post', placementName: string, _state: PhaseState, _placementPath: readonly string[]): void {
this.#events.push(`exit:${phase}:${placementName}`);
}
}See Observability for the full hook reference.
Related Concepts
- DAGBuilder -
.phase(name, "pre" | "post", node)registers a phase placement - Observability -
onPhaseEnterandonPhaseExitobserve phase boundaries - Reference: Builder
- Example 19: Phase Nodes