Skip to content

Speed up loading bundles with many included files - #6195

Merged
janniklasrose merged 11 commits into
mainfrom
janniklasrose/bundle-large-number-of-includes
Sep 16, 2026
Merged

janniklasrose merged 11 commits into
mainfrom
janniklasrose/bundle-large-number-of-includes

Conversation

@janniklasrose

@janniklasrose janniklasrose commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary

Each file in include was applied as its own mutator. Entering a mutator converts the whole configuration between its typed and dynamic representations (config.Root.MarkMutatorEntryconvert.FromTyped), so file N re-converted the N-1 resources already merged — making configuration load quadratic in the number of included files.

This came from a customer bundle with ~6000 jobs, one job per YAML file (the documented layout), plus ~18000 notebooks. Their CI spent ~29 minutes in configuration load before any API call.

Fix

Apply the per-file includes within the enclosing mutator scope, so the configuration is converted once per load rather than once per file. ProcessInclude merges through config.Root.Merge, which keeps both representations in sync, so it does not need a scope of its own.

The expanded include list now goes through Mutate for the same reason: with no per-file scope, nothing converts the typed field back into the dynamic tree afterwards, and the next ToTyped would restore the raw glob patterns. An empty list is dropped rather than written as [], since the typed field is omitempty.

Results

To size this, I generated bundles matching the customer's shape — real files on disk, one 8-task job per YAML file across nested directories, 4 wildcard include patterns, plus ~18000 notebook files at the 6000-job size. Measured with bundle validate (the load path, no deploy) on an Apple M4 Max:

Job files Before After Speedup
3000 301 s 35 s 8.6x
4000 529 s 60 s 8.8x
5000 1237 s 93 s 13x
6000 1208 s 131 s 9.2x

At the customer's size, ~20 minutes becomes ~2 minutes (~89% less). Before the fix, doubling the file count roughly quadrupled the time; repeated runs at 3000 files spanned 301-316 s, so the growth is well outside measurement noise. A CPU profile at 800 files attributed 22% of samples to MarkMutatorEntry, nearly all under convert.FromTyped, plus the allocation and GC churn it caused.

Defining several jobs per file is an effective workaround for affected bundles and needs no CLI change: 1000 jobs one-per-file took 36 s, and the same jobs in a single file took 1.5 s.

What this does not fix

merge.Merge is independently quadratic — mergeMap allocates a new mapping and copies every existing entry, so file N also copies the N-1 resources already merged. That is untouched here and becomes the dominant term once this change lands, so load is much faster but not linear: at 100/200/400 files, time per file still rises from 91 µs to 120 µs to 159 µs.

A prototype that accumulates included resources into a single mapping instead of folding each file into the whole configuration was linear across 500-6000 files and reached ~1.2 s at 6000 files. It is deliberately not part of this PR: it has to preserve the merge semantics the current pairwise merge provides, and that deserves review on its own. Worth recording for whoever picks it up — a naive version that replaces instead of deep-merging on key collision drops fields from the earlier definition and collapses the accumulated locations, which also stops validate:unique_resource_keys from reporting duplicate resource keys, since it detects them by counting locations per key.

One behavior change worth flagging: per-include mutator timings are no longer recorded individually in telemetry; they roll up into ProcessRootIncludes.

Tests

Three unit tests in process_root_includes_test.go, covering what this change could plausibly break:

  • the expanded include list is visible in both the typed and dynamic configuration (removing the per-file scope initially regressed this)
  • an empty list stays absent from the dynamic tree, so bundle validate -o json does not gain an empty include (my first attempt at the above regressed this)
  • merge semantics across included files are unchanged: per-key map merge with the later file winning, sequence concatenation, and accumulated locations

Full unit suite (6235 tests) and acceptance suite (2524) pass. bundle/templates/lakeflow-integrations fails identically on a clean tree in my environment (local Python/uv build issue), unrelated to this change.

This pull request and its description were written by Isaac.

Each file in `include` was applied as its own mutator. Entering a mutator
converts the whole configuration between its typed and dynamic representations,
so file N re-converted the N-1 resources already merged, making load quadratic
in the number of included files. A bundle with 6000 job files spent ~20 minutes
in configuration load before any API call.

Apply the per-file includes within the enclosing mutator scope instead, so the
configuration is converted once per load. `ProcessInclude` merges through
`config.Root.Merge`, which keeps both representations in sync, so it does not
need a scope of its own. The expanded include list now goes through `Mutate` for
the same reason: nothing converts the typed field back afterwards.

Measured with `bundle validate` on generated bundles of 8-task jobs, one job per
file: 3000 files 301s -> 35s, 5000 files 1237s -> 93s, 6000 files 1208s -> 131s.

