Summary
dataform compile became roughly 4x slower in @dataform/core 3.0.61 and has stayed slow through 3.0.69 (current latest). This affects the CLI only — the hosted Dataform service shows no regression on the same project.
Root cause: after the protobufjs 7.5.8 -> 7.6.3 bump in 3.0.61, Writer.finish() returns a host Buffer rather than a Uint8Array, and the CLI runs Dataform Core inside a vm2 sandbox, so the entire encoded graph now has to cross the sandbox/host boundary. Details below. The added cost scales with the volume of column descriptors in the project, so documentation-heavy projects are hit hardest. A real project of ~400 actions with ~39k documented columns went from ~3s to ~10s per compile.
Bisected with the CLI pinned at 3.0.69 throughout, varying only @dataform/core:
| @dataform/core |
compile |
| 3.0.50 |
3.0s |
| 3.0.55 |
3.1s |
| 3.0.60 |
3.2s |
| 3.0.61 |
10.3s |
| 3.0.65 |
10.1s |
| 3.0.69 |
10.2s |
Root cause
protobufjs picks its output representation at runtime: Writer.finish() returns a Node Buffer if one can be resolved, and a plain Uint8Array otherwise. The CLI runs Dataform Core inside a vm2 sandbox, so which one comes back decides whether the encoded graph crosses the sandbox/host boundary.
- 3.0.60 —
Buffer does not resolve inside the sandbox. The compiled graph is encoded into a Uint8Array and never crosses the bridge.
- 3.0.61+ — with protobufjs 7.6.3 it resolves.
finish() allocates one host Buffer (3.6 MB for the project below), which crosses the vm2 boundary, where vm2 walks it with containsDangerousConstructor and thisSafeGetOwnPropertyDescriptor.
I profiled this by running compilation in-process through vm2 using the pattern in testing/run_core.ts, which reproduces the regression outside the CLI's forked child (2.16s vs 5.86s):
|
total |
vm2 bridge |
GC |
host Buffer allocations |
| core 3.0.60 |
2.41s |
0.03s |
0.04s |
0 |
| core 3.0.69 |
5.72s |
2.33s |
0.46s |
1 x 3.6 MB |
The bridge accounts for roughly 83% of the regression. Call path of the heaviest frame, leaf first:
containsDangerousConstructor bridge.js:2208 1135 ms
thisFromOtherWithFactory bridge.js:3851
thisFromOther bridge.js:3565
apply bridge.js:2848
alloc / allocUnsafe setup-sandbox.js:934 / :948
Writer.finish @dataform/core bundle.js
main @dataform/core bundle.js
Instrumenting the host Buffer.allocUnsafe confirms the count: 0 calls on 3.0.60, exactly 1 call of 3.6 MB on 3.0.69. It is a single large object, not many small ones — the cost is vm2 inspecting it as it crosses.
This accounts for every symptom: the cost scales with the size of the encoded graph (hence with column descriptor volume and with description length), peak memory roughly triples, Node version is irrelevant, --json is irrelevant, and the hosted service is unaffected because it does not run core through this sandbox path.
The core-side changes in 3.0.61 (getContents(), .md handling in core/compilers.ts) are dormant unless a project uses them and are not involved.
Fix direction: keep the encoded graph off the vm2 boundary as a host Buffer. Returning a Uint8Array, or converting before it crosses, should restore 3.0.60 timings while keeping the dependency bump.
Reproduction
The trigger is the volume of column descriptors. The script below builds a throwaway project with N tables x M documented columns — no markdown, no getContents(), no JS, nothing but config { columns: ... } blocks — and times both versions with peak RSS:
#!/usr/bin/env bash
set -euo pipefail
DIR=$(mktemp -d)
TABLES=${TABLES:-90}
COLS=${COLS:-435}
mkdir -p "$DIR/definitions"
cat > "$DIR/workflow_settings.yaml" <<'YAML'
defaultProject: test-project
defaultLocation: EU
defaultDataset: test
defaultAssertionDataset: test_assertions
YAML
node -e "
const fs = require('fs');
const dir = process.argv[1], nt = +process.argv[2], nc = +process.argv[3];
for (let t = 1; t <= nt; t++) {
const cols = {};
for (let c = 1; c <= nc; c++) {
cols['COL_' + c] = 'Description for column ' + c + ' with some reasonable length of documentation text';
}
const select = Object.keys(cols).map(k => '1 as ' + k).join(', ');
fs.writeFileSync(
dir + '/definitions/t' + t + '.sqlx',
'config { type: \"table\", columns: ' + JSON.stringify(cols) + ' }\nselect ' + select + '\n'
);
}
" "$DIR" "$TABLES" "$COLS"
echo "project: $DIR ($TABLES tables x $COLS documented columns = $((TABLES * COLS)))"
for V in 3.0.60 3.0.61; do
echo "{\"dependencies\":{\"@dataform/core\":\"$V\"}}" > "$DIR/package.json"
(cd "$DIR" && npm install --silent >/dev/null 2>&1)
echo "--- @dataform/core $V ---"
/usr/bin/time -l dataform compile "$DIR" --json --timeout=5m >/dev/null 2>"$DIR/t.txt" || true
grep -E "real|maximum resident set size" "$DIR/t.txt" || true
done
Result at the default 90 x 435 (39,150 documented columns):
| @dataform/core |
compile |
peak RSS |
| 3.0.60 |
2.2s |
376 MB |
| 3.0.61 |
8.7s |
1074 MB |
Both symptoms reproduce from the same input, which is why I suspect this and #2219 share a root cause.
Scaling
Holding the shape fixed and varying the descriptor volume (90 tables unless noted):
| documented columns |
3.0.60 |
3.0.61 |
| 9,810 |
1.62s |
3.06s |
| 19,620 |
1.69s |
4.91s |
| 39,240 (180 tables) |
2.68s |
9.49s |
| 39,150, descriptions shortened to 1 character |
2.00s |
3.97s |
The added cost is roughly linear in total column-descriptor volume (~0.17 ms per documented column at ~75-character descriptions), and shrinks with description length, so it tracks serialized size rather than field count alone.
Control: 200 tables with no column documentation compile in 1.32s on 3.0.60 and 1.19s on 3.0.61 — unaffected. The regression only shows up once actions carry column descriptors.
Scope: CLI only, hosted service unaffected
Compiling the same project through the Dataform API with CreateCompilationResult shows no regression. Both arms are the same repository with identical content apart from the pinned core version, and both produced 400 compilation actions:
|
core version |
compile (5 runs) |
median |
| Hosted API, branch |
3.0.50 |
3.76 / 3.73 / 5.37 / 3.54 / 3.51 |
3.73s |
| Hosted API, workspace |
3.0.69 |
1.94 / 2.81 / 1.54 / 3.05 / 2.82 |
2.82s |
| Local CLI |
3.0.60 |
— |
3.2s |
| Local CLI |
3.0.69 |
— |
9.6s |
CreateCompilationResult is synchronous — querying the result immediately after a 2.98s POST returns all 400 actions — so these are compile times rather than queue times. They include the API round trip, so the hosted figures are upper bounds.
Two candidate explanations for the local slowdown that I ruled out:
- Node version. core 3.0.69 on the same project: Node 20.19.0 -> 7.8s, 22.13.0 -> 7.3s, 23.6.1 -> 9.5s, 24.14.0 -> 9.6s. Slow on all of them.
- Output serialization.
dataform compile --json 9.58s vs dataform compile (no --json) 10.43s. Not the JSON encoding of the graph.
So whatever changed in 3.0.61 costs time in the way the CLI runs compilation locally, while the hosted path is unaffected. That may be why this has not shown up in your own monitoring.
I can only observe the hosted service from outside, so I cannot confirm how it executes core. Given the root cause above, the natural explanation is that it does not run core inside a vm2 sandbox the way the CLI does, so there is no boundary for the encoded graph to cross.
Relationship to #2219
#2219 reports a memory increase at the same version boundary, and the repro above shows both the slowdown and a ~3x peak RSS increase from one input, so they are probably the same underlying change.
That issue attributes the cause to #2164 (getContents() reading markdown descriptions). I don't think that holds: the repro here never calls getContents() and contains no markdown files at all, yet reproduces both symptoms. That seems consistent with @kolina's own suggestion on that thread that it is the dependency upgrades in 3.0.61.
The root cause section above supersedes this: the shared mechanism is the encoded graph crossing the vm2 bridge as a host Buffer, which explains the memory increase as well as the slowdown.
Environment
- macOS, Node v24.14.0
@dataform/cli 3.0.69 for every run; only @dataform/core varied
- 3.0.69 is the latest published version, so there is currently no release with the fix
Worth noting for anyone else hitting this: propertyGraphs support requires >= 3.0.65, so pinning back to 3.0.60 to recover compile speed also means giving up property graphs.
Summary
dataform compilebecame roughly 4x slower in @dataform/core 3.0.61 and has stayed slow through 3.0.69 (current latest). This affects the CLI only — the hosted Dataform service shows no regression on the same project.Root cause: after the protobufjs 7.5.8 -> 7.6.3 bump in 3.0.61,
Writer.finish()returns a hostBufferrather than aUint8Array, and the CLI runs Dataform Core inside a vm2 sandbox, so the entire encoded graph now has to cross the sandbox/host boundary. Details below. The added cost scales with the volume of column descriptors in the project, so documentation-heavy projects are hit hardest. A real project of ~400 actions with ~39k documented columns went from ~3s to ~10s per compile.Bisected with the CLI pinned at 3.0.69 throughout, varying only
@dataform/core:Root cause
protobufjs picks its output representation at runtime:
Writer.finish()returns a NodeBufferif one can be resolved, and a plainUint8Arrayotherwise. The CLI runs Dataform Core inside a vm2 sandbox, so which one comes back decides whether the encoded graph crosses the sandbox/host boundary.Bufferdoes not resolve inside the sandbox. The compiled graph is encoded into aUint8Arrayand never crosses the bridge.finish()allocates one hostBuffer(3.6 MB for the project below), which crosses the vm2 boundary, where vm2 walks it withcontainsDangerousConstructorandthisSafeGetOwnPropertyDescriptor.I profiled this by running compilation in-process through vm2 using the pattern in
testing/run_core.ts, which reproduces the regression outside the CLI's forked child (2.16s vs 5.86s):BufferallocationsThe bridge accounts for roughly 83% of the regression. Call path of the heaviest frame, leaf first:
Instrumenting the host
Buffer.allocUnsafeconfirms the count: 0 calls on 3.0.60, exactly 1 call of 3.6 MB on 3.0.69. It is a single large object, not many small ones — the cost is vm2 inspecting it as it crosses.This accounts for every symptom: the cost scales with the size of the encoded graph (hence with column descriptor volume and with description length), peak memory roughly triples, Node version is irrelevant,
--jsonis irrelevant, and the hosted service is unaffected because it does not run core through this sandbox path.The core-side changes in 3.0.61 (
getContents(),.mdhandling incore/compilers.ts) are dormant unless a project uses them and are not involved.Fix direction: keep the encoded graph off the vm2 boundary as a host
Buffer. Returning aUint8Array, or converting before it crosses, should restore 3.0.60 timings while keeping the dependency bump.Reproduction
The trigger is the volume of column descriptors. The script below builds a throwaway project with N tables x M documented columns — no markdown, no
getContents(), no JS, nothing butconfig { columns: ... }blocks — and times both versions with peak RSS:Result at the default 90 x 435 (39,150 documented columns):
Both symptoms reproduce from the same input, which is why I suspect this and #2219 share a root cause.
Scaling
Holding the shape fixed and varying the descriptor volume (90 tables unless noted):
The added cost is roughly linear in total column-descriptor volume (~0.17 ms per documented column at ~75-character descriptions), and shrinks with description length, so it tracks serialized size rather than field count alone.
Control: 200 tables with no column documentation compile in 1.32s on 3.0.60 and 1.19s on 3.0.61 — unaffected. The regression only shows up once actions carry column descriptors.
Scope: CLI only, hosted service unaffected
Compiling the same project through the Dataform API with
CreateCompilationResultshows no regression. Both arms are the same repository with identical content apart from the pinned core version, and both produced 400 compilation actions:CreateCompilationResultis synchronous — querying the result immediately after a 2.98s POST returns all 400 actions — so these are compile times rather than queue times. They include the API round trip, so the hosted figures are upper bounds.Two candidate explanations for the local slowdown that I ruled out:
dataform compile --json9.58s vsdataform compile(no--json) 10.43s. Not the JSON encoding of the graph.So whatever changed in 3.0.61 costs time in the way the CLI runs compilation locally, while the hosted path is unaffected. That may be why this has not shown up in your own monitoring.
I can only observe the hosted service from outside, so I cannot confirm how it executes core. Given the root cause above, the natural explanation is that it does not run core inside a vm2 sandbox the way the CLI does, so there is no boundary for the encoded graph to cross.
Relationship to #2219
#2219 reports a memory increase at the same version boundary, and the repro above shows both the slowdown and a ~3x peak RSS increase from one input, so they are probably the same underlying change.
That issue attributes the cause to #2164 (
getContents()reading markdown descriptions). I don't think that holds: the repro here never callsgetContents()and contains no markdown files at all, yet reproduces both symptoms. That seems consistent with @kolina's own suggestion on that thread that it is the dependency upgrades in 3.0.61.The root cause section above supersedes this: the shared mechanism is the encoded graph crossing the vm2 bridge as a host
Buffer, which explains the memory increase as well as the slowdown.Environment
@dataform/cli3.0.69 for every run; only@dataform/corevariedWorth noting for anyone else hitting this:
propertyGraphssupport requires >= 3.0.65, so pinning back to 3.0.60 to recover compile speed also means giving up property graphs.