Skip to content

Commit 1e8ba45

Browse files
bundle/deploy/lock: record deployment history in DMS behind experimental.record_deployment_history
Adds a deploymentVersionRecorder that records each deploy/destroy as a version with the Deployment Metadata Service (DMS), running alongside the existing workspace-filesystem lock inside DeploymentLock.Acquire / Release. Enabled by the experimental.record_deployment_history config field. The deployment ID is the state lineage read from resources.json, so a bundle deployment maps one-to-one to a DMS deployment record: - Acquire: after the file lock, GetDeployment(lineage); CreateDeployment only when it does not exist yet; then create the next version (server validates version_id == last_version_id + 1). A heartbeat goroutine keeps the version's lease alive. dstate.GetOrInitLineage generates and stores a lineage on the first deploy so the deploy persists the same value. - Release: complete the version (success/failure); on destroy + success, delete the deployment record. bind/unbind and --force are not supported with DMS. The version recorder uses w.Bundle from the Databricks Go SDK directly. Adds the config field, its schema annotation, and regenerates jsonschema.json. Co-authored-by: Shreyas Goenka <shreyas.goenka@databricks.com>
1 parent ff910cd commit 1e8ba45

6 files changed

Lines changed: 261 additions & 3 deletions

File tree

bundle/config/experimental.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ type Experimental struct {
4242
// Eventually this can be made the default once we have native CRUD in DABs
4343
// at which point we can deprecate or remove this field all together.
4444
SkipNamePrefixForSchema bool `json:"skip_name_prefix_for_schema,omitempty"`
45+
46+
// RecordDeploymentHistory opts the bundle into the deployment metadata
47+
// service (DMS), which records deployment history and tracks what changed
48+
// across deployments.
49+
RecordDeploymentHistory bool `json:"record_deployment_history,omitempty"`
4550
}
4651

4752
type Python struct {
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package lock
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"strconv"
9+
"time"
10+
11+
clibundle "github.com/databricks/cli/bundle"
12+
"github.com/databricks/cli/internal/build"
13+
"github.com/databricks/cli/libs/log"
14+
"github.com/databricks/databricks-sdk-go/apierr"
15+
sdkbundle "github.com/databricks/databricks-sdk-go/service/bundle"
16+
)
17+
18+
const defaultHeartbeatInterval = 30 * time.Second
19+
20+
// deploymentVersionRecorder records each deployment as a version with the
21+
// Deployment Metadata Service. It runs alongside the workspace-filesystem lock
22+
// inside DeploymentLock.Acquire / Release.
23+
//
24+
// The deployment ID is the state lineage (read from resources.json), so a
25+
// bundle deployment maps one-to-one to a DMS deployment record.
26+
type deploymentVersionRecorder struct {
27+
b *clibundle.Bundle
28+
goal Goal
29+
30+
// populated by createVersion
31+
svc sdkbundle.BundleInterface
32+
deploymentID string
33+
versionNum int64
34+
stopHeartbeat context.CancelFunc
35+
}
36+
37+
func newDeploymentVersionRecorder(b *clibundle.Bundle, goal Goal) *deploymentVersionRecorder {
38+
return &deploymentVersionRecorder{b: b, goal: goal}
39+
}
40+
41+
func (r *deploymentVersionRecorder) createVersion(ctx context.Context) error {
42+
versionType, ok := goalToVersionType(r.goal)
43+
if !ok {
44+
return fmt.Errorf("%s is not supported with the deployment metadata service", r.goal)
45+
}
46+
47+
r.svc = r.b.WorkspaceClient(ctx).Bundle
48+
49+
// The deployment ID is the state lineage. GetOrInitLineage generates one on
50+
// the first deploy and stores it so the deploy persists the same value.
51+
r.deploymentID = r.b.DeploymentBundle.StateDB.GetOrInitLineage()
52+
53+
versionID, err := createDeploymentVersion(ctx, r.b, r.svc, r.deploymentID, versionType)
54+
if err != nil {
55+
return err
56+
}
57+
58+
versionNum, err := strconv.ParseInt(versionID, 10, 64)
59+
if err != nil {
60+
return fmt.Errorf("failed to parse version ID %q: %w", versionID, err)
61+
}
62+
r.versionNum = versionNum
63+
r.stopHeartbeat = startHeartbeat(ctx, r.svc, r.deploymentID, versionID)
64+
return nil
65+
}
66+
67+
func (r *deploymentVersionRecorder) completeVersion(ctx context.Context, status DeploymentStatus) error {
68+
if r.stopHeartbeat != nil {
69+
r.stopHeartbeat()
70+
}
71+
72+
versionIDStr := strconv.FormatInt(r.versionNum, 10)
73+
versionName := fmt.Sprintf("deployments/%s/versions/%s", r.deploymentID, versionIDStr)
74+
75+
reason := sdkbundle.VersionCompleteVersionCompleteSuccess
76+
if status == DeploymentFailure {
77+
reason = sdkbundle.VersionCompleteVersionCompleteFailure
78+
}
79+
80+
_, err := r.svc.CompleteVersion(ctx, sdkbundle.CompleteVersionRequest{
81+
Name: versionName,
82+
CompletionReason: reason,
83+
})
84+
if err != nil {
85+
return err
86+
}
87+
log.Infof(ctx, "Completed deployment version: deployment=%s version=%s reason=%s", r.deploymentID, versionIDStr, reason)
88+
89+
// For destroy operations, delete the deployment record after the version
90+
// completes successfully.
91+
if status == DeploymentSuccess && r.goal == GoalDestroy {
92+
err = r.svc.DeleteDeployment(ctx, sdkbundle.DeleteDeploymentRequest{
93+
Name: "deployments/" + r.deploymentID,
94+
})
95+
if err != nil {
96+
return fmt.Errorf("failed to delete deployment: %w", err)
97+
}
98+
}
99+
100+
return nil
101+
}
102+
103+
// createDeploymentVersion ensures the deployment record exists, then creates a
104+
// new version. The deployment ID is the state lineage: we GetDeployment first
105+
// and only CreateDeployment when it does not exist yet.
106+
func createDeploymentVersion(ctx context.Context, b *clibundle.Bundle, svc sdkbundle.BundleInterface, deploymentID string, versionType sdkbundle.VersionType) (versionID string, err error) {
107+
dep, getErr := svc.GetDeployment(ctx, sdkbundle.GetDeploymentRequest{
108+
Name: "deployments/" + deploymentID,
109+
})
110+
switch {
111+
case errors.Is(getErr, apierr.ErrNotFound):
112+
// Fresh deployment: create the record and start at version 1.
113+
_, createErr := svc.CreateDeployment(ctx, sdkbundle.CreateDeploymentRequest{
114+
DeploymentId: deploymentID,
115+
Deployment: sdkbundle.Deployment{
116+
TargetName: b.Config.Bundle.Target,
117+
},
118+
})
119+
if createErr != nil {
120+
return "", fmt.Errorf("failed to create deployment: %w", createErr)
121+
}
122+
versionID = "1"
123+
case getErr != nil:
124+
return "", fmt.Errorf("failed to get deployment: %w", getErr)
125+
default:
126+
// Existing deployment: increment the last version to get the next one.
127+
lastVersion, parseErr := strconv.ParseInt(dep.LastVersionId, 10, 64)
128+
if parseErr != nil {
129+
return "", fmt.Errorf("failed to parse last_version_id %q: %w", dep.LastVersionId, parseErr)
130+
}
131+
versionID = strconv.FormatInt(lastVersion+1, 10)
132+
}
133+
134+
// The server validates that versionID equals last_version_id + 1 and returns
135+
// ABORTED otherwise (e.g. a concurrent deploy already created this version).
136+
version, versionErr := svc.CreateVersion(ctx, sdkbundle.CreateVersionRequest{
137+
Parent: "deployments/" + deploymentID,
138+
VersionId: versionID,
139+
Version: sdkbundle.Version{
140+
CliVersion: build.GetInfo().Version,
141+
VersionType: versionType,
142+
TargetName: b.Config.Bundle.Target,
143+
},
144+
})
145+
if versionErr != nil {
146+
return "", fmt.Errorf("failed to create deployment version: %w", versionErr)
147+
}
148+
149+
log.Infof(ctx, "Created deployment version: deployment=%s version=%s", deploymentID, version.VersionId)
150+
return versionID, nil
151+
}
152+
153+
// startHeartbeat starts a background goroutine that sends heartbeats to keep
154+
// the deployment version's lease alive. Returns a cancel function to stop it.
155+
func startHeartbeat(ctx context.Context, svc sdkbundle.BundleInterface, deploymentID, versionID string) context.CancelFunc {
156+
ctx, cancel := context.WithCancel(ctx)
157+
versionName := fmt.Sprintf("deployments/%s/versions/%s", deploymentID, versionID)
158+
159+
go func() {
160+
ticker := time.NewTicker(defaultHeartbeatInterval)
161+
defer ticker.Stop()
162+
163+
for {
164+
select {
165+
case <-ctx.Done():
166+
return
167+
case <-ticker.C:
168+
_, err := svc.Heartbeat(ctx, sdkbundle.HeartbeatRequest{Name: versionName})
169+
if err != nil {
170+
// A 409 ABORTED is expected if the version was completed
171+
// between the ticker firing and the heartbeat.
172+
if isAbortedErr(err) {
173+
log.Debugf(ctx, "Heartbeat stopped: version already completed")
174+
return
175+
}
176+
log.Warnf(ctx, "Failed to send deployment heartbeat: %v", err)
177+
} else {
178+
log.Debugf(ctx, "Deployment heartbeat sent: deployment=%s version=%s", deploymentID, versionID)
179+
}
180+
}
181+
}
182+
}()
183+
184+
return cancel
185+
}
186+
187+
// isAbortedErr reports whether err is an HTTP 409 ABORTED from the DMS API.
188+
func isAbortedErr(err error) bool {
189+
var apiErr *apierr.APIError
190+
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict && apiErr.ErrorCode == "ABORTED"
191+
}
192+
193+
// goalToVersionType maps a deployment goal to a DMS VersionType.
194+
// Returns false for goals not supported by the metadata service (bind/unbind).
195+
func goalToVersionType(goal Goal) (sdkbundle.VersionType, bool) {
196+
switch goal {
197+
case GoalDeploy:
198+
return sdkbundle.VersionTypeVersionTypeDeploy, true
199+
case GoalDestroy:
200+
return sdkbundle.VersionTypeVersionTypeDestroy, true
201+
default:
202+
return "", false
203+
}
204+
}

bundle/deploy/lock/lock.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,14 @@ const (
2727
)
2828

2929
// DeploymentLock manages the lifecycle of a bundle deployment.
30-
// The workspace-filesystem lock serializes concurrent deployments.
31-
// DMS version tracking will be added additively — see deployment_metadata_service.go.
30+
// The workspace-filesystem lock always serializes concurrent deployments.
31+
// DMS version tracking is additive: CreateVersion is called after acquiring
32+
// the file lock, CompleteVersion before releasing it.
3233
type DeploymentLock struct {
3334
wfs workspaceFilesystemLock
35+
// dms records deployment versions with the metadata service. It is nil
36+
// unless experimental.record_deployment_history is set.
37+
dms *deploymentVersionRecorder
3438
}
3539

3640
// NewDeploymentLock returns a DeploymentLock for the bundle.
@@ -54,15 +58,37 @@ func NewDeploymentLock(ctx context.Context, b *bundle.Bundle, goal Goal) *Deploy
5458
if enabled {
5559
l.wfs.client = b.WorkspaceClient(ctx)
5660
}
61+
if b.Config.Experimental != nil && b.Config.Experimental.RecordDeploymentHistory {
62+
l.dms = newDeploymentVersionRecorder(b, goal)
63+
}
5764
return l
5865
}
5966

6067
// Acquire acquires the deployment lock.
68+
// The workspace-filesystem lock is always acquired first.
69+
// When DMS recording is enabled, a new deployment version is created after the
70+
// file lock so the deployment is also registered server-side.
71+
//
72+
// Optimization: once the deployment is tracked by DMS (the state lineage has a
73+
// server-side record), the file lock could be skipped — creating a version
74+
// provides equivalent concurrency control via the server-side version counter.
6175
func (l *DeploymentLock) Acquire(ctx context.Context) error {
62-
return l.wfs.acquire(ctx)
76+
if err := l.wfs.acquire(ctx); err != nil {
77+
return err
78+
}
79+
if l.dms != nil {
80+
return l.dms.createVersion(ctx)
81+
}
82+
return nil
6383
}
6484

6585
// Release releases the deployment lock.
86+
// When DMS is enabled, CompleteVersion is called before releasing the file lock.
6687
func (l *DeploymentLock) Release(ctx context.Context, status DeploymentStatus) error {
88+
if l.dms != nil {
89+
if err := l.dms.completeVersion(ctx, status); err != nil {
90+
return err
91+
}
92+
}
6793
return l.wfs.release(ctx, status)
6894
}

bundle/direct/dstate/state.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,22 @@ func (db *DeploymentState) GetResourceID(key string) string {
147147
return db.stateIDs[key]
148148
}
149149

150+
// GetOrInitLineage returns the state lineage, generating and storing a new one
151+
// in memory if the state does not have a lineage yet (fresh deployment). Storing
152+
// it in Data.Lineage means a subsequent Open-for-write / UpgradeToWrite persists
153+
// the same value, so the lineage the caller observes here matches the one written
154+
// to the state file by the deploy. This lets the DMS lock derive a stable
155+
// deployment ID from the lineage even on the first deploy.
156+
func (db *DeploymentState) GetOrInitLineage() string {
157+
db.mu.Lock()
158+
defer db.mu.Unlock()
159+
160+
if db.Data.Lineage == "" {
161+
db.Data.Lineage = uuid.New().String()
162+
}
163+
return db.Data.Lineage
164+
}
165+
150166
type (
151167
// If true, then Open reads the WAL and merges it in the state. If false, and WAL is present, Open returns an error.
152168
WithRecovery bool

bundle/internal/schema/annotations.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ github.com/databricks/cli/bundle/config.Experimental:
7575
"python_wheel_wrapper":
7676
"description": |-
7777
Whether to use a Python wheel wrapper.
78+
"record_deployment_history":
79+
"description": |-
80+
Whether to record deployment history using the deployment metadata service (DMS), which tracks what changed across deployments.
7881
"scripts":
7982
"description": |-
8083
The commands to run.

bundle/schema/jsonschema.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)