Merging itself is still quadratic: `mergeMap` allocates a new mapping and copies
every existing entry, so file N also copies the N-1 resources already merged.
That is untouched here and becomes the dominant term, so load is much faster but
not linear.

Co-authored-by: Isaac
@janniklasrose
janniklasrose force-pushed the janniklasrose/bundle-large-number-of-includes branch from 22fb541 to 66cb8ab Compare August 7, 2026 08:31
@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: bfd593d

Run: 35020566583

Env 💚​RECOVERED ✅​pass 🙈​skip Time
💚​ aws linux 1 275 16 5:15
💚​ aws windows 1 277 14 5:20
💚​ azure linux 1 274 16 5:20
💚​ azure windows 1 276 14 3:21
💚​ gcp linux 1 275 16 5:38
💚​ gcp windows 1 277 14 3:33
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
Top 3 slowest tests (at least 2 minutes):
duration env testname
5:18 aws windows TestAccept
3:21 gcp windows TestAccept
3:14 azure windows TestAccept

Adds an end-to-end acceptance test that plans a bundle whose resources are
defined across two include globs (resources/*.yml, resources/*/*.yml) and the
root databricks.yml, and asserts every resource appears in the plan exactly
once. A target override for one job lives in a separate file from its base
definition, verifying that a cross-file merge lands in the plan rather than
being dropped or duplicated.

This guards the include loading and merge path exercised by the switch to
ApplySeqInScopeContext: a regression that dropped or duplicated a resource, or
lost a cross-file override, would change the plan summary.

Co-authored-by: Isaac
The leaf test.toml only repeated Local/Cloud/EnvMatrix, which are inherited from
the root acceptance/test.toml. The `Local` key was since removed from TestConfig
on main (tests always run locally), so decoding the leaf now errors with an
undecoded key. Remove the leaf entirely and inherit from the root instead.

Co-authored-by: Isaac
Cover the three properties the include loading path relies on: mutators are
applied once in order, application stops at the first mutator that logs an error
(later mutators do not run), and changes made through Root.Mutate survive without
a per-mutator scope, becoming visible in both the typed and dynamic configuration
once the enclosing scope exits.

Co-authored-by: Isaac
}

// Swap out the original includes list with the expanded globs.
b.Config.Include = files

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 includes were part of validate -o json output. If so, the output is different now and can break some worfklows. I think just calling it out would be enough though, no need to have specific handling

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

we have acceptance/bundle/templates/default-python/integration_classic/out.validate.dev.json and that isn't affected by this change.

the output is different now

in which way? maybe you have a case in mind that isn't covered by that golden

Comment thread acceptance/bundle/includes/plan_multifile/databricks.yml
Comment thread bundle/config/loader/process_root_includes_test.go Outdated
Comment thread bundle/mutator.go
// (which keeps both representations in sync). A mutator that assigns to a typed field
// directly relies on the scope entry to carry that value into the dynamic tree, and
// would lose it here.
func ApplySeqInScopeContext(ctx context.Context, b *Bundle, mutators ...Mutator) {

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.

Do we really need this function? We can just call mutators in place directly like we do everywhere else

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Prefer to leave this logic here, so it's unit-testable and it keeps bundle/config/loader/process_root_includes.go cleaner

janniklasrose and others added 2 commits September 15, 2026 20:14
An unknown field in a nested included file makes bundle validate report its location; asserting that location proves source locations still point to the right included file after includes are applied within the caller's mutator scope.

Co-authored-by: Isaac <no-reply@databricks.com>
TestProcessRootIncludesMergesAcrossFiles duplicated merge coverage that the plan_multifile acceptance test already exercises (two globs, a root-file resource, and a cross-file target override), including the location-preservation assertion now covered there.

Co-authored-by: Isaac <no-reply@databricks.com>
@janniklasrose
janniklasrose force-pushed the janniklasrose/bundle-large-number-of-includes branch from 3c10a5e to efe6d6b Compare September 15, 2026 20:34
@janniklasrose
janniklasrose added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 0f99ce6 Sep 16, 2026
35 checks passed
@janniklasrose
janniklasrose deleted the janniklasrose/bundle-large-number-of-includes branch September 16, 2026 09:33
deco-sdk-tagging Bot added a commit that referenced this pull request Sep 16, 2026
## Release v1.17.0

### Notable Changes

 * Bump the direct deployment state version to 3. Clients older than v1.8.0 will reject bundles deployed with this release. ([#6713](#6713))

### CLI

 * Add an `INVALID_REFRESH_TOKEN` error code to `databricks auth token --output json` failures. ([#6684](#6684))
 * Add experimental `databricks auth docker configure` to configure Docker credential helper access for Databricks Artifact Registry. ([#6700](#6700))
 * Add experimental `databricks auth docker token` to generate Docker credentials for Databricks Artifact Registry. ([#6699](#6699))
 * `databricks environments setup-local` now reports the `E_PROVISION_CONFLICT` error code instead of the generic `E_PROVISION` when `uv sync` fails to resolve a dependency conflict. ([#6666](#6666))
 * Preserve SSH sessions across temporary tunnel disconnects, with bounded replay and backpressure for large transfers. ([#6650](#6650))
 * Allow OAuth U2M logins to override the CLI client ID with `--client-id`, profile `client_id`, or `DATABRICKS_CLIENT_ID`. ([#6594](#6594))

### Bundles

 * direct: Store a dashboard's `serialized_dashboard` in state as a content hash instead of its full contents. ([#6105](#6105))
 * direct: Fix pipelines recreation when the whole `ingestion_definition` block is added or removed. ([#6589](#6589))
 * `bundle plan`, `deploy`, and `destroy` no longer report removing `permissions`, `grants`, or secret scope ACLs from a bundle as a deletion, since it leaves the resource untouched. ([#6647](#6647))
 * `bundle plan` and `deploy` no longer list or count a resource that was already deleted remotely as a deletion, matching `bundle destroy`; applying still cleans up its stale state entry. ([#6675](#6675))
 * Fix `bundle run` failing with `expected an int, found a string` when an unrelated resource references another resource that is not deployed. `bundle run` now resolves `${resources.*}` references only within the resource being run. ([#6690](#6690))
 * Add grants support for the AI Gateway `model_service`, `mcp_service`, and `model_provider_service` resources (direct engine). ([#6635](#6635))
 * Add bundle support for the AI Gateway `mcp_service` resource (direct engine). ([#6633](#6633))
 * Add bundle support for the AI Gateway `model_provider_service` resource (direct engine). ([#6634](#6634))
 * Add bundle support for the AI Gateway `model_service` resource (direct engine). ([#6525](#6525))
 * Prevent resource drift on catalogs if `storage_root` contained a trailing slash in the URL. ([#6622](#6622))
 * Fixed a "lineage mismatch in state files" error that could occur after destroying a bundle and redeploying it from another machine. `bundle destroy` now removes the local state file so no stale lineage is left behind, and prunes the state directories it leaves empty (such as `.internal/` and `sync-snapshots/`). ([#6210](#6210), [#6685](#6685))
 * direct: `bundle plan` no longer reports a permanent update on a cluster that uses a cluster policy: when the cluster spec sets `policy_id`, a field present in the remote but absent from the bundle config is not treated as drift. ([#6531](#6531))
 * `bundle deploy` on the direct engine now reports each resource as soon as it is deployed, instead of listing them all after the deployment finishes. A deploy that fails part way through now reports the resources it did apply. ([#6361](#6361))
 * Direct-engine bundles no longer flag phantom drift on server-populated nested fields under reused config types (e.g. `external_locations` file-event-queue resource IDs, `database_instances` parent-instance refs, `apps` git credential ID). ([#6618](#6618))
 * `databricks bundle generate app` now reproduces a git-backed app's `git_repository` and `git_source` configuration instead of emitting a workspace `source_code_path`, so generating from a Git-deployed app no longer silently converts it to workspace source. ([#6656](#6656))
 * Improved configuration load time for bundles with many included files. ([#6195](#6195))
 * `bundle destroy` no longer deletes triggered job runs, leaving them untouched on the backend. ([#6672](#6672))
 * direct: resources.job\_runs: new lifecycle.triggers.on\_file\_change setting to restart the run when monitored files change. Can be set to a series of paths or globs. ([#6309](#6309))
 * Bundle summary now shows a name for Postgres branches, endpoints, databases, and roles instead of a blank Name field. ([#6663](#6663))
 * Added PyDABs (Python) support for cluster policies, dashboards, and Genie spaces. ([#6585](#6585))
 * CLI commands no longer imply that a resource whose type has no workspace URL is merely not deployed yet. ([#6583](#6583))
 * Capture the implicit dependency a vector search index has on a catalog or schema defined in the same bundle, so the catalog and schema are deployed first. ([#6655](#6655))

### Dependency Updates

 * Bump dependencies with known vulnerabilities. ([#6695](#6695))
 * Bump `github.com/databricks/databricks-sdk-go` from v0.177.0 to v0.178.0. ([#6673](#6673))
 * Bump Terraform provider from v1.131.0 to v1.132.0. ([#6671](#6671))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants