From b54278c5ba046e8eb86bc04839daed9be60a1946 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Fri, 19 Jun 2026 17:14:24 +0200 Subject: [PATCH] bundle/direct/dstate: add GetOrInitLineage, the single lineage init point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract lineage initialization into GetOrInitLineage and route Open (write branch) and UpgradeToWrite through it, so there is exactly one place the deployment lineage is read-or-generated. The lineage is now readable in read mode (before planning), not only when the engine opens the WAL for write, and the same value is written to the WAL header — so a later consumer (the Deployment Metadata Service) and the deployment engine always agree on it. No behavioral change to the persisted state: the lineage value and WAL/state output are unchanged; only the in-memory init is consolidated and exposed. Co-authored-by: Shreyas Goenka --- bundle/direct/dstate/state.go | 33 ++++++++++++++++++++---------- bundle/direct/dstate/state_test.go | 28 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index 0bf50809e16..eb41217f61d 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -147,6 +147,26 @@ func (db *DeploymentState) GetResourceID(key string) string { return db.stateIDs[key] } +// GetOrInitLineage returns the deployment lineage, generating and storing a new +// one if the state does not have one yet. It is the single place the lineage is +// initialized, shared so the direct deployment engine (when it writes state, via +// Open/UpgradeToWrite) and DMS (when it records a deployment) always agree on the +// value. +// +// DMS needs this before the engine writes state: it records the version at lock +// time, which is before the engine assigns the lineage at plan-apply time. +// Seeding db.Data.Lineage here means the subsequent write reuses the same value +// instead of minting a different one. +// +// It does not take db.mu: Open and UpgradeToWrite already hold it, and the DMS +// recorder calls it during deploy setup, before any concurrent state writes. +func (db *DeploymentState) GetOrInitLineage() string { + if db.Data.Lineage == "" { + db.Data.Lineage = uuid.New().String() + } + return db.Data.Lineage +} + 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 @@ -213,13 +233,8 @@ func (db *DeploymentState) Open(ctx context.Context, path string, withRecovery W return fmt.Errorf("failed to open WAL file %s: %w", walPath, err) } db.walFile = walFile - lineage := db.Data.Lineage - if lineage == "" { - // state file is new, does not have lineage yet; store lineage in the WAL only - lineage = uuid.New().String() - } walHead := Header{ - Lineage: lineage, + Lineage: db.GetOrInitLineage(), Serial: db.Data.Serial + 1, StateVersion: currentStateVersion, CLIVersion: build.GetInfo().Version, @@ -414,12 +429,8 @@ func (db *DeploymentState) UpgradeToWrite() error { } db.walFile = walFile - lineage := db.Data.Lineage - if lineage == "" { - lineage = uuid.New().String() - } walHead := Header{ - Lineage: lineage, + Lineage: db.GetOrInitLineage(), Serial: db.Data.Serial + 1, StateVersion: currentStateVersion, CLIVersion: build.GetInfo().Version, diff --git a/bundle/direct/dstate/state_test.go b/bundle/direct/dstate/state_test.go index b2d13c0a6c7..b52f2bf4d82 100644 --- a/bundle/direct/dstate/state_test.go +++ b/bundle/direct/dstate/state_test.go @@ -109,3 +109,31 @@ func TestDeleteState(t *testing.T) { assert.Empty(t, db3.GetResourceID("jobs.my_job")) mustFinalize(t, &db3) } + +func TestGetOrInitLineageReadableBeforeWriteAndPersisted(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + + // Fresh state opened read-only, as the deploy does before planning: no + // lineage yet. + var db DeploymentState + require.NoError(t, db.Open(t.Context(), path, WithRecovery(true), WithWrite(false))) + require.Empty(t, db.Data.Lineage) + + // GetOrInitLineage initializes the lineage and makes it readable before any + // write (i.e. before planning), and is stable across calls. + lineage := db.GetOrInitLineage() + require.NotEmpty(t, lineage) + require.Equal(t, lineage, db.GetOrInitLineage()) + + // Upgrading to write reuses the same lineage (it goes into the WAL header), + // and a write makes it durable. + require.NoError(t, db.UpgradeToWrite()) + require.NoError(t, db.SaveState("jobs.my_job", "123", map[string]string{}, nil)) + mustFinalize(t, &db) + + // Re-open: the persisted lineage matches the one read before the write. + var reopened DeploymentState + require.NoError(t, reopened.Open(t.Context(), path, WithRecovery(false), WithWrite(false))) + assert.Equal(t, lineage, reopened.Data.Lineage) + mustFinalize(t, &reopened) +}