Skip to content

Commit 71a41d9

Browse files
bundle/statemgmt: add StateReader abstraction for file and DMS state
Introduce a StateReader interface with two implementations that populate the direct engine's resource-state DB: - file-based, reading the local resources.json (delegates to DeploymentState.Open, preserving WAL recovery + migration) - DMS-based, reading from the deployment metadata service via the SDK Bundle.ListResourcesAll This is a self-contained, drop-in abstraction with unit tests; it is not yet wired into the deploy path. Integration (selecting the reader by managed-state and sourcing the deployment ID) follows once the DMS lock and op-reporting PRs land. Co-authored-by: Isaac
1 parent a649259 commit 71a41d9

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

bundle/statemgmt/statereader.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package statemgmt
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/databricks/cli/bundle/direct/dstate"
9+
sdkbundle "github.com/databricks/databricks-sdk-go/service/bundle"
10+
)
11+
12+
// StateReader populates the deployment resource-state DB used by the direct
13+
// engine's plan/apply path. It abstracts where the state comes from: the local
14+
// resources.json file (the historical source) or the deployment metadata
15+
// service (DMS), which owns the state server-side when the bundle is opted into
16+
// managed state.
17+
//
18+
// Both implementations leave the DB open in read mode. The plan path may later
19+
// upgrade it to write mode; see dstate.DeploymentState.UpgradeToWrite.
20+
type StateReader interface {
21+
// Load opens db and populates it with the deployment's resource state.
22+
Load(ctx context.Context, db *dstate.DeploymentState) error
23+
}
24+
25+
// fileStateReader reads resource state from the local resources.json file.
26+
type fileStateReader struct {
27+
path string
28+
}
29+
30+
// NewFileStateReader returns a StateReader backed by the local resources.json
31+
// file at path. This is the historical (non-DMS) source of resource state.
32+
func NewFileStateReader(path string) StateReader {
33+
return &fileStateReader{path: path}
34+
}
35+
36+
func (r *fileStateReader) Load(ctx context.Context, db *dstate.DeploymentState) error {
37+
// Recovery replays any leftover write-ahead log from a crashed deploy; the
38+
// file reader owns the on-disk state, so recovery applies here.
39+
return db.Open(ctx, r.path, dstate.WithRecovery(true), dstate.WithWrite(false))
40+
}
41+
42+
// dmsStateReader reads resource state from the deployment metadata service.
43+
type dmsStateReader struct {
44+
client sdkbundle.BundleInterface
45+
deploymentID string
46+
47+
// path is the local resources.json path. OpenWithData records it as the
48+
// eventual write target if the plan path later upgrades the DB to write
49+
// mode; the DMS reader itself never reads from or writes to it.
50+
path string
51+
}
52+
53+
// NewDMSStateReader returns a StateReader backed by the deployment metadata
54+
// service for the deployment identified by deploymentID, which must be
55+
// non-empty. path is the local resources.json path (see dmsStateReader.path).
56+
func NewDMSStateReader(client sdkbundle.BundleInterface, deploymentID, path string) StateReader {
57+
return &dmsStateReader{client: client, deploymentID: deploymentID, path: path}
58+
}
59+
60+
func (r *dmsStateReader) Load(ctx context.Context, db *dstate.DeploymentState) error {
61+
data, err := fetchDeploymentState(ctx, r.client, r.deploymentID)
62+
if err != nil {
63+
return err
64+
}
65+
db.OpenWithData(r.path, data)
66+
return nil
67+
}
68+
69+
// fetchDeploymentState lists every resource recorded for the deployment and
70+
// assembles them into a state Database.
71+
func fetchDeploymentState(ctx context.Context, client sdkbundle.BundleInterface, deploymentID string) (dstate.Database, error) {
72+
resources, err := client.ListResourcesAll(ctx, sdkbundle.ListResourcesRequest{
73+
Parent: "deployments/" + deploymentID,
74+
})
75+
if err != nil {
76+
return dstate.Database{}, fmt.Errorf("listing resources from deployment metadata service: %w", err)
77+
}
78+
79+
// Lineage and serial are file-state concepts for detecting concurrent
80+
// local edits; under DMS the server owns versioning, so they stay empty.
81+
data := dstate.NewDatabase("", 0)
82+
for _, res := range resources {
83+
// DMS reports resource keys without the "resources." prefix (e.g.
84+
// "jobs.foo"), but the local state DB keys are fully qualified
85+
// ("resources.jobs.foo"), so prepend it here.
86+
key := "resources." + res.ResourceKey
87+
88+
var state json.RawMessage
89+
if res.State != nil {
90+
state = *res.State
91+
}
92+
93+
data.State[key] = dstate.ResourceEntry{
94+
ID: res.ResourceId,
95+
State: state,
96+
}
97+
}
98+
return data, nil
99+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package statemgmt
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/databricks/cli/bundle/direct/dstate"
12+
sdkbundle "github.com/databricks/databricks-sdk-go/service/bundle"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
type fakeBundleClient struct {
18+
sdkbundle.BundleInterface
19+
resources []sdkbundle.Resource
20+
requests []sdkbundle.ListResourcesRequest
21+
err error
22+
}
23+
24+
func (c *fakeBundleClient) ListResourcesAll(_ context.Context, req sdkbundle.ListResourcesRequest) ([]sdkbundle.Resource, error) {
25+
c.requests = append(c.requests, req)
26+
if c.err != nil {
27+
return nil, c.err
28+
}
29+
return c.resources, nil
30+
}
31+
32+
func rawState(t *testing.T, s string) *json.RawMessage {
33+
t.Helper()
34+
msg := json.RawMessage(s)
35+
return &msg
36+
}
37+
38+
func TestDMSStateReaderPopulatesStateAndPrefixesKeys(t *testing.T) {
39+
client := &fakeBundleClient{
40+
resources: []sdkbundle.Resource{
41+
{ResourceKey: "jobs.foo", ResourceId: "job-1", State: rawState(t, `{"name":"foo"}`)},
42+
{ResourceKey: "pipelines.bar", ResourceId: "pipe-2", State: rawState(t, `{"name":"bar"}`)},
43+
},
44+
}
45+
46+
var db dstate.DeploymentState
47+
reader := NewDMSStateReader(client, "dep-1", filepath.Join(t.TempDir(), "resources.json"))
48+
require.NoError(t, reader.Load(t.Context(), &db))
49+
50+
require.Len(t, client.requests, 1)
51+
assert.Equal(t, "deployments/dep-1", client.requests[0].Parent)
52+
53+
entry, ok := db.GetResourceEntry("resources.jobs.foo")
54+
require.True(t, ok)
55+
assert.Equal(t, "job-1", entry.ID)
56+
assert.JSONEq(t, `{"name":"foo"}`, string(entry.State))
57+
assert.Equal(t, "job-1", db.GetResourceID("resources.jobs.foo"))
58+
59+
entry, ok = db.GetResourceEntry("resources.pipelines.bar")
60+
require.True(t, ok)
61+
assert.Equal(t, "pipe-2", entry.ID)
62+
}
63+
64+
func TestDMSStateReaderEmptyList(t *testing.T) {
65+
client := &fakeBundleClient{}
66+
67+
var db dstate.DeploymentState
68+
reader := NewDMSStateReader(client, "dep-1", filepath.Join(t.TempDir(), "resources.json"))
69+
require.NoError(t, reader.Load(t.Context(), &db))
70+
71+
_, ok := db.GetResourceEntry("resources.jobs.foo")
72+
assert.False(t, ok)
73+
}
74+
75+
func TestDMSStateReaderNilStateBecomesEmpty(t *testing.T) {
76+
client := &fakeBundleClient{
77+
resources: []sdkbundle.Resource{
78+
{ResourceKey: "jobs.foo", ResourceId: "job-1", State: nil},
79+
},
80+
}
81+
82+
var db dstate.DeploymentState
83+
reader := NewDMSStateReader(client, "dep-1", filepath.Join(t.TempDir(), "resources.json"))
84+
require.NoError(t, reader.Load(t.Context(), &db))
85+
86+
entry, ok := db.GetResourceEntry("resources.jobs.foo")
87+
require.True(t, ok)
88+
assert.Equal(t, "job-1", entry.ID)
89+
assert.Nil(t, entry.State)
90+
}
91+
92+
func TestDMSStateReaderPropagatesListError(t *testing.T) {
93+
wantErr := errors.New("boom")
94+
client := &fakeBundleClient{err: wantErr}
95+
96+
var db dstate.DeploymentState
97+
reader := NewDMSStateReader(client, "dep-1", filepath.Join(t.TempDir(), "resources.json"))
98+
err := reader.Load(t.Context(), &db)
99+
assert.ErrorIs(t, err, wantErr)
100+
}
101+
102+
func TestFileStateReaderReadsLocalState(t *testing.T) {
103+
path := filepath.Join(t.TempDir(), "resources.json")
104+
105+
seed := dstate.NewDatabase("lineage-1", 3)
106+
seed.State["resources.jobs.foo"] = dstate.ResourceEntry{ID: "job-1", State: json.RawMessage(`{"name":"foo"}`)}
107+
content, err := json.Marshal(seed)
108+
require.NoError(t, err)
109+
require.NoError(t, os.WriteFile(path, content, 0o600))
110+
111+
var db dstate.DeploymentState
112+
reader := NewFileStateReader(path)
113+
require.NoError(t, reader.Load(t.Context(), &db))
114+
115+
assert.Equal(t, "lineage-1", db.Data.Lineage)
116+
assert.Equal(t, 3, db.Data.Serial)
117+
118+
entry, ok := db.GetResourceEntry("resources.jobs.foo")
119+
require.True(t, ok)
120+
assert.Equal(t, "job-1", entry.ID)
121+
}
122+
123+
func TestFileStateReaderMissingFileIsEmptyState(t *testing.T) {
124+
path := filepath.Join(t.TempDir(), "does-not-exist.json")
125+
126+
var db dstate.DeploymentState
127+
reader := NewFileStateReader(path)
128+
require.NoError(t, reader.Load(t.Context(), &db))
129+
130+
_, ok := db.GetResourceEntry("resources.jobs.foo")
131+
assert.False(t, ok)
132+
}

0 commit comments

Comments
 (0)