From 2df14e38abbf4dc3f5884fdeb07b5a99b88f1f24 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 8 Jul 2026 14:26:59 +0200 Subject: [PATCH 1/6] bundle/direct: add a feature-flag list to the direct deployment state Bumps the direct state schema to version 3, adding a per-state feature map (Header.Features) recording each feature flag the state depends on. On load the CLI rejects any state recording a feature it does not know, pointing the user at the feature's documentation (derived from its name), so older CLIs fail with an actionable message instead of silently mishandling the state. Feature flags are defined in a static set (supportedFeatures). A dummy feature exercises the mechanism in tests; no real deployment records one yet. Co-authored-by: Isaac --- .../bundle/deploy/wal/chain-3-jobs/output.txt | 2 +- .../deploy/wal/crash-after-create/output.txt | 2 +- .../deploy/wal/header-only-wal/output.txt | 4 +- .../out.state_after_bind.direct.json | 2 +- .../bundle/migrate/basic/out.new_state.json | 2 +- .../migrate/dashboards/out.new_state.json | 2 +- .../out.state_after_migration.json | 2 +- .../bundle/migrate/grants/out.new_state.json | 2 +- .../migrate/permissions/out.new_state.json | 2 +- .../bundle/migrate/runas/out.new_state.json | 2 +- .../jobs/big_id/out.state.direct.json | 2 +- .../jobs/update/out.state.direct.json | 2 +- .../bundle/state/feature_flags/databricks.yml | 7 ++ .../bundle/state/feature_flags/out.test.toml | 3 + .../bundle/state/feature_flags/output.txt | 13 +++ .../state/feature_flags/resources.known.json | 10 ++ .../feature_flags/resources.unknown.json | 10 ++ acceptance/bundle/state/feature_flags/script | 9 ++ .../bundle/state/feature_flags/test.toml | 4 + .../bundle/state/future_version/output.txt | 2 +- .../permission_level_migration/output.txt | 2 +- .../simple/out.requests.deploy.direct.json | 2 +- bundle/direct/dstate/migrate.go | 8 ++ bundle/direct/dstate/state.go | 99 +++++++++++++++++-- bundle/direct/dstate/state_test.go | 42 ++++++++ 25 files changed, 212 insertions(+), 25 deletions(-) create mode 100644 acceptance/bundle/state/feature_flags/databricks.yml create mode 100644 acceptance/bundle/state/feature_flags/out.test.toml create mode 100644 acceptance/bundle/state/feature_flags/output.txt create mode 100644 acceptance/bundle/state/feature_flags/resources.known.json create mode 100644 acceptance/bundle/state/feature_flags/resources.unknown.json create mode 100644 acceptance/bundle/state/feature_flags/script create mode 100644 acceptance/bundle/state/feature_flags/test.toml diff --git a/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt b/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt index 7cbc9f31a85..93f6238cafd 100644 --- a/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt +++ b/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt @@ -12,7 +12,7 @@ Exit code: [KILLED] "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 1, - "state_version": 2 + "state_version": 3 } { "k": "resources.jobs.job_01", diff --git a/acceptance/bundle/deploy/wal/crash-after-create/output.txt b/acceptance/bundle/deploy/wal/crash-after-create/output.txt index e9e2c19c094..467d9571366 100644 --- a/acceptance/bundle/deploy/wal/crash-after-create/output.txt +++ b/acceptance/bundle/deploy/wal/crash-after-create/output.txt @@ -13,7 +13,7 @@ Exit code: [KILLED] >>> cat .databricks/bundle/default/resources.json.wal { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 1 diff --git a/acceptance/bundle/deploy/wal/header-only-wal/output.txt b/acceptance/bundle/deploy/wal/header-only-wal/output.txt index 88ea1e3c038..44ca12cd21d 100644 --- a/acceptance/bundle/deploy/wal/header-only-wal/output.txt +++ b/acceptance/bundle/deploy/wal/header-only-wal/output.txt @@ -8,7 +8,7 @@ Deploying resources... Exit code: [KILLED] >>> cat .databricks/bundle/default/resources.json.wal -{"state_version":2,"cli_version":"[CLI_VERSION]","lineage":"[UUID]","serial":1} +{"state_version":3,"cli_version":"[CLI_VERSION]","lineage":"[UUID]","serial":1} === Second deploy (killed again, leaves another header-only WAL) >>> errcode [CLI] bundle deploy --force-lock @@ -19,7 +19,7 @@ Deploying resources... Exit code: [KILLED] >>> cat .databricks/bundle/default/resources.json.wal -{"state_version":2,"cli_version":"[CLI_VERSION]","lineage":"[UUID]","serial":1} +{"state_version":3,"cli_version":"[CLI_VERSION]","lineage":"[UUID]","serial":1} === Third deploy (must recover and succeed, not blocked by the leftover WAL) >>> errcode [CLI] bundle deploy --force-lock diff --git a/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json b/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json index eb3a71d91ff..05614d798bf 100644 --- a/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json +++ b/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 2, diff --git a/acceptance/bundle/migrate/basic/out.new_state.json b/acceptance/bundle/migrate/basic/out.new_state.json index 7a02965e08f..e924cb4d005 100644 --- a/acceptance/bundle/migrate/basic/out.new_state.json +++ b/acceptance/bundle/migrate/basic/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 6, diff --git a/acceptance/bundle/migrate/dashboards/out.new_state.json b/acceptance/bundle/migrate/dashboards/out.new_state.json index ed623b6b487..3a405e6b088 100644 --- a/acceptance/bundle/migrate/dashboards/out.new_state.json +++ b/acceptance/bundle/migrate/dashboards/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 3, diff --git a/acceptance/bundle/migrate/default-python/out.state_after_migration.json b/acceptance/bundle/migrate/default-python/out.state_after_migration.json index c1e83c7d577..484db23fd94 100644 --- a/acceptance/bundle/migrate/default-python/out.state_after_migration.json +++ b/acceptance/bundle/migrate/default-python/out.state_after_migration.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 5, diff --git a/acceptance/bundle/migrate/grants/out.new_state.json b/acceptance/bundle/migrate/grants/out.new_state.json index 12da0aa5f9b..82b918f76d3 100644 --- a/acceptance/bundle/migrate/grants/out.new_state.json +++ b/acceptance/bundle/migrate/grants/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 9, diff --git a/acceptance/bundle/migrate/permissions/out.new_state.json b/acceptance/bundle/migrate/permissions/out.new_state.json index 522d892b949..53d41f1fe45 100644 --- a/acceptance/bundle/migrate/permissions/out.new_state.json +++ b/acceptance/bundle/migrate/permissions/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 7, diff --git a/acceptance/bundle/migrate/runas/out.new_state.json b/acceptance/bundle/migrate/runas/out.new_state.json index 8c6bb5060ce..134f7aff689 100644 --- a/acceptance/bundle/migrate/runas/out.new_state.json +++ b/acceptance/bundle/migrate/runas/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 5, diff --git a/acceptance/bundle/resources/jobs/big_id/out.state.direct.json b/acceptance/bundle/resources/jobs/big_id/out.state.direct.json index b6075147e57..7bbc03c50c2 100644 --- a/acceptance/bundle/resources/jobs/big_id/out.state.direct.json +++ b/acceptance/bundle/resources/jobs/big_id/out.state.direct.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 1, diff --git a/acceptance/bundle/resources/jobs/update/out.state.direct.json b/acceptance/bundle/resources/jobs/update/out.state.direct.json index 6d978c0208d..4897c138fc8 100644 --- a/acceptance/bundle/resources/jobs/update/out.state.direct.json +++ b/acceptance/bundle/resources/jobs/update/out.state.direct.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 1, diff --git a/acceptance/bundle/state/feature_flags/databricks.yml b/acceptance/bundle/state/feature_flags/databricks.yml new file mode 100644 index 00000000000..5134dbcc12c --- /dev/null +++ b/acceptance/bundle/state/feature_flags/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: test-bundle + +resources: + jobs: + my_job: + name: "my job" diff --git a/acceptance/bundle/state/feature_flags/out.test.toml b/acceptance/bundle/state/feature_flags/out.test.toml new file mode 100644 index 00000000000..e90b6d5d1ba --- /dev/null +++ b/acceptance/bundle/state/feature_flags/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/state/feature_flags/output.txt b/acceptance/bundle/state/feature_flags/output.txt new file mode 100644 index 00000000000..ce8839068aa --- /dev/null +++ b/acceptance/bundle/state/feature_flags/output.txt @@ -0,0 +1,13 @@ + +=== a state recording a known feature flag is accepted +>>> [CLI] bundle plan +create jobs.my_job + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +=== a state recording an unknown feature flag is rejected +>>> errcode [CLI] bundle plan +Error: the deployment state requires feature "future_feature" which this CLI does not support; upgrade to the latest CLI version and see https://docs.databricks.com/aws/en/dev-tools/bundles/future_feature for more information + + +Exit code: 1 diff --git a/acceptance/bundle/state/feature_flags/resources.known.json b/acceptance/bundle/state/feature_flags/resources.known.json new file mode 100644 index 00000000000..19caabaf2a0 --- /dev/null +++ b/acceptance/bundle/state/feature_flags/resources.known.json @@ -0,0 +1,10 @@ +{ + "state_version": 3, + "features": { + "dummy": {} + }, + "cli_version": "0.0.0-dev", + "lineage": "test-lineage", + "serial": 1, + "state": {} +} diff --git a/acceptance/bundle/state/feature_flags/resources.unknown.json b/acceptance/bundle/state/feature_flags/resources.unknown.json new file mode 100644 index 00000000000..b844b098a70 --- /dev/null +++ b/acceptance/bundle/state/feature_flags/resources.unknown.json @@ -0,0 +1,10 @@ +{ + "state_version": 3, + "features": { + "future_feature": {} + }, + "cli_version": "0.0.0-dev", + "lineage": "test-lineage", + "serial": 1, + "state": {} +} diff --git a/acceptance/bundle/state/feature_flags/script b/acceptance/bundle/state/feature_flags/script new file mode 100644 index 00000000000..bf8c39641e8 --- /dev/null +++ b/acceptance/bundle/state/feature_flags/script @@ -0,0 +1,9 @@ +mkdir -p .databricks/bundle/default + +title "a state recording a known feature flag is accepted" +cp resources.known.json .databricks/bundle/default/resources.json +trace $CLI bundle plan | contains.py "Plan:" + +title "a state recording an unknown feature flag is rejected" +cp resources.unknown.json .databricks/bundle/default/resources.json +trace errcode $CLI bundle plan 2>&1 | contains.py 'requires feature "future_feature"' "upgrade to the latest CLI version" "https://docs.databricks.com/aws/en/dev-tools/bundles/future_feature" diff --git a/acceptance/bundle/state/feature_flags/test.toml b/acceptance/bundle/state/feature_flags/test.toml new file mode 100644 index 00000000000..7fc493d51a4 --- /dev/null +++ b/acceptance/bundle/state/feature_flags/test.toml @@ -0,0 +1,4 @@ +Ignore = [".databricks"] + +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/state/future_version/output.txt b/acceptance/bundle/state/future_version/output.txt index 7cf98129ee9..0a16971f472 100644 --- a/acceptance/bundle/state/future_version/output.txt +++ b/acceptance/bundle/state/future_version/output.txt @@ -1,3 +1,3 @@ -state version 999 is newer than supported version 2; upgrade the CLI +state version 999 is newer than supported version 3; upgrade the CLI Exit code: 1 diff --git a/acceptance/bundle/state/permission_level_migration/output.txt b/acceptance/bundle/state/permission_level_migration/output.txt index d6d2e3f71ca..60068d3d3ce 100644 --- a/acceptance/bundle/state/permission_level_migration/output.txt +++ b/acceptance/bundle/state/permission_level_migration/output.txt @@ -12,7 +12,7 @@ Deployment complete! === Print state after deploy >>> print_state.py { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "test-lineage", "serial": 2, diff --git a/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json b/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json index 5326f079064..f0c60602386 100644 --- a/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json +++ b/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json @@ -240,7 +240,7 @@ "overwrite": "true" }, "body": { - "state_version": 2, + "state_version": 3, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 1, diff --git a/bundle/direct/dstate/migrate.go b/bundle/direct/dstate/migrate.go index 381d63a12eb..58f874381fd 100644 --- a/bundle/direct/dstate/migrate.go +++ b/bundle/direct/dstate/migrate.go @@ -39,6 +39,14 @@ func migrateState(db *Database) error { var migrations = map[int]func(*Database) error{ 0: migrateV1ToV2, 1: migrateV1ToV2, + 2: migrateV2ToV3, +} + +// migrateV2ToV3 upgrades to the schema that carries the feature-flag list +// (Header.Features). The list is optional and absent by default, so there is +// nothing to transform. +func migrateV2ToV3(db *Database) error { + return nil } // migrateV1ToV2 migrates permissions and grants entries from the old format diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index eb41217f61d..15b273fd8c1 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -10,6 +10,7 @@ import ( "io/fs" "os" "path/filepath" + "slices" "strings" "sync" @@ -21,12 +22,48 @@ import ( ) const ( - currentStateVersion = 2 - initialBufferSize = 64 * 1024 - maxWalEntrySize = 10 * 1024 * 1024 - walSuffix = ".wal" + // currentStateVersion is the schema version written for all direct deployments + // and the target older states are migrated up to on load. Version 3 introduced + // the per-state feature list (Header.Features): from v3 on, additive capabilities + // are recorded as feature flags rather than version bumps, so bump this only for a + // structural change older CLIs cannot read (and add a migration for it). + currentStateVersion = 3 + + initialBufferSize = 64 * 1024 + maxWalEntrySize = 10 * 1024 * 1024 + walSuffix = ".wal" ) +// docBaseURL is the documentation prefix a feature name is appended to, to form +// the link shown when a feature is unsupported. The full URL is derived from the +// feature name (docBaseURL + name), so only the name is recorded in the state. +const docBaseURL = "https://docs.databricks.com/aws/en/dev-tools/bundles/" + +// FeatureInfo is the per-feature value recorded in the state. It is intentionally +// empty: features are keyed by name and the presence of the key is what matters. +// It is a struct (rather than making Features a set/list) so per-feature fields +// can be added later without changing the state's shape. +type FeatureInfo struct{} + +// featureDummy is a placeholder feature flag used only to exercise the mechanism +// in tests; no real deployment ever records it. +const featureDummy = "dummy" + +// supportedFeatures is the set of deployment feature flags this CLI supports. To +// define a feature flag, add its name here (and document it at docBaseURL+name). +// +// A state recording a feature absent from this set was written by a newer CLI; +// checkSupportedFeatures rejects it and points the user at docBaseURL+name. +var supportedFeatures = map[string]struct{}{ + featureDummy: {}, +} + +// featureDocURL returns the documentation link for a feature, derived from its +// name so the state only needs to record the name. +func featureDocURL(name string) string { + return docBaseURL + name +} + // errStaleWAL is returned when the WAL serial is behind the expected serial. // The caller should delete the stale WAL and proceed normally. var errStaleWAL = errors.New("stale WAL") @@ -42,10 +79,17 @@ type DeploymentState struct { } type Header struct { - StateVersion int `json:"state_version"` - CLIVersion string `json:"cli_version"` - Lineage string `json:"lineage"` - Serial int `json:"serial"` + StateVersion int `json:"state_version"` + + // Features maps each feature flag this state depends on to a (currently empty) + // FeatureInfo. A CLI that does not recognize a feature refuses the state and + // points the user at the feature's documentation (see checkSupportedFeatures). + // Empty/omitted for states that use no features. + Features map[string]FeatureInfo `json:"features,omitempty"` + + CLIVersion string `json:"cli_version"` + Lineage string `json:"lineage"` + Serial int `json:"serial"` } type Database struct { @@ -167,6 +211,37 @@ func (db *DeploymentState) GetOrInitLineage() string { return db.Data.Lineage } +// recordFeatures stamps the given opted-in features into the state, so a later +// write persists them and older CLIs that don't support them are turned away. +// Each name must be a known feature (the caller opts in by passing it to Open). +func (db *Database) recordFeatures(names []string) { + for _, name := range names { + if _, ok := supportedFeatures[name]; !ok { + panic(fmt.Sprintf("internal error: unknown feature %q", name)) + } + if db.Features == nil { + db.Features = make(map[string]FeatureInfo) + } + db.Features[name] = FeatureInfo{} + } +} + +// checkSupportedFeatures rejects a state that records a feature flag this CLI does +// not understand, pointing the user at the feature's documentation. +func checkSupportedFeatures(db *Database) error { + names := make([]string, 0, len(db.Features)) + for name := range db.Features { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + if _, ok := supportedFeatures[name]; !ok { + return fmt.Errorf("the deployment state requires feature %q which this CLI does not support; upgrade to the latest CLI version and see %s for more information", name, featureDocURL(name)) + } + } + return nil +} + type ( // If true, then Open reads the WAL and merges it in the state. If false, and WAL is present, Open returns an error. WithRecovery bool @@ -176,7 +251,7 @@ type ( WithWrite bool ) -func (db *DeploymentState) Open(ctx context.Context, path string, withRecovery WithRecovery, withWrite WithWrite) error { +func (db *DeploymentState) Open(ctx context.Context, path string, withRecovery WithRecovery, withWrite WithWrite, features ...string) error { db.mu.Lock() defer db.mu.Unlock() @@ -224,6 +299,12 @@ func (db *DeploymentState) Open(ctx context.Context, path string, withRecovery W return fmt.Errorf("migrating state %s: %w", path, err) } + if err := checkSupportedFeatures(&db.Data); err != nil { + return err + } + + db.Data.recordFeatures(features) + if withWrite { if err := os.MkdirAll(filepath.Dir(walPath), 0o755); err != nil { return fmt.Errorf("failed to create state directory: %w", err) diff --git a/bundle/direct/dstate/state_test.go b/bundle/direct/dstate/state_test.go index b52f2bf4d82..7c44249207e 100644 --- a/bundle/direct/dstate/state_test.go +++ b/bundle/direct/dstate/state_test.go @@ -90,6 +90,48 @@ func TestHeaderOnlyWALRecoveryDoesNotAdvanceSerial(t *testing.T) { mustFinalize(t, &recovered) } +func TestOpenWithFeaturePersists(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + + // Opting into a feature at Open records it; the subsequent write persists it. + var db DeploymentState + require.NoError(t, db.Open(t.Context(), path, WithRecovery(true), WithWrite(true), featureDummy)) + require.NoError(t, db.SaveState("jobs.my_job", "123", map[string]string{"key": "val"}, nil)) + mustFinalize(t, &db) + + var db2 DeploymentState + require.NoError(t, db2.Open(t.Context(), path, WithRecovery(false), WithWrite(false))) + assert.Equal(t, currentStateVersion, db2.Data.StateVersion) + assert.Equal(t, map[string]FeatureInfo{featureDummy: {}}, db2.Data.Features) + mustFinalize(t, &db2) +} + +func TestOpenWithUnknownFeaturePanics(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + + var db DeploymentState + assert.Panics(t, func() { + _ = db.Open(t.Context(), path, WithRecovery(true), WithWrite(true), "no-such-feature") + }) +} + +func TestCheckSupportedFeatures(t *testing.T) { + // Known features (and none at all) are accepted; an unknown feature is rejected + // with its name and the documentation link derived from it. + require.NoError(t, checkSupportedFeatures(&Database{})) + require.NoError(t, checkSupportedFeatures(&Database{Header: Header{ + Features: map[string]FeatureInfo{featureDummy: {}}, + }})) + + err := checkSupportedFeatures(&Database{Header: Header{ + Features: map[string]FeatureInfo{"future_feature": {}}, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), `feature "future_feature"`) + assert.Contains(t, err.Error(), "upgrade to the latest CLI version") + assert.Contains(t, err.Error(), featureDocURL("future_feature")) +} + func TestDeleteState(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") From f87c9ba84b23b1bfe813b0e4f7ca6e6494aacc7b Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 8 Jul 2026 17:04:44 +0200 Subject: [PATCH 2/6] bundle/direct: record feature flags in the direct deployment state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-state feature map (Header.Features) recording each feature flag a state depends on. The baseline state version stays 2; a state that records a feature is written at version 3 so an older CLI (which reads only up to 2) refuses it instead of silently ignoring the feature. On load, a version-3 state with no features is treated as version 2, so a later release can start writing version 3 unconditionally — the actual version bump — while CLIs with this change already tolerate such states. A CLI that loads a state recording a feature it does not know rejects it and points the user at the feature's documentation (derived from its name). Feature flags are defined in a static set (supportedFeatures); a dummy feature exercises the mechanism in tests, and no real deployment records one yet. Co-authored-by: Isaac --- .../bundle/deploy/wal/chain-3-jobs/output.txt | 2 +- .../deploy/wal/crash-after-create/output.txt | 2 +- .../deploy/wal/header-only-wal/output.txt | 4 +-- .../out.state_after_bind.direct.json | 2 +- .../bundle/migrate/basic/out.new_state.json | 2 +- .../migrate/dashboards/out.new_state.json | 2 +- .../out.state_after_migration.json | 2 +- .../bundle/migrate/grants/out.new_state.json | 2 +- .../migrate/permissions/out.new_state.json | 2 +- .../bundle/migrate/runas/out.new_state.json | 2 +- .../jobs/big_id/out.state.direct.json | 2 +- .../jobs/update/out.state.direct.json | 2 +- .../permission_level_migration/output.txt | 2 +- .../simple/out.requests.deploy.direct.json | 2 +- bundle/direct/dstate/migrate.go | 27 ++++++++++++------- bundle/direct/dstate/state.go | 26 +++++++++++++----- bundle/direct/dstate/state_test.go | 11 +++++++- 17 files changed, 62 insertions(+), 32 deletions(-) diff --git a/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt b/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt index 93f6238cafd..7cbc9f31a85 100644 --- a/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt +++ b/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt @@ -12,7 +12,7 @@ Exit code: [KILLED] "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 1, - "state_version": 3 + "state_version": 2 } { "k": "resources.jobs.job_01", diff --git a/acceptance/bundle/deploy/wal/crash-after-create/output.txt b/acceptance/bundle/deploy/wal/crash-after-create/output.txt index 467d9571366..e9e2c19c094 100644 --- a/acceptance/bundle/deploy/wal/crash-after-create/output.txt +++ b/acceptance/bundle/deploy/wal/crash-after-create/output.txt @@ -13,7 +13,7 @@ Exit code: [KILLED] >>> cat .databricks/bundle/default/resources.json.wal { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 1 diff --git a/acceptance/bundle/deploy/wal/header-only-wal/output.txt b/acceptance/bundle/deploy/wal/header-only-wal/output.txt index 44ca12cd21d..88ea1e3c038 100644 --- a/acceptance/bundle/deploy/wal/header-only-wal/output.txt +++ b/acceptance/bundle/deploy/wal/header-only-wal/output.txt @@ -8,7 +8,7 @@ Deploying resources... Exit code: [KILLED] >>> cat .databricks/bundle/default/resources.json.wal -{"state_version":3,"cli_version":"[CLI_VERSION]","lineage":"[UUID]","serial":1} +{"state_version":2,"cli_version":"[CLI_VERSION]","lineage":"[UUID]","serial":1} === Second deploy (killed again, leaves another header-only WAL) >>> errcode [CLI] bundle deploy --force-lock @@ -19,7 +19,7 @@ Deploying resources... Exit code: [KILLED] >>> cat .databricks/bundle/default/resources.json.wal -{"state_version":3,"cli_version":"[CLI_VERSION]","lineage":"[UUID]","serial":1} +{"state_version":2,"cli_version":"[CLI_VERSION]","lineage":"[UUID]","serial":1} === Third deploy (must recover and succeed, not blocked by the leftover WAL) >>> errcode [CLI] bundle deploy --force-lock diff --git a/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json b/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json index 05614d798bf..eb3a71d91ff 100644 --- a/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json +++ b/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json @@ -1,5 +1,5 @@ { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 2, diff --git a/acceptance/bundle/migrate/basic/out.new_state.json b/acceptance/bundle/migrate/basic/out.new_state.json index e924cb4d005..7a02965e08f 100644 --- a/acceptance/bundle/migrate/basic/out.new_state.json +++ b/acceptance/bundle/migrate/basic/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 6, diff --git a/acceptance/bundle/migrate/dashboards/out.new_state.json b/acceptance/bundle/migrate/dashboards/out.new_state.json index 3a405e6b088..ed623b6b487 100644 --- a/acceptance/bundle/migrate/dashboards/out.new_state.json +++ b/acceptance/bundle/migrate/dashboards/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 3, diff --git a/acceptance/bundle/migrate/default-python/out.state_after_migration.json b/acceptance/bundle/migrate/default-python/out.state_after_migration.json index 484db23fd94..c1e83c7d577 100644 --- a/acceptance/bundle/migrate/default-python/out.state_after_migration.json +++ b/acceptance/bundle/migrate/default-python/out.state_after_migration.json @@ -1,5 +1,5 @@ { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 5, diff --git a/acceptance/bundle/migrate/grants/out.new_state.json b/acceptance/bundle/migrate/grants/out.new_state.json index 82b918f76d3..12da0aa5f9b 100644 --- a/acceptance/bundle/migrate/grants/out.new_state.json +++ b/acceptance/bundle/migrate/grants/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 9, diff --git a/acceptance/bundle/migrate/permissions/out.new_state.json b/acceptance/bundle/migrate/permissions/out.new_state.json index 53d41f1fe45..522d892b949 100644 --- a/acceptance/bundle/migrate/permissions/out.new_state.json +++ b/acceptance/bundle/migrate/permissions/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 7, diff --git a/acceptance/bundle/migrate/runas/out.new_state.json b/acceptance/bundle/migrate/runas/out.new_state.json index 134f7aff689..8c6bb5060ce 100644 --- a/acceptance/bundle/migrate/runas/out.new_state.json +++ b/acceptance/bundle/migrate/runas/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 5, diff --git a/acceptance/bundle/resources/jobs/big_id/out.state.direct.json b/acceptance/bundle/resources/jobs/big_id/out.state.direct.json index 7bbc03c50c2..b6075147e57 100644 --- a/acceptance/bundle/resources/jobs/big_id/out.state.direct.json +++ b/acceptance/bundle/resources/jobs/big_id/out.state.direct.json @@ -1,5 +1,5 @@ { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 1, diff --git a/acceptance/bundle/resources/jobs/update/out.state.direct.json b/acceptance/bundle/resources/jobs/update/out.state.direct.json index 4897c138fc8..6d978c0208d 100644 --- a/acceptance/bundle/resources/jobs/update/out.state.direct.json +++ b/acceptance/bundle/resources/jobs/update/out.state.direct.json @@ -1,5 +1,5 @@ { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 1, diff --git a/acceptance/bundle/state/permission_level_migration/output.txt b/acceptance/bundle/state/permission_level_migration/output.txt index 60068d3d3ce..d6d2e3f71ca 100644 --- a/acceptance/bundle/state/permission_level_migration/output.txt +++ b/acceptance/bundle/state/permission_level_migration/output.txt @@ -12,7 +12,7 @@ Deployment complete! === Print state after deploy >>> print_state.py { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "test-lineage", "serial": 2, diff --git a/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json b/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json index f0c60602386..5326f079064 100644 --- a/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json +++ b/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json @@ -240,7 +240,7 @@ "overwrite": "true" }, "body": { - "state_version": 3, + "state_version": 2, "cli_version": "[CLI_VERSION]", "lineage": "[UUID]", "serial": 1, diff --git a/bundle/direct/dstate/migrate.go b/bundle/direct/dstate/migrate.go index 58f874381fd..10ea9156138 100644 --- a/bundle/direct/dstate/migrate.go +++ b/bundle/direct/dstate/migrate.go @@ -13,11 +13,26 @@ import ( // migrateState runs all necessary migrations on the database. // It is called after loading state from disk. func migrateState(db *Database) error { + // A featureStateVersion state that records no features is equivalent to + // currentStateVersion; normalize it so this CLI treats it as current rather + // than rejecting it as too new. This lets a later release write + // featureStateVersion unconditionally without breaking CLIs with this change. + if db.StateVersion == featureStateVersion && len(db.Features) == 0 { + db.StateVersion = currentStateVersion + } + + // A featureStateVersion state that records features is readable as long as this + // CLI supports those features (checkSupportedFeatures decides). It carries no + // structural change over currentStateVersion, so there is nothing to migrate. + if db.StateVersion == featureStateVersion { + return nil + } + if db.StateVersion == currentStateVersion { return nil } - if db.StateVersion > currentStateVersion { - return fmt.Errorf("state version %d is newer than supported version %d; upgrade the CLI", db.StateVersion, currentStateVersion) + if db.StateVersion > featureStateVersion { + return fmt.Errorf("state version %d is newer than supported version %d; upgrade the CLI", db.StateVersion, featureStateVersion) } for version := db.StateVersion; version < currentStateVersion; version++ { @@ -39,14 +54,6 @@ func migrateState(db *Database) error { var migrations = map[int]func(*Database) error{ 0: migrateV1ToV2, 1: migrateV1ToV2, - 2: migrateV2ToV3, -} - -// migrateV2ToV3 upgrades to the schema that carries the feature-flag list -// (Header.Features). The list is optional and absent by default, so there is -// nothing to transform. -func migrateV2ToV3(db *Database) error { - return nil } // migrateV1ToV2 migrates permissions and grants entries from the old format diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index 15b273fd8c1..4786c997dd5 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -22,12 +22,20 @@ import ( ) const ( - // currentStateVersion is the schema version written for all direct deployments - // and the target older states are migrated up to on load. Version 3 introduced - // the per-state feature list (Header.Features): from v3 on, additive capabilities - // are recorded as feature flags rather than version bumps, so bump this only for a - // structural change older CLIs cannot read (and add a migration for it). - currentStateVersion = 3 + // currentStateVersion is the schema version written for deployments that record + // no feature flags, and the version legacy states are migrated up to on load. + currentStateVersion = 2 + + // featureStateVersion is written for states that record feature flags (see + // Header.Features) and is the newest version this CLI can read. Writing it (not + // currentStateVersion) makes older CLIs, which read only up to + // currentStateVersion, refuse a state that depends on a feature they lack. + // + // A featureStateVersion state with no features is treated as currentStateVersion + // (see migrateState). That lets a later release start writing featureStateVersion + // unconditionally — "the version bump" — while CLIs with this change already + // tolerate such states instead of rejecting them. + featureStateVersion = 3 initialBufferSize = 64 * 1024 maxWalEntrySize = 10 * 1024 * 1024 @@ -224,6 +232,12 @@ func (db *Database) recordFeatures(names []string) { } db.Features[name] = FeatureInfo{} } + // A state that records a feature must be written at featureStateVersion so an + // older CLI, which reads only up to currentStateVersion, refuses it rather than + // silently ignoring the feature it depends on. + if len(db.Features) > 0 { + db.StateVersion = featureStateVersion + } } // checkSupportedFeatures rejects a state that records a feature flag this CLI does diff --git a/bundle/direct/dstate/state_test.go b/bundle/direct/dstate/state_test.go index 7c44249207e..b3f1b23229c 100644 --- a/bundle/direct/dstate/state_test.go +++ b/bundle/direct/dstate/state_test.go @@ -99,13 +99,22 @@ func TestOpenWithFeaturePersists(t *testing.T) { require.NoError(t, db.SaveState("jobs.my_job", "123", map[string]string{"key": "val"}, nil)) mustFinalize(t, &db) + // Recording a feature writes featureStateVersion so older CLIs refuse the state. var db2 DeploymentState require.NoError(t, db2.Open(t.Context(), path, WithRecovery(false), WithWrite(false))) - assert.Equal(t, currentStateVersion, db2.Data.StateVersion) + assert.Equal(t, featureStateVersion, db2.Data.StateVersion) assert.Equal(t, map[string]FeatureInfo{featureDummy: {}}, db2.Data.Features) mustFinalize(t, &db2) } +func TestMigrateStateTreatsEmptyFeatureStateAsCurrent(t *testing.T) { + // A featureStateVersion state with no features is normalized to currentStateVersion, + // so this CLI reads it instead of rejecting it as too new. + db := &Database{Header: Header{StateVersion: featureStateVersion}} + require.NoError(t, migrateState(db)) + assert.Equal(t, currentStateVersion, db.StateVersion) +} + func TestOpenWithUnknownFeaturePanics(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") From a22f81d00527cdc870ffcf4e6db048d728503948 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 8 Jul 2026 17:28:59 +0200 Subject: [PATCH 3/6] bundle/direct/dstate: carry feature flags through WAL recovery The WAL header now records the state's schema version and feature flags, and recovery restores them. Previously a crash between opening the WAL for write and Finalize would replay the WAL onto the pre-crash state and silently drop the features the interrupted deploy recorded, downgrading the recovered state. Co-authored-by: Isaac --- bundle/direct/dstate/state.go | 13 +++++++++++-- bundle/direct/dstate/state_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index 4786c997dd5..ae526d6ff68 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -331,7 +331,8 @@ func (db *DeploymentState) Open(ctx context.Context, path string, withRecovery W walHead := Header{ Lineage: db.GetOrInitLineage(), Serial: db.Data.Serial + 1, - StateVersion: currentStateVersion, + StateVersion: db.Data.StateVersion, + Features: db.Data.Features, CLIVersion: build.GetInfo().Version, } return appendJSONLine(db.walFile, walHead) @@ -397,6 +398,7 @@ func (db *DeploymentState) mergeWalIntoState(ctx context.Context) (bool, error) lineNumber := 0 var corruptedLines [][]byte var newSerial int + var newHeader Header for scanner.Scan() { lineNumber++ @@ -421,6 +423,7 @@ func (db *DeploymentState) mergeWalIntoState(ctx context.Context) (bool, error) return false, fmt.Errorf("WAL serial (%d) is ahead of expected (%d), state may be corrupted", header.Serial, expectedSerial) } newSerial = header.Serial + newHeader = header } else { var entry WALEntry if err := json.Unmarshal(line, &entry); err != nil { @@ -465,6 +468,11 @@ func (db *DeploymentState) mergeWalIntoState(ctx context.Context) (bool, error) // "ahead of expected". See acceptance/bundle/deploy/wal/header-only-wal. if hasEntries { db.Data.Serial = newSerial + // Restore the schema version and feature flags the interrupted deploy was + // writing, so a crash between the WAL header and Finalize does not drop the + // features the recovered state depends on. + db.Data.StateVersion = newHeader.StateVersion + db.Data.Features = newHeader.Features } return hasEntries, nil @@ -527,7 +535,8 @@ func (db *DeploymentState) UpgradeToWrite() error { walHead := Header{ Lineage: db.GetOrInitLineage(), Serial: db.Data.Serial + 1, - StateVersion: currentStateVersion, + StateVersion: db.Data.StateVersion, + Features: db.Data.Features, CLIVersion: build.GetInfo().Version, } return appendJSONLine(db.walFile, walHead) diff --git a/bundle/direct/dstate/state_test.go b/bundle/direct/dstate/state_test.go index b3f1b23229c..f41f9919c72 100644 --- a/bundle/direct/dstate/state_test.go +++ b/bundle/direct/dstate/state_test.go @@ -115,6 +115,31 @@ func TestMigrateStateTreatsEmptyFeatureStateAsCurrent(t *testing.T) { assert.Equal(t, currentStateVersion, db.StateVersion) } +func TestRecoveryPreservesFeatures(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + + // Commit an initial state with no features. + var db DeploymentState + require.NoError(t, db.Open(t.Context(), path, WithRecovery(true), WithWrite(true))) + require.NoError(t, db.SaveState("jobs.my_job", "1", map[string]string{}, nil)) + mustFinalize(t, &db) + + // A feature-recording deploy writes WAL entries but crashes before Finalize, + // leaving the WAL behind. Closing walFile without finalizing simulates the crash. + var crashed DeploymentState + require.NoError(t, crashed.Open(t.Context(), path, WithRecovery(true), WithWrite(true), featureDummy)) + require.NoError(t, crashed.SaveState("jobs.my_job", "1", map[string]string{"changed": "yes"}, nil)) + require.NoError(t, crashed.walFile.Close()) + + // Recovery must carry the feature (and its version) forward from the WAL header, + // not silently drop it back to the pre-crash featureless state. + var recovered DeploymentState + require.NoError(t, recovered.Open(t.Context(), path, WithRecovery(true), WithWrite(false))) + assert.Equal(t, featureStateVersion, recovered.Data.StateVersion) + assert.Equal(t, map[string]FeatureInfo{featureDummy: {}}, recovered.Data.Features) + mustFinalize(t, &recovered) +} + func TestOpenWithUnknownFeaturePanics(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") From 38c6f2cf76e0393c547cd4d913bb3a18a3571bdb Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Wed, 8 Jul 2026 17:30:15 +0200 Subject: [PATCH 4/6] bundle/direct/dstate: simplify migrateState version branches Collapse the currentStateVersion / featureStateVersion "nothing to migrate" checks into one branch. No behavior change. Co-authored-by: Isaac --- bundle/direct/dstate/migrate.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bundle/direct/dstate/migrate.go b/bundle/direct/dstate/migrate.go index 10ea9156138..b105fd42003 100644 --- a/bundle/direct/dstate/migrate.go +++ b/bundle/direct/dstate/migrate.go @@ -13,7 +13,7 @@ import ( // migrateState runs all necessary migrations on the database. // It is called after loading state from disk. func migrateState(db *Database) error { - // A featureStateVersion state that records no features is equivalent to + // A featureStateVersion state with no features is equivalent to // currentStateVersion; normalize it so this CLI treats it as current rather // than rejecting it as too new. This lets a later release write // featureStateVersion unconditionally without breaking CLIs with this change. @@ -21,14 +21,11 @@ func migrateState(db *Database) error { db.StateVersion = currentStateVersion } - // A featureStateVersion state that records features is readable as long as this - // CLI supports those features (checkSupportedFeatures decides). It carries no - // structural change over currentStateVersion, so there is nothing to migrate. - if db.StateVersion == featureStateVersion { - return nil - } - - if db.StateVersion == currentStateVersion { + // featureStateVersion is the newest version this CLI reads. A featureStateVersion + // state carries no structural change over currentStateVersion (only the feature + // list, validated separately by checkSupportedFeatures), so there is nothing to + // migrate. Anything past it was written by a newer CLI and is refused. + if db.StateVersion == currentStateVersion || db.StateVersion == featureStateVersion { return nil } if db.StateVersion > featureStateVersion { From edd6ff32d49e00651fb7503940e0fc1689965921 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Thu, 9 Jul 2026 11:39:44 +0200 Subject: [PATCH 5/6] bundle/direct/dstate: cover and document the v3-empty-features special case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an acceptance case for a version-3 state with an empty features map (the CLI accepts it, treating it as version 2), so all three feature-flag cases — known, unknown, empty-v3 — are exercised together. Documents that the "version 3 + empty features == version 2" equivalence is special-cased to version 3 only, and renames the unit test to TestEmptyFeatureStateEquivalentToVersion2 as a forcing function: it must be removed when the baseline is actually bumped to 3, so a real v3 state is not silently reinterpreted as v2. Co-authored-by: Isaac --- .../bundle/state/feature_flags/output.txt | 6 ++++ .../resources.empty_features.json | 8 +++++ acceptance/bundle/state/feature_flags/script | 4 +++ bundle/direct/dstate/state.go | 7 +++++ bundle/direct/dstate/state_test.go | 31 +++++++++++++++---- 5 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 acceptance/bundle/state/feature_flags/resources.empty_features.json diff --git a/acceptance/bundle/state/feature_flags/output.txt b/acceptance/bundle/state/feature_flags/output.txt index ce8839068aa..dbeb66db95d 100644 --- a/acceptance/bundle/state/feature_flags/output.txt +++ b/acceptance/bundle/state/feature_flags/output.txt @@ -11,3 +11,9 @@ Error: the deployment state requires feature "future_feature" which this CLI doe Exit code: 1 + +=== a version-3 state with an empty features map is accepted (treated as version 2) +>>> [CLI] bundle plan +create jobs.my_job + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged diff --git a/acceptance/bundle/state/feature_flags/resources.empty_features.json b/acceptance/bundle/state/feature_flags/resources.empty_features.json new file mode 100644 index 00000000000..b20c97aa074 --- /dev/null +++ b/acceptance/bundle/state/feature_flags/resources.empty_features.json @@ -0,0 +1,8 @@ +{ + "state_version": 3, + "features": {}, + "cli_version": "0.0.0-dev", + "lineage": "test-lineage", + "serial": 1, + "state": {} +} diff --git a/acceptance/bundle/state/feature_flags/script b/acceptance/bundle/state/feature_flags/script index bf8c39641e8..ddff5762e05 100644 --- a/acceptance/bundle/state/feature_flags/script +++ b/acceptance/bundle/state/feature_flags/script @@ -7,3 +7,7 @@ trace $CLI bundle plan | contains.py "Plan:" title "a state recording an unknown feature flag is rejected" cp resources.unknown.json .databricks/bundle/default/resources.json trace errcode $CLI bundle plan 2>&1 | contains.py 'requires feature "future_feature"' "upgrade to the latest CLI version" "https://docs.databricks.com/aws/en/dev-tools/bundles/future_feature" + +title "a version-3 state with an empty features map is accepted (treated as version 2)" +cp resources.empty_features.json .databricks/bundle/default/resources.json +trace $CLI bundle plan | contains.py "Plan:" diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index ae526d6ff68..b85b38c1ea9 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -35,6 +35,13 @@ const ( // (see migrateState). That lets a later release start writing featureStateVersion // unconditionally — "the version bump" — while CLIs with this change already // tolerate such states instead of rejecting them. + // + // IMPORTANT: this "version 3 + empty features == version 2" equivalence is + // special-cased to version 3 only. It is scaffolding for the deferred bump, not + // a general rule. When you actually bump the baseline (currentStateVersion) to 3, + // delete featureStateVersion, delete the normalization branch in migrateState, + // and delete TestEmptyFeatureStateEquivalentToVersion2 — otherwise a real v3 + // state gets silently reinterpreted as v2. The test exists to force this cleanup. featureStateVersion = 3 initialBufferSize = 64 * 1024 diff --git a/bundle/direct/dstate/state_test.go b/bundle/direct/dstate/state_test.go index f41f9919c72..b984ab3d4a5 100644 --- a/bundle/direct/dstate/state_test.go +++ b/bundle/direct/dstate/state_test.go @@ -107,12 +107,31 @@ func TestOpenWithFeaturePersists(t *testing.T) { mustFinalize(t, &db2) } -func TestMigrateStateTreatsEmptyFeatureStateAsCurrent(t *testing.T) { - // A featureStateVersion state with no features is normalized to currentStateVersion, - // so this CLI reads it instead of rejecting it as too new. - db := &Database{Header: Header{StateVersion: featureStateVersion}} - require.NoError(t, migrateState(db)) - assert.Equal(t, currentStateVersion, db.StateVersion) +// TestEmptyFeatureStateEquivalentToVersion2 documents and pins the special case +// that a version-3 state with no features is read as version 2. This is scaffolding +// for the deferred version bump, special-cased to version 3 only (see the +// featureStateVersion doc comment). +// +// When the baseline is actually bumped to 3, this equivalence must go away — a real +// version-3 state must not be reinterpreted as 2. This test is the forcing function: +// it fails once featureStateVersion is removed or the normalization changes, making +// the author decide what the post-bump behavior should be. +func TestEmptyFeatureStateEquivalentToVersion2(t *testing.T) { + // The special case applies to featureStateVersion (3) only, and only when the + // feature map is empty; a populated feature map keeps the version as-is. + require.Equal(t, 2, currentStateVersion, "when currentStateVersion is bumped, remove featureStateVersion and this special case") + require.Equal(t, 3, featureStateVersion) + + empty := &Database{Header: Header{StateVersion: featureStateVersion}} + require.NoError(t, migrateState(empty)) + assert.Equal(t, currentStateVersion, empty.StateVersion, "v3 + no features is read as v2") + + withFeature := &Database{Header: Header{ + StateVersion: featureStateVersion, + Features: map[string]FeatureInfo{featureDummy: {}}, + }} + require.NoError(t, migrateState(withFeature)) + assert.Equal(t, featureStateVersion, withFeature.StateVersion, "v3 + features stays v3") } func TestRecoveryPreservesFeatures(t *testing.T) { From 7a23f6ef10086103a80e5b1b1db44f02d9720024 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Thu, 9 Jul 2026 16:11:35 +0200 Subject: [PATCH 6/6] bundle/direct/dstate: use a fixed features documentation URL The unsupported-feature error now links to a single fixed documentation page (the state-features section Julia is adding) instead of deriving a per-feature URL from the feature name. There is one docs page for all features, so drop docBaseURL/featureDocURL and use a fixed featuresDocURL constant. Co-authored-by: Isaac --- .../bundle/state/feature_flags/output.txt | 2 +- acceptance/bundle/state/feature_flags/script | 2 +- bundle/direct/dstate/state.go | 22 ++++++++----------- bundle/direct/dstate/state_test.go | 4 ++-- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/acceptance/bundle/state/feature_flags/output.txt b/acceptance/bundle/state/feature_flags/output.txt index dbeb66db95d..e9279ea54f7 100644 --- a/acceptance/bundle/state/feature_flags/output.txt +++ b/acceptance/bundle/state/feature_flags/output.txt @@ -7,7 +7,7 @@ Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged === a state recording an unknown feature flag is rejected >>> errcode [CLI] bundle plan -Error: the deployment state requires feature "future_feature" which this CLI does not support; upgrade to the latest CLI version and see https://docs.databricks.com/aws/en/dev-tools/bundles/future_feature for more information +Error: the deployment state requires feature "future_feature" which this CLI does not support; upgrade to the latest CLI version and see https://docs.databricks.com/aws/en/dev-tools/bundles/state-features#features for more information Exit code: 1 diff --git a/acceptance/bundle/state/feature_flags/script b/acceptance/bundle/state/feature_flags/script index ddff5762e05..5d0f8f57693 100644 --- a/acceptance/bundle/state/feature_flags/script +++ b/acceptance/bundle/state/feature_flags/script @@ -6,7 +6,7 @@ trace $CLI bundle plan | contains.py "Plan:" title "a state recording an unknown feature flag is rejected" cp resources.unknown.json .databricks/bundle/default/resources.json -trace errcode $CLI bundle plan 2>&1 | contains.py 'requires feature "future_feature"' "upgrade to the latest CLI version" "https://docs.databricks.com/aws/en/dev-tools/bundles/future_feature" +trace errcode $CLI bundle plan 2>&1 | contains.py 'requires feature "future_feature"' "upgrade to the latest CLI version" "https://docs.databricks.com/aws/en/dev-tools/bundles/state-features#features" title "a version-3 state with an empty features map is accepted (treated as version 2)" cp resources.empty_features.json .databricks/bundle/default/resources.json diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index b85b38c1ea9..02853fb6135 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -49,10 +49,12 @@ const ( walSuffix = ".wal" ) -// docBaseURL is the documentation prefix a feature name is appended to, to form -// the link shown when a feature is unsupported. The full URL is derived from the -// feature name (docBaseURL + name), so only the name is recorded in the state. -const docBaseURL = "https://docs.databricks.com/aws/en/dev-tools/bundles/" +// featuresDocURL is the single documentation page describing deployment state +// feature flags. It is shown when a state records a feature this CLI does not +// support; it is a fixed link for all features, not derived per feature. The +// #features anchor points at the feature table; if it ever breaks, the user still +// lands on the page. +const featuresDocURL = "https://docs.databricks.com/aws/en/dev-tools/bundles/state-features#features" // FeatureInfo is the per-feature value recorded in the state. It is intentionally // empty: features are keyed by name and the presence of the key is what matters. @@ -65,20 +67,14 @@ type FeatureInfo struct{} const featureDummy = "dummy" // supportedFeatures is the set of deployment feature flags this CLI supports. To -// define a feature flag, add its name here (and document it at docBaseURL+name). +// define a feature flag, add its name here (and document it at featuresDocURL). // // A state recording a feature absent from this set was written by a newer CLI; -// checkSupportedFeatures rejects it and points the user at docBaseURL+name. +// checkSupportedFeatures rejects it and points the user at featuresDocURL. var supportedFeatures = map[string]struct{}{ featureDummy: {}, } -// featureDocURL returns the documentation link for a feature, derived from its -// name so the state only needs to record the name. -func featureDocURL(name string) string { - return docBaseURL + name -} - // errStaleWAL is returned when the WAL serial is behind the expected serial. // The caller should delete the stale WAL and proceed normally. var errStaleWAL = errors.New("stale WAL") @@ -257,7 +253,7 @@ func checkSupportedFeatures(db *Database) error { slices.Sort(names) for _, name := range names { if _, ok := supportedFeatures[name]; !ok { - return fmt.Errorf("the deployment state requires feature %q which this CLI does not support; upgrade to the latest CLI version and see %s for more information", name, featureDocURL(name)) + return fmt.Errorf("the deployment state requires feature %q which this CLI does not support; upgrade to the latest CLI version and see %s for more information", name, featuresDocURL) } } return nil diff --git a/bundle/direct/dstate/state_test.go b/bundle/direct/dstate/state_test.go index b984ab3d4a5..87b183a708b 100644 --- a/bundle/direct/dstate/state_test.go +++ b/bundle/direct/dstate/state_test.go @@ -170,7 +170,7 @@ func TestOpenWithUnknownFeaturePanics(t *testing.T) { func TestCheckSupportedFeatures(t *testing.T) { // Known features (and none at all) are accepted; an unknown feature is rejected - // with its name and the documentation link derived from it. + // with its name and the fixed features documentation link. require.NoError(t, checkSupportedFeatures(&Database{})) require.NoError(t, checkSupportedFeatures(&Database{Header: Header{ Features: map[string]FeatureInfo{featureDummy: {}}, @@ -182,7 +182,7 @@ func TestCheckSupportedFeatures(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), `feature "future_feature"`) assert.Contains(t, err.Error(), "upgrade to the latest CLI version") - assert.Contains(t, err.Error(), featureDocURL("future_feature")) + assert.Contains(t, err.Error(), featuresDocURL) } func TestDeleteState(t *testing.T) {