A Workflow Engine: Turning the Pipeline Into a Resumable Task Graph
Replace the sequential script with a workflow engine that has nodes, dependencies, and state, so every step's output lands on disk, every step is idempotent, and a failure resumes from the checkpoint instead of burning money from scratch.
Today's Goals
- Implement a minimal task-graph executor that schedules nodes by dependency and records each node's state
- Make every node idempotent: the same input doesn't spend money twice, and a rerun only completes the unfinished parts
- Log each run's outputs and cost into a ledger, traceable by run id
Yesterday's pain report had a number on it: when the fourth stage blew up, the CNY 3.1250 already spent would be spent again in full. Today we turn it into 0. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
The slate: which take we are on, where a reshoot resumes
Every set has a small board, filled in before each take: scene, shot, take number. It looks like something for post to match material with, but its real job is getting "where are we" out of any one person's head. The DP is replaced mid-shoot, a machine crashes and reboots, pickups happen the next day — check the log and you know where to resume.
Yesterday's pipeline had no slate. It kept progress in the process's call stack — step four meant step four, and when the process died, what the first three steps did, what they cost and where their artifacts were all died with it. So it could only start over.
Fixing that requires something more basic first: separating scheduling, state and retry from the business code.
Yesterday's six stages were six await calls in sequence, with business logic welded to execution order. Now write it differently: each stage declares only what it is, not when it runs.
// A node declares four things: who I depend on, what my inputs are, which files I produce,
// and how I run. "When do I run", "what if I crash", "have I run before" are not its business.
export const clipsNode = {
id: 'clips',
title: 'shot videos',
deps: ['frames'],
version: 1, // bump this whenever the node's implementation changes; old artifacts die at once
inputs: { model: 'video-model-id', shots: shots.map((s) => `${s.id}|${s.visual}|${s.durationSec}`) },
outputs: shots.map((s) => `${s.id}.mp4`),
async run(ctx) {
for (const s of shots) {
// Read a dependency's artifacts from its directory, never pass them in memory -
// that is what makes it possible for a node to run in another process.
const firstFrame = `${ctx.depDir('frames')}/${s.id}.png`
const r = await video.generate({ prompt: s.visual, firstFrame, outPath: `${ctx.outDir}/${s.id}.mp4` })
ctx.record({ nodeId: 'clips', kind: 'video', units: s.durationSec, costCny: r.costCny })
}
return { note: `${shots.length} shots` }
},
}# A node declares four things: who I depend on, what my inputs are, which files I produce,
# and how I run. "When do I run", "what if I crash", "have I run before" are not its business.
from dataclasses import dataclass
from typing import Any, Callable
@dataclass
class NodeSpec:
id: str
title: str
deps: list[str]
version: int # bump whenever the implementation changes
inputs: Any # the inputs that take part in the idempotency key
outputs: list[str] # produced filenames, relative to this node's artifact directory
run: Callable[["Ctx"], dict]
def run_clips(ctx: "Ctx") -> dict:
for s in SHOTS:
# Read a dependency's artifacts from its directory, never pass them in memory -
# that is what makes it possible for a node to run in another process.
first_frame = f"{ctx.dep_dir('frames')}/{s.id}.png"
r = video.generate(prompt=s.visual, first_frame=first_frame, out_path=f"{ctx.out_dir}/{s.id}.mp4")
ctx.record(node_id="clips", kind="video", units=s.duration_sec, cost_cny=r["costCny"])
return {"note": f"{len(SHOTS)} shots"}The value of this step is not prettier code; it is that the node can now be pushed around freely by the engine: skipped, reordered, run in another process, or asked before running whether this work has already been done. And that last question is the entire source of the savings.
So how does the engine know the work has already been done? The answer is a key, and whether that key is designed correctly decides whether your pipeline halves its bill or quietly hands you an episode with the wrong footage in it.
Idempotency comes down to the key
Idempotent sounds abstract; on this pipeline it means one thing: the same input must not spend money a second time.
You implement it by computing a fingerprint per node and using it to ask whether that work's artifacts already exist. All the difficulty lies in what goes into the fingerprint.
Four things must be in it, none optional:
The node id. Different nodes must not collide. Obvious.
The implementation version. You changed the node's prompt template, encoding parameters, algorithm — the input did not change, but the output should. If the version is not in the key, you read old artifacts with new code, undetectably. This is the easiest of the four to omit and the most insidious.
This node's inputs. Model id, prompt, duration, resolution — everything that affects the output.
Every dependency's fingerprint. This is the only mechanism by which an upstream change invalidates downstream work. Hash only your own inputs and, when the script changes upstream, you keep using the old shot files: no error, just a wrong cut.
And what must never be in it: the run id, timestamps, random numbers, absolute paths. Include any of them and every key is new, the cache never hits, and you will assume the cache is broken. "Cache never hits" and "cache serves the wrong take" both come from the key, in opposite directions.
import { createHash } from 'node:crypto'
// All four parts are required. Without version, a changed implementation reads stale artifacts;
// without deps, upstream changes never propagate; with runId, the cache never hits.
export function fingerprint(node, depFingerprints) {
const payload = JSON.stringify({
id: node.id,
version: node.version,
inputs: node.inputs,
deps: depFingerprints,
})
return createHash('sha256').update(payload).digest('hex').slice(0, 16)
}import hashlib
import json
# All four parts are required. Without version, a changed implementation reads stale artifacts;
# without deps, upstream changes never propagate; with run_id, the cache never hits.
def fingerprint(node: NodeSpec, dep_fingerprints: list[str]) -> str:
payload = json.dumps(
{"id": node.id, "version": node.version, "inputs": node.inputs, "deps": dep_fingerprints},
sort_keys=True,
ensure_ascii=False,
)
return hashlib.sha256(payload.encode()).hexdigest()[:16]One boundary in passing: D3's asset deduplication — "the same description does not regenerate" — is this mechanism applied to the image stage; how much the cache actually saves and how to degrade when hit rate is low is D12's topic. Today, just get the key right.
Where artifacts land: the directory name is the fingerprint
With a fingerprint, artifacts should land under it. That is content addressing — the directory name is decided by content, not by which run produced it.
So the disk splits cleanly into two layers:
work/
├── cache/<nodeId>/<fingerprint>/ artifacts; the same input always writes the same directory
│ ├── clips/a276b932372bc7c6/s01.mp4
│ └── voice/182a684769cb79e8/s01.mp3
└── runs/<runId>/
├── state.json per-node status, elapsed time, spend and fingerprint
├── run.json the cost ledger
└── output/ cut, subtitles and cover taken from compose's cache dirThe cache is global; state is per run. That division is worth remembering, because it maps onto two completely different capabilities: idempotency comes from the cache — re-run under a new run id and you still spend nothing; resumption comes from state — knowing where the last run got to, and which node failed rather than never being reached.
Separating them has another benefit: run directories become very light — one state file, one ledger and a few final artifacts — so dozens of experiments' run directories add up to nothing, while the heavy material lives in the cache deduplicated by content, naturally single-copy per input.
Resumption: a re-run becomes a set difference
With those two sections laid down, resuming holds no mystery. The engine's main loop walks the topological order and asks three questions per node: did anything upstream fail? are my artifacts complete? did I persist the result after running?
export async function runWorkflow(specs, opts) {
const state = await loadState(opts.statePath, opts.runId)
const fingerprints = new Map()
const dirs = new Map()
let failedAt
for (const node of topoSort(specs)) {
const fp = fingerprint(node, node.deps.map((d) => fingerprints.get(d) ?? ''))
fingerprints.set(node.id, fp)
const outDir = `${opts.cacheDir}/${node.id}/${fp}`
dirs.set(node.id, outDir)
// Upstream unfinished means incomplete input downstream; running only wastes money again.
if (failedAt) {
state.nodes[node.id] = { fingerprint: fp, status: 'blocked', ms: 0, calls: 0, costCny: 0 }
continue
}
// Idempotency check: look only at whether the artifacts exist, never at the state file.
if (await allExist(outDir, node.outputs)) {
state.nodes[node.id] = { fingerprint: fp, status: 'done', source: 'cache', ms: 0, calls: 0, costCny: 0 }
await saveState(opts.statePath, state) // persist per step, so a kill loses only the running node
continue
}
const started = Date.now()
const callsBefore = opts.callCount()
try {
const { note } = await node.run({ outDir, depDir: (id) => dirs.get(id), record: opts.onCost })
state.nodes[node.id] = { fingerprint: fp, status: 'done', source: 'executed', ms: Date.now() - started, calls: opts.callCount() - callsBefore, note }
} catch (err) {
// Persist failed nodes too: the money they spent is real, and partial artifacts stay.
state.nodes[node.id] = { fingerprint: fp, status: 'failed', ms: Date.now() - started, calls: opts.callCount() - callsBefore, error: String(err) }
failedAt = node.id
}
await saveState(opts.statePath, state)
}
return { state, dirs, failedAt }
}def run_workflow(specs: list[NodeSpec], opts: EngineOptions) -> WorkflowResult:
state = load_state(opts.state_path, opts.run_id)
fingerprints: dict[str, str] = {}
dirs: dict[str, str] = {}
failed_at: str | None = None
for node in topo_sort(specs):
fp = fingerprint(node, [fingerprints.get(d, "") for d in node.deps])
fingerprints[node.id] = fp
out_dir = f"{opts.cache_dir}/{node.id}/{fp}"
dirs[node.id] = out_dir
# Upstream unfinished means incomplete input downstream; running only wastes money.
if failed_at:
state["nodes"][node.id] = {"fingerprint": fp, "status": "blocked", "ms": 0, "calls": 0, "costCny": 0}
continue
# Idempotency check: look only at whether the artifacts exist, never at the state file.
if all_exist(out_dir, node.outputs):
state["nodes"][node.id] = {"fingerprint": fp, "status": "done", "source": "cache", "ms": 0, "calls": 0}
save_state(opts.state_path, state) # persist per step, so a kill loses only one node
continue
started, calls_before = time.time(), opts.call_count()
try:
note = node.run(Ctx(out_dir=out_dir, dep_dir=dirs.get, record=opts.on_cost)).get("note")
state["nodes"][node.id] = {"fingerprint": fp, "status": "done", "source": "executed", "note": note,
"ms": int((time.time() - started) * 1000), "calls": opts.call_count() - calls_before}
except Exception as err:
# Persist failed nodes too: the money they spent is real, and partial artifacts stay.
state["nodes"][node.id] = {"fingerprint": fp, "status": "failed", "error": str(err),
"ms": int((time.time() - started) * 1000), "calls": opts.call_count() - calls_before}
failed_at = node.id
save_state(opts.state_path, state)
return WorkflowResult(state=state, dirs=dirs, failed_at=failed_at)In practice: the first run executes all six nodes; the second hits the cache on all six with zero paid calls:
skip script cache hit e5eba27d2715034e
skip assets cache hit 3d4e77e25b7af76f
skip frames cache hit 6a17ff8efdb8e2b8
skip clips cache hit a276b932372bc7c6
skip voice cache hit 182a684769cb79e8
skip compose cache hit be81c7a6875a8931Inject a failure at the fourth node and then resume, and you get the direct answer to yesterday's pain: the first three nodes hit cache, 0 calls, CNY 0, and real execution begins at the fourth. Yesterday's wasted CNY 3.1250 is 0 today.
Note one thing in the main loop that is easy to omit: state must be persisted immediately after each node, not written once at the end. Written at the end, a hard kill, a power loss or an evicted container loses all of it — and none of those are rare during a video task that runs for a quarter of an hour.
And one boundary to state honestly: this course's idempotency granularity is the node, not the shot. Four shots live in one node, so a failure on the third redoes all four and the first two's money is wasted. Splitting to one node per shot is thriftier, at the price of a much larger task graph and more complex scheduling. That is the classic trade-off between parallel granularity and failure radius, and D9 tackles it head on.
The ledger: which node spent whose money
With node state in hand, the cost ledger comes almost free — each node records which provider it called, how many times, how much it cost and what it produced. Print a table at the end:
Ledger (by node)
node status source elapsed paid calls estimated fingerprint
script ok cache 0ms 0 CNY 0.0000 e5eba27d2715034e
assets ok cache 0ms 0 CNY 0.0000 dd68d1cf67dfdb0b
frames ok cache 0ms 0 CNY 0.0000 6fccd700817dfc5c
clips ok exec 2666ms 3 CNY 10.4950 14741e30ae0d75c0
voice ok exec 258ms 5 CNY 0.0193 9fa591ead4503587
compose ok exec 2730ms 0 CNY 0.0000 e4a2658619e63c9e
total 5654ms 8 CNY 10.5143
real spend this run: video CNY 10.4950, tts CNY 0.0193This table has two columns yesterday's did not, and those two columns are today's entire result: source tells you whether the node really ran or hit the cache, and fingerprint lets any artifact be traced back to which run, which version and which input produced it.
One rule for reading it: some columns are reproducible and some are not, so do not copy the two kinds together. Elapsed time depends on your machine and gives ten different numbers in ten runs; fingerprint, source and paid call count are deterministic — the same input necessarily computes the same fingerprint, and the second run's call count is necessarily 0. So acceptance watches those columns and treats elapsed time as a reference. Not knowing which numbers should reproduce is the most common way to misread someone else's performance report.
The paid-calls column deserves emphasis: it must be counted, never inferred. "It hit cache so it should be 0" is an inference, and an inference becomes a lie after one careless code change. The right way is a counter wrapped around the four providers, incremented on every real call. A metric that cannot prove anything is worse than no metric.
Offline, the ledger's amounts are estimates — usage converted at published unit prices, with the speech tier approximated from a resource package. Estimating is fine, but it must be labeled as an estimate. How to use this ledger to save money — draft tier versus final tier, model routing, budget circuit breakers — is D12; today we only keep the books straight.
When to switch to something off the shelf
One contrarian note to close: the engine you wrote today is not meant to be a general orchestration system.
It is under three hundred lines and does four things: topological sort, fingerprinting, cache hits, state persistence. No distributed scheduling, no retry policy, no cron triggers, no visualization, no multi-tenancy, no permissions. Every one of those absences is deliberate, because those four things are exactly what you need right now, and writing them yourself buys you a thorough understanding of why an idempotency key must include dependency fingerprints and why state must be persisted per step. That understanding transfers to any off-the-shelf engine, whereas someone who only clicks buttons is helpless the moment the framework changes.
So when should you switch? Three signals; any one of them warrants a serious evaluation.
One: you need cross-machine scheduling. In-process scheduling on one machine no longer holds, and nodes must be dispatched to several machines. Implementing distributed scheduling yourself grows exponentially in complexity, and not switching here is reinventing the wheel.
Two: you need human-in-the-loop nodes. Something like "generate, then wait for a human review before continuing" requires a flow that suspends for hours or days, with state externalized to a database rather than a JSON file. D10's review desk is where you feel this need for the first time.
Three: non-engineers need to see it. Operations needs to know which step an episode is stuck at and to click retry themselves. At that point what you need is not an engine but a product with an interface.
With none of the three present, keep your own three hundred lines. Adopting a heavyweight orchestration framework too early costs you a detour around its abstractions on every business change, while the payoff arrives only at scale.
Source Reading
Hands-On Lab
Before you start, dig out yesterday's pain report and keep the "money already spent" figure to hand — today's acceptance is watching it become 0. The lab persists state to a JSON file; no database and no API key needed.
- Run the same command twice in a row, compare the two ledgers, and confirm the second run's paid call count is 0 rather than merely "no errors."
- Delete one artifact file from one node's cache by hand and run again; confirm that node redoes its work while the others still hit.
- Inject a failure at the fourth node, read the spend on its ledger row and the status of the two downstream rows, then resume and confirm execution starts at the fourth node.
- Bump one node's implementation version and run again; confirm it and everything downstream invalidate while its upstream is unaffected.
- Change the sentence and the run id, run once, put the two fingerprint columns side by side, and say why all six changed.
Interview Questions
Today's 3 questions are in the question bank below, focused on separating scheduling from business logic, idempotency key design, and what resumption must persist. Read the analysis before the key points — practicing the derivation beats memorizing them. The cn / global labels let you pick by target market.
Checklist and Tomorrow
- I can implement a minimal task-graph executor that schedules nodes by dependency and records each node's state
- I can make every node idempotent: the same input doesn't spend money twice, and a rerun only completes the unfinished parts
- I can log each run's outputs and cost into a ledger, traceable by run id
- I can name the four things an idempotency key must include and the four it must never include
- I can explain what the cache layer and the state layer each provide, and why they are separate
- All 5 lab acceptance criteria pass
- I can answer at least 2 of the 3 interview questions without looking at the key points
Tomorrow (D9) we start several episodes at once. Today solved "do not do the same thing twice"; tomorrow solves "how to do different things simultaneously without blowing through the vendor's quota" — the speech API allows twenty requests per minute for topped-up accounts, and one episode with forty storyboard entries means forty syntheses, so without a gate you will hit the limit. Why idempotency before concurrency? Because concurrency only burns money faster, and turning it on while every failure still restarts from scratch multiplies yesterday's pain by the number of episodes.
Interview questions
How do you make a node that calls a paid generation API idempotent? What belongs in the cache key and what does not?怎么让一个会调用付费接口的生成节点是幂等的?缓存键里该放什么、不该放什么?
Common in ChinaCommon overseasIntermediate#idempotency#caching#workflow-engineHow to reason about it · think before answering
- The discriminator is the second half: what must not go in. People who only say 'hash the inputs' have usually never been burned by a cache. The two failure modes point in opposite directions: never hitting, and hitting when it should not.
- State the criterion first: include everything that changes the artifact, exclude everything that changes every run without affecting the artifact. Both lists fall out of that.
- Include four things: node id, implementation version, this node's own inputs (model id, prompt, duration, resolution), and the fingerprints of all dependencies. The version and the dependency fingerprints are the two people forget — miss the version and new code reads old artifacts; miss the dependencies and an upstream script change never propagates.
- Exclude: run id, timestamps, random values, absolute paths, and anything carrying a hostname or temp directory. Any of those makes every key new, and you will blame the cache instead of the key.
- Two implementation details worth volunteering: decide 'is it done' by checking the artifacts on disk, not the state file, because files get deleted by hand; and think about granularity — four shots in one node means one failed shot redoes all four, while finer granularity saves money at the cost of a much larger graph.
- Expect the follow-up 'does hashing dependency keys over-invalidate'. Yes. An upstream wording change that produces an identical artifact still invalidates downstream. Hashing the dependency's artifact content instead is tighter but requires reading the artifact every time — worth it for small files, not for large videos.
分析过程 · 先想清楚再作答
- 这题的区分度全在「不该放什么」那一半。只答「把输入哈希一下」的人,通常没在真实项目里被缓存坑过——缓存的两种病方向相反,一种是永远不命中,一种是命中了不该命中的。
- 先给判据:键里应该出现的,是所有会改变产物的东西;不该出现的,是所有每次都会变但不影响产物的东西。这一条能直接推出下面两张清单。
- 该放的四样:节点标识、实现版本号、本节点的输入(模型 id、提示词、时长、分辨率)、以及全部依赖的指纹。版本号和依赖指纹是最容易漏的两样——漏了版本号,改完代码读到旧产物;漏了依赖指纹,上游换了剧本你还在用旧的镜头。
- 不该放的:运行标识、时间戳、随机数、绝对路径、以及任何带机器名或临时目录的东西。放进去等于每次都是新键,你会以为缓存写坏了,其实是键设计错了。
- 还有两条落地细节值得主动说:判断「做没做完」要看磁盘上产物齐不齐,不能只信状态文件,因为文件可能被手删;以及幂等的粒度要想清楚,一个节点里跑四个镜头,第三镜失败就是四镜全重做,粒度更细更省钱但任务图会大很多。
- 可预期的追问是「依赖指纹会不会失效得太狠」。答:会。上游只是文案改了、产物其实一样,下游也会跟着重做。更省的做法是对依赖的产物内容做哈希而不是对它的键做哈希,代价是每次都要把产物读一遍——小文件划算,大视频不划算,这是要自己量的一笔账。
Key points
- One criterion: include what changes the artifact, exclude what changes every run without affecting it.
- Must include: node id, implementation version, the node's own inputs, and all dependency fingerprints.
- Must exclude: run id, timestamps, random values, absolute paths and host-specific data.
- Decide cache hits by checking artifacts on disk, not by trusting the state file.
- Choose the idempotency granularity explicitly: per node is simpler, per shot saves more but grows the graph.
答题要点
- 判据一句话:会改变产物的进键,每次都变但不影响产物的不进键。
- 必放四样:节点标识、实现版本号、本节点输入、全部依赖的指纹。
- 禁放:运行标识、时间戳、随机数、绝对路径与机器相关信息。
- 命中判定看磁盘上产物是否齐全,不能只信状态文件。
- 幂等粒度要显式选择:节点粒度实现简单,镜头粒度更省钱但图更大。
What state must you persist to support resuming a workflow? Is per-node completion status enough?要支持断点续跑,你需要持久化哪些状态?只存每个节点的完成状态够不够?
Common in ChinaCommon overseasDeep dive#workflow-engine#state-persistence#resumeHow to reason about it · think before answering
- The words 'is it enough' hint that it is not. A system storing only completion status knows a node ran, but not which version ran, so it happily skips after you change the code.
- Frame it as three questions a resume must answer: which nodes are done, are they the version I want now, and are their artifacts still there? Each maps to something you must persist.
- So beyond status you need the fingerprint and the artifact location. The fingerprint answers 'same version?', the location answers 'still there?'. Storing artifacts in a content-addressed directory named by the fingerprint collapses the third question into a file-existence check.
- Also separate two layers: the artifact cache is global and shared across runs, providing idempotency; node state is per run, providing resume. Collapse them and a new run id costs you full price again.
- Write timing is part of the answer: persist state right after each node completes, not once at the end. Hard kills, power loss and container eviction are not rare during ten-minute video jobs.
- Expect the follow-up 'do you delete a failed node's partial artifacts'. No. Keep them, and make the hit condition 'every declared output exists'. Missing one means redo, so partials are never mistaken for success.
分析过程 · 先想清楚再作答
- 题眼在「够不够」三个字,它在暗示你答案是不够。只存完成状态的系统,重跑时只知道「这个节点做过」,却答不出「做的是哪一版」——于是改完代码重跑,它照样跳过。
- 拆的角度是:续跑要回答三个问题。哪些节点做完了?它们做的是不是我现在要的那一版?它们的产物还在不在?三个问题分别对应三样要持久化的东西。
- 所以除了状态,还要存指纹和产物位置。指纹回答「是不是同一版」,产物位置回答「东西还在不在」。本课的做法是把产物按指纹落进内容寻址的目录,这样第三个问题退化成一次文件存在性检查,连记都不用记。
- 还要区分两层:产物缓存是全局的,跨运行共享,它提供的是幂等;节点状态是每次运行一份,它提供的是断点续跑。混成一层的话,换个运行标识就得重花一次钱。
- 落盘时机也是这题的一部分:状态必须在每个节点跑完之后立刻写,而不是整个流程结束再写一次。进程被强杀、机器掉电、容器被驱逐,在跑十几分钟的视频任务时并不罕见。
- 可预期的追问是「失败节点的残产物要不要删」。答:不删。留着它,下一次跑到这里判断产物齐不齐就直接得到结论;但判定必须是「outputs 里每个文件都在」才算命中,缺一个就重做,否则残产物会被当成成功的。
Key points
- Completion status alone is not enough: persist the fingerprint and artifact location to answer 'which version' and 'still present'.
- Content-addressed artifact directories reduce 'still present' to a file-existence check.
- Keep two layers: a global cache for idempotency, per-run node state for resume.
- Persist state immediately after each node, not once at the end of the run.
- Keep failed nodes' partial artifacts, but only count a hit when every declared output exists.
答题要点
- 只存完成状态不够,还要存指纹和产物位置,分别回答「哪一版」和「还在不在」。
- 产物按指纹落进内容寻址目录后,「还在不在」退化成一次文件存在性检查。
- 两层分开:缓存全局共享提供幂等,节点状态每次运行一份提供断点续跑。
- 状态要在每个节点跑完后立刻落盘,不能等整个流程结束再写。
- 失败节点的残产物保留,但命中判定必须是全部产物齐全才算数。
When should you write your own scheduler, and when should you adopt an off-the-shelf workflow engine?什么时候该自己写调度,什么时候该直接上现成的工作流引擎?
Common in ChinaCommon overseasBasic#architecture#build-vs-buy#workflow-engineHow to reason about it · think before answering
- This tests selection maturity. Both extremes lose points: building everything yourself shows no sense of leverage, adopting a framework for everything shows no judgment. The interviewer wants your switching signals.
- Give a general criterion: writing it yourself buys understanding and fit; a framework buys you past problems you have not hit yet. So the decision hinges on how much of what you need overlaps with the framework's core.
- Writing your own pays off when: single machine, a handful of nodes, a path you fixed yourself, and you only need topological ordering plus idempotency plus state persistence. That is under three hundred lines, and the understanding transfers to any engine you adopt later.
- Three signals to switch: you need cross-machine scheduling, where rolling your own scales in complexity exponentially; you need human-in-the-loop nodes, so runs suspend for hours or days and state must live in a database rather than a JSON file; or non-engineers need to see and operate it, in which case you need a product with a UI, not an engine.
- Conversely, adopting a heavy framework too early has a concrete cost: every business change must route around its abstractions, while its benefits only land at scale. Cost up front, payoff deferred.
- Expect the follow-up 'can you migrate off your own version cleanly'. Yes, if nodes were declarative from the start — dependencies, inputs, outputs, body — with scheduling and state kept out of the business code. Then migration replaces the engine, not the nodes.
分析过程 · 先想清楚再作答
- 这题考的是技术选型的成熟度。两个极端都会被扣分:什么都自己写显得不懂杠杆,什么都上框架显得没判断力。面试官想听的是你的切换信号是什么。
- 先给一条通用判据:自己写的收益是理解和贴合,框架的收益是省掉你还没遇到的那些问题。所以决策取决于「你现在需要的功能有多少落在框架的核心能力上」。
- 自己写划算的情形:单机、节点数是个位数、路径是你定死的、需要的只是拓扑排序加幂等加状态落盘这几件事。这时候自己写不到三百行,而且换来的理解是通用的——你会彻底搞懂幂等键为什么要包含依赖指纹、状态为什么必须每步落盘。
- 该换的三个信号:一是开始需要跨机器调度,自己实现分布式调度的复杂度是指数级上升的;二是开始需要人工介入节点,流程要挂起几小时甚至几天,状态必须外置到数据库而不是一个 JSON 文件;三是开始需要给非工程师看和操作,那你需要的其实是一个带界面的产品。
- 反过来说,过早引入重型框架的代价很具体:每一个业务改动都要先绕过它的抽象,而它的收益要等规模上来才兑现。这是典型的成本前置、收益后置。
- 可预期的追问是「自己写的那一套能不能平滑迁走」。答:能,前提是你从一开始就把节点定义成纯声明(依赖、输入、产物、执行体),调度和状态不侵入业务。这样迁移时改的是引擎,不是六个节点。
Key points
- Decide by how much your needs overlap the framework's core, not by a build-versus-buy stance.
- Rolling your own wins on a single machine with few nodes and a fixed path, needing only topo order, idempotency and state persistence.
- Three switching signals: cross-machine scheduling, human-in-the-loop suspension, and non-engineers needing to operate it.
- Adopting a heavy framework early costs a detour around its abstractions on every change, with benefits deferred to scale.
- Keep nodes declarative and scheduling non-invasive so a later migration replaces the engine, not the nodes.
答题要点
- 判据是你需要的功能与框架核心能力的重叠度,不是「自研还是选型」的立场。
- 自己写划算:单机、节点数少、路径固定,只需要拓扑排序加幂等加状态落盘。
- 该换的三个信号:跨机器调度、人工介入导致流程长时间挂起、非工程师要操作。
- 过早上重型框架的代价是每次业务改动都要绕过它的抽象,收益却要等规模。
- 把节点写成纯声明,调度与状态不侵入业务,将来迁移改的是引擎而不是节点。