From c8f93ddd550ce7b76c4c90b79053ba9738bbd7aa Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 9 Jul 2026 14:17:18 +0000 Subject: [PATCH 01/25] Add idempotency_token to job_runs resource Job runs now send an automatic idempotency_token on run-now so a retried deployment reuses the existing run instead of triggering a duplicate. The token is the hex SHA-256 of the RunNow request (job_id cleared of the token itself), computed in DoCreate on a copy so it is never persisted to state. A user-set idempotency_token is rejected at validation time, and the local testserver now deduplicates run-now by token so the behavior is covered by tests. --- .../resources/job_runs/basic/output.txt | 1 + .../bundle/resources/job_runs/basic/test.toml | 4 - .../job_runs/job_parameters/output.txt | 1 + .../job_runs/job_parameters/test.toml | 4 - .../resources/job_runs/redeploy/output.txt | 2 + .../resources/job_runs/redeploy/test.toml | 4 - .../bundle/resources/job_runs/test.toml | 11 +++ .../validate_job_run_idempotency_token.go | 48 ++++++++++++ ...validate_job_run_idempotency_token_test.go | 57 ++++++++++++++ bundle/direct/dresources/job_run.go | 28 ++++++- bundle/direct/dresources/job_run_test.go | 78 +++++++++++++++++++ bundle/phases/initialize.go | 3 + libs/testserver/fake_workspace.go | 2 + libs/testserver/jobs.go | 12 +++ 14 files changed, 242 insertions(+), 13 deletions(-) delete mode 100644 acceptance/bundle/resources/job_runs/basic/test.toml delete mode 100644 acceptance/bundle/resources/job_runs/job_parameters/test.toml delete mode 100644 acceptance/bundle/resources/job_runs/redeploy/test.toml create mode 100644 acceptance/bundle/resources/job_runs/test.toml create mode 100644 bundle/config/validate/validate_job_run_idempotency_token.go create mode 100644 bundle/config/validate/validate_job_run_idempotency_token_test.go create mode 100644 bundle/direct/dresources/job_run_test.go diff --git a/acceptance/bundle/resources/job_runs/basic/output.txt b/acceptance/bundle/resources/job_runs/basic/output.txt index 14018b93ec6..b5872e724b0 100644 --- a/acceptance/bundle/resources/job_runs/basic/output.txt +++ b/acceptance/bundle/resources/job_runs/basic/output.txt @@ -68,6 +68,7 @@ Resources: "method": "POST", "path": "/api/2.2/jobs/run-now", "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", "job_id": [MY_JOB_ID] } } diff --git a/acceptance/bundle/resources/job_runs/basic/test.toml b/acceptance/bundle/resources/job_runs/basic/test.toml deleted file mode 100644 index 4b94d8b58e9..00000000000 --- a/acceptance/bundle/resources/job_runs/basic/test.toml +++ /dev/null @@ -1,4 +0,0 @@ -# job_runs is a direct-engine-only resource; the Terraform provider has no -# equivalent, so restrict the matrix to direct. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] -RecordRequests = true diff --git a/acceptance/bundle/resources/job_runs/job_parameters/output.txt b/acceptance/bundle/resources/job_runs/job_parameters/output.txt index bcf3f21e017..5d4761bf2b6 100644 --- a/acceptance/bundle/resources/job_runs/job_parameters/output.txt +++ b/acceptance/bundle/resources/job_runs/job_parameters/output.txt @@ -11,6 +11,7 @@ Deployment complete! "method": "POST", "path": "/api/2.2/jobs/run-now", "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", "job_id": [NUMID], "job_parameters": { "env": "prod" diff --git a/acceptance/bundle/resources/job_runs/job_parameters/test.toml b/acceptance/bundle/resources/job_runs/job_parameters/test.toml deleted file mode 100644 index 4b94d8b58e9..00000000000 --- a/acceptance/bundle/resources/job_runs/job_parameters/test.toml +++ /dev/null @@ -1,4 +0,0 @@ -# job_runs is a direct-engine-only resource; the Terraform provider has no -# equivalent, so restrict the matrix to direct. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] -RecordRequests = true diff --git a/acceptance/bundle/resources/job_runs/redeploy/output.txt b/acceptance/bundle/resources/job_runs/redeploy/output.txt index 6ec19be8bce..b39382c0a4a 100644 --- a/acceptance/bundle/resources/job_runs/redeploy/output.txt +++ b/acceptance/bundle/resources/job_runs/redeploy/output.txt @@ -30,6 +30,7 @@ Resources: "method": "POST", "path": "/api/2.2/jobs/run-now", "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", "job_id": [MY_JOB_ID], "job_parameters": { "env": "dev" @@ -118,6 +119,7 @@ Resources: "method": "POST", "path": "/api/2.2/jobs/run-now", "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", "job_id": [MY_JOB_ID], "job_parameters": { "env": "prod" diff --git a/acceptance/bundle/resources/job_runs/redeploy/test.toml b/acceptance/bundle/resources/job_runs/redeploy/test.toml deleted file mode 100644 index 4b94d8b58e9..00000000000 --- a/acceptance/bundle/resources/job_runs/redeploy/test.toml +++ /dev/null @@ -1,4 +0,0 @@ -# job_runs is a direct-engine-only resource; the Terraform provider has no -# equivalent, so restrict the matrix to direct. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] -RecordRequests = true diff --git a/acceptance/bundle/resources/job_runs/test.toml b/acceptance/bundle/resources/job_runs/test.toml new file mode 100644 index 00000000000..0a29818c6e5 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/test.toml @@ -0,0 +1,11 @@ +# job_runs is a direct-engine-only resource; the Terraform provider has no +# equivalent, so restrict the matrix to direct. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +RecordRequests = true + +# idempotency_token is a SHA-256 of the RunNow request, and the fake assigns +# job_id from a time-based counter, so the hash differs every run. Mask it so the +# run-now request goldens stay stable. +[[Repls]] +Old = '[0-9a-f]{64}' +New = '[IDEMPOTENCY_TOKEN]' diff --git a/bundle/config/validate/validate_job_run_idempotency_token.go b/bundle/config/validate/validate_job_run_idempotency_token.go new file mode 100644 index 00000000000..55ea913827e --- /dev/null +++ b/bundle/config/validate/validate_job_run_idempotency_token.go @@ -0,0 +1,48 @@ +package validate + +import ( + "cmp" + "context" + "slices" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/dyn" +) + +func ValidateJobRunIdempotencyToken() bundle.ReadOnlyMutator { + return &validateJobRunIdempotencyToken{} +} + +type validateJobRunIdempotencyToken struct{ bundle.RO } + +func (v *validateJobRunIdempotencyToken) Name() string { + return "validate:validate_job_run_idempotency_token" +} + +func (v *validateJobRunIdempotencyToken) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { + var diags diag.Diagnostics + + // idempotency_token is computed automatically from the run configuration + // (SHA-256 of the RunNow request) so retries dedupe. A user-provided value + // would be overwritten, so reject it up front. + for name, jr := range b.Config.Resources.JobRuns { + if jr.IdempotencyToken == "" { + continue + } + path := "resources.job_runs." + name + ".idempotency_token" + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "idempotency_token is computed automatically and must not be set in bundle configuration", + Paths: []dyn.Path{dyn.MustPathFromString(path)}, + Locations: b.Config.GetLocations(path), + }) + } + + // Map iteration order is randomized; sort by path for stable output. + slices.SortFunc(diags, func(x, y diag.Diagnostic) int { + return cmp.Compare(x.Paths[0].String(), y.Paths[0].String()) + }) + + return diags +} diff --git a/bundle/config/validate/validate_job_run_idempotency_token_test.go b/bundle/config/validate/validate_job_run_idempotency_token_test.go new file mode 100644 index 00000000000..1bd8fddeb90 --- /dev/null +++ b/bundle/config/validate/validate_job_run_idempotency_token_test.go @@ -0,0 +1,57 @@ +package validate + +import ( + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/libs/diag" + "github.com/databricks/databricks-sdk-go/service/jobs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func jobRunBundle(token string) *bundle.Bundle { + return &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + JobRuns: map[string]*resources.JobRun{ + "my_run": {RunNow: jobs.RunNow{JobId: 1, IdempotencyToken: token}}, + }, + }, + }, + } +} + +func TestValidateJobRunIdempotencyTokenRejectsUserValue(t *testing.T) { + diags := ValidateJobRunIdempotencyToken().Apply(t.Context(), jobRunBundle("x")) + require.Len(t, diags, 1) + assert.Equal(t, diag.Error, diags[0].Severity) + assert.Equal(t, "idempotency_token is computed automatically and must not be set in bundle configuration", diags[0].Summary) + assert.Equal(t, "resources.job_runs.my_run.idempotency_token", diags[0].Paths[0].String()) +} + +func TestValidateJobRunIdempotencyTokenAllowsUnset(t *testing.T) { + diags := ValidateJobRunIdempotencyToken().Apply(t.Context(), jobRunBundle("")) + require.Empty(t, diags) +} + +func TestValidateJobRunIdempotencyTokenReportsAllSorted(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + JobRuns: map[string]*resources.JobRun{ + "b_run": {RunNow: jobs.RunNow{JobId: 1, IdempotencyToken: "x"}}, + "a_run": {RunNow: jobs.RunNow{JobId: 2, IdempotencyToken: "y"}}, + }, + }, + }, + } + + diags := ValidateJobRunIdempotencyToken().Apply(t.Context(), b) + require.Len(t, diags, 2) + // Sorted by path, so a_run comes before b_run regardless of map order. + assert.Equal(t, "resources.job_runs.a_run.idempotency_token", diags[0].Paths[0].String()) + assert.Equal(t, "resources.job_runs.b_run.idempotency_token", diags[1].Paths[0].String()) +} diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 0a2ae0ea6af..d1ca0659153 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -2,6 +2,9 @@ package dresources import ( "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" "strconv" @@ -129,9 +132,18 @@ func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { } func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (string, *JobRunRemote, error) { + // Copy the request so the derived token is sent to the API but never + // persisted into state (state stays token-free, plans stay clean). + req := config.RunNow + token, err := idempotencyToken(req) + if err != nil { + return "", nil, err + } + req.IdempotencyToken = token + // RunNow returns only the new run id, so we return a nil remote and let the // framework read it back via DoRead. - wait, err := r.client.Jobs.RunNow(ctx, config.RunNow) + wait, err := r.client.Jobs.RunNow(ctx, req) if err != nil { return "", nil, err } @@ -159,3 +171,17 @@ func parseRunID(id string) (int64, error) { } return result, nil } + +// idempotencyToken derives a stable token from the RunNow request so that a +// retried run-now returns the existing run instead of creating a duplicate. +// The token is the hex SHA-256 of the request's JSON with idempotency_token +// cleared; hex SHA-256 is exactly 64 chars, the Jobs API maximum. +func idempotencyToken(req jobs.RunNow) (string, error) { + req.IdempotencyToken = "" + canonical, err := json.Marshal(req) + if err != nil { + return "", err + } + sum := sha256.Sum256(canonical) + return hex.EncodeToString(sum[:]), nil +} diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go new file mode 100644 index 00000000000..fd1ded7000a --- /dev/null +++ b/bundle/direct/dresources/job_run_test.go @@ -0,0 +1,78 @@ +package dresources + +import ( + "testing" + + "github.com/databricks/databricks-sdk-go/service/jobs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIdempotencyTokenIsStableHex(t *testing.T) { + run := jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "prod"}} + + got, err := idempotencyToken(run) + require.NoError(t, err) + + // hex SHA-256 is always 64 lowercase hex chars (the Jobs API maximum). + assert.Regexp(t, "^[0-9a-f]{64}$", got) + + // Deterministic: the same config yields the same token, so a retry dedupes. + again, err := idempotencyToken(run) + require.NoError(t, err) + assert.Equal(t, got, again) +} + +func TestIdempotencyTokenIgnoresPresetToken(t *testing.T) { + a, err := idempotencyToken(jobs.RunNow{JobId: 123}) + require.NoError(t, err) + b, err := idempotencyToken(jobs.RunNow{JobId: 123, IdempotencyToken: "user-supplied"}) + require.NoError(t, err) + + // The token is cleared before hashing, so a preset value cannot change it. + assert.Equal(t, a, b) +} + +func TestIdempotencyTokenChangesWithConfig(t *testing.T) { + dev, err := idempotencyToken(jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "dev"}}) + require.NoError(t, err) + prod, err := idempotencyToken(jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "prod"}}) + require.NoError(t, err) + otherJob, err := idempotencyToken(jobs.RunNow{JobId: 456}) + require.NoError(t, err) + + assert.NotEqual(t, dev, prod) // different params --> different token + assert.NotEqual(t, dev, otherJob) // different job_id --> different token +} + +func TestJobRunIdempotentCreate(t *testing.T) { + _, client := setupTestServerClient(t) + ctx := t.Context() + + // A run can only target an existing job, so create one first. + job, err := client.Jobs.Create(ctx, jobs.CreateJob{ + Name: "idempotency-job", + Tasks: []jobs.Task{{ + TaskKey: "t", + NotebookTask: &jobs.NotebookTask{NotebookPath: "/Workspace/Users/user@example.com/notebook"}, + }}, + }) + require.NoError(t, err) + + r := (&ResourceJobRun{}).New(client) + config := &JobRunState{RunNow: jobs.RunNow{JobId: job.JobId}} + + // Same config twice: the derived token is identical, so the backend returns + // the existing run instead of creating a duplicate. + id1, _, err := r.DoCreate(ctx, config) + require.NoError(t, err) + id2, _, err := r.DoCreate(ctx, config) + require.NoError(t, err) + assert.Equal(t, id1, id2, "same config must dedupe to the same run") + + // Different config: different token, so a genuinely new run is created. + other := &JobRunState{RunNow: jobs.RunNow{JobId: job.JobId, JobParameters: map[string]string{"env": "prod"}}} + id3, _, err := r.DoCreate(ctx, other) + require.NoError(t, err) + assert.NotEqual(t, id1, id3, "different config must create a new run") +} diff --git a/bundle/phases/initialize.go b/bundle/phases/initialize.go index bfa2af4124b..8d51491b30f 100644 --- a/bundle/phases/initialize.go +++ b/bundle/phases/initialize.go @@ -177,6 +177,9 @@ func Initialize(ctx context.Context, b *bundle.Bundle) { // They are set by the CLI to track the bundle deployment and must not be set by the user. validate.ValidateDeploymentFields(), + // Validate that idempotency_token is not set on job runs. It is computed automatically and must not be set by the user. + validate.ValidateJobRunIdempotencyToken(), + // Reads (dynamic): * (strings) (searches for ${resources.*} references) // Warns (TF engine) or errors (direct engine) when a cross-resource reference // points to a Terraform-only field with no DABs equivalent. diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 1c5d842f0f8..ec0854d3baa 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -169,6 +169,7 @@ type FakeWorkspace struct { Jobs map[int64]jobs.Job JobRuns map[int64]jobs.Run JobRunOutputs map[int64]jobs.RunOutput + JobRunsByToken map[string]int64 Pipelines map[string]pipelines.GetPipelineResponse PipelineUpdates map[string]bool Monitors map[string]catalog.MonitorInfo @@ -334,6 +335,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { Jobs: map[int64]jobs.Job{}, JobRuns: map[int64]jobs.Run{}, JobRunOutputs: map[int64]jobs.RunOutput{}, + JobRunsByToken: map[string]int64{}, Grants: map[string][]catalog.PrivilegeAssignment{}, Pipelines: map[string]pipelines.GetPipelineResponse{}, PipelineUpdates: map[string]bool{}, diff --git a/libs/testserver/jobs.go b/libs/testserver/jobs.go index 2d019c3e496..b6bb3fa967b 100644 --- a/libs/testserver/jobs.go +++ b/libs/testserver/jobs.go @@ -349,6 +349,14 @@ func (s *FakeWorkspace) JobsRunNow(req Request) Response { return Response{StatusCode: 404} } + // The Jobs API treats run-now as idempotent: the same idempotency_token + // returns the run already created for it instead of starting a new one. + if request.IdempotencyToken != "" { + if existing, ok := s.JobRunsByToken[request.IdempotencyToken]; ok { + return Response{Body: jobs.RunNowResponse{RunId: existing}} + } + } + runId := nextID() runName := "run-name" if job.Settings != nil && job.Settings.Name != "" { @@ -412,6 +420,10 @@ func (s *FakeWorkspace) JobsRunNow(req Request) Response { OverridingParameters: runOverridingParameters(request), } + if request.IdempotencyToken != "" { + s.JobRunsByToken[request.IdempotencyToken] = runId + } + return Response{Body: jobs.RunNowResponse{RunId: runId}} } From 4285052219754b37b33513b6d11125e1f411e114 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Fri, 10 Jul 2026 09:30:38 +0000 Subject: [PATCH 02/25] Add acceptance test for job_runs idempotent recreate Covers the idempotency token end-to-end: deploy triggers a run, the run is dropped from local state (keeping the job so job_id and the derived token stay stable), and a redeploy re-issues run-now. The backend dedupes on the token and returns the existing run, so no duplicate is created. Drop the DoCreate-level TestJobRunIdempotentCreate since the acceptance test now covers that path end-to-end; the token helper's unit tests remain. --- .../idempotent_recreate/databricks.yml | 15 +++++ .../idempotent_recreate/out.test.toml | 3 + .../job_runs/idempotent_recreate/output.txt | 61 +++++++++++++++++++ .../job_runs/idempotent_recreate/script | 33 ++++++++++ bundle/direct/dresources/job_run_test.go | 32 ---------- 5 files changed, 112 insertions(+), 32 deletions(-) create mode 100644 acceptance/bundle/resources/job_runs/idempotent_recreate/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt create mode 100644 acceptance/bundle/resources/job_runs/idempotent_recreate/script diff --git a/acceptance/bundle/resources/job_runs/idempotent_recreate/databricks.yml b/acceptance/bundle/resources/job_runs/idempotent_recreate/databricks.yml new file mode 100644 index 00000000000..6ff4be6a2ae --- /dev/null +++ b/acceptance/bundle/resources/job_runs/idempotent_recreate/databricks.yml @@ -0,0 +1,15 @@ +bundle: + name: job-runs-idempotent-recreate + +resources: + jobs: + my_job: + name: my-job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + job_runs: + my_run: + job_id: ${resources.jobs.my_job.id} diff --git a/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml b/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml new file mode 100644 index 00000000000..e90b6d5d1ba --- /dev/null +++ b/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt b/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt new file mode 100644 index 00000000000..d506c75a435 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt @@ -0,0 +1,61 @@ + +=== initial deploy triggers the run +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-idempotent-recreate/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== drop the job run from local state but keep the job (simulates a lost run id) +>>> [CLI] bundle plan -o json +{ + "depends_on": [ + { + "node": "resources.jobs.my_job", + "label": "${resources.jobs.my_job.id}" + } + ], + "action": "create", + "new_state": { + "value": { + "job_id": [NUMID] + } + } +} + +=== redeploy re-issues run-now; the token dedupes to the existing run +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-idempotent-recreate/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== run-now was issued twice with the same token, but no duplicate run was created +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", + "job_id": [NUMID] + } +} +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", + "job_id": [NUMID] + } +} +run id unchanged after recreate: the idempotency token deduped to the existing run + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.job_runs.my_run + delete resources.jobs.my_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-idempotent-recreate/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/job_runs/idempotent_recreate/script b/acceptance/bundle/resources/job_runs/idempotent_recreate/script new file mode 100644 index 00000000000..e25372521fa --- /dev/null +++ b/acceptance/bundle/resources/job_runs/idempotent_recreate/script @@ -0,0 +1,33 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +STATE=.databricks/bundle/default/resources.json +run_id() { + jq -r '.state["resources.job_runs.my_run"].__id__' "$STATE" +} + +title "initial deploy triggers the run" +trace $CLI bundle deploy +before=$(run_id) + +title "drop the job run from local state but keep the job (simulates a lost run id)" +# The job stays deployed, so job_id -- and therefore the derived idempotency +# token -- is unchanged, which is what lets the retried run-now dedupe. +jq 'del(.state["resources.job_runs.my_run"])' "$STATE" > "$STATE.new" +mv "$STATE.new" "$STATE" +trace $CLI bundle plan -o json | jq '.plan["resources.job_runs.my_run"]' + +title "redeploy re-issues run-now; the token dedupes to the existing run" +trace $CLI bundle deploy +after=$(run_id) + +title "run-now was issued twice with the same token, but no duplicate run was created" +trace print_requests.py //jobs/run-now +if [ "$before" = "$after" ]; then + echo "run id unchanged after recreate: the idempotency token deduped to the existing run" +else + echo "run id changed ($before -> $after): a duplicate run was created" +fi diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index fd1ded7000a..8c10d8df85f 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -44,35 +44,3 @@ func TestIdempotencyTokenChangesWithConfig(t *testing.T) { assert.NotEqual(t, dev, prod) // different params --> different token assert.NotEqual(t, dev, otherJob) // different job_id --> different token } - -func TestJobRunIdempotentCreate(t *testing.T) { - _, client := setupTestServerClient(t) - ctx := t.Context() - - // A run can only target an existing job, so create one first. - job, err := client.Jobs.Create(ctx, jobs.CreateJob{ - Name: "idempotency-job", - Tasks: []jobs.Task{{ - TaskKey: "t", - NotebookTask: &jobs.NotebookTask{NotebookPath: "/Workspace/Users/user@example.com/notebook"}, - }}, - }) - require.NoError(t, err) - - r := (&ResourceJobRun{}).New(client) - config := &JobRunState{RunNow: jobs.RunNow{JobId: job.JobId}} - - // Same config twice: the derived token is identical, so the backend returns - // the existing run instead of creating a duplicate. - id1, _, err := r.DoCreate(ctx, config) - require.NoError(t, err) - id2, _, err := r.DoCreate(ctx, config) - require.NoError(t, err) - assert.Equal(t, id1, id2, "same config must dedupe to the same run") - - // Different config: different token, so a genuinely new run is created. - other := &JobRunState{RunNow: jobs.RunNow{JobId: job.JobId, JobParameters: map[string]string{"env": "prod"}}} - id3, _, err := r.DoCreate(ctx, other) - require.NoError(t, err) - assert.NotEqual(t, id1, id3, "different config must create a new run") -} From 37568db8bae2ec9b32602f4d3c46d6b8950e7433 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 13 Jul 2026 09:11:23 +0000 Subject: [PATCH 03/25] Regenerate out.test.toml for job_runs idempotent_recreate Pick up GOOSOnPR fields added by the acceptance harness on main (#5876, #5879), which restrict OS-independent bundle tests to Linux on PRs. --- .../bundle/resources/job_runs/idempotent_recreate/out.test.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml b/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml index e90b6d5d1ba..1a3e24fa574 100644 --- a/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml +++ b/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml @@ -1,3 +1,5 @@ Local = true Cloud = false +GOOSOnPR.darwin = false +GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] From 66ca15aa6a3dc8909a12da82a234fb33f7e136e6 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 14 Jul 2026 08:29:23 +0000 Subject: [PATCH 04/25] Wait for job_runs completion and expose run output fields Add WaitAfterCreate to the job_runs direct-engine resource: it polls until the run reaches a terminal state and returns the run's output fields (state, run_id, run_page_url, ...) so downstream resources can read e.g. ${resources.job_runs.x.state.result_state}. A FAILED result is surfaced as readable state rather than failing the deploy; only INTERNAL_ERROR fails it. Add the wait_output acceptance test (a downstream job reads result_state, and a redeploy stays a no-op so the resolved value doesn't churn), and extend idempotent_recreate to cover both mid-run retry paths: a lost run id (token dedupes run-now) and a recorded run id (retry is a no-op). Regenerate the redeploy golden, which now shows the run TERMINATED. --- .../job_runs/idempotent_recreate/output.txt | 19 +++-- .../job_runs/idempotent_recreate/script | 27 +++--- .../resources/job_runs/redeploy/output.txt | 3 +- .../job_runs/wait_output/databricks.yml | 26 ++++++ .../job_runs/wait_output/out.test.toml | 5 ++ .../resources/job_runs/wait_output/output.txt | 84 +++++++++++++++++++ .../resources/job_runs/wait_output/script | 18 ++++ bundle/direct/dresources/job_run.go | 27 +++++- 8 files changed, 190 insertions(+), 19 deletions(-) create mode 100644 acceptance/bundle/resources/job_runs/wait_output/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/wait_output/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/wait_output/output.txt create mode 100644 acceptance/bundle/resources/job_runs/wait_output/script diff --git a/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt b/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt index d506c75a435..1cb7c2b4124 100644 --- a/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt +++ b/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt @@ -1,12 +1,12 @@ -=== initial deploy triggers the run +=== initial deploy triggers the run and waits for it to finish >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-idempotent-recreate/default/files... Deploying resources... Updating deployment state... Deployment complete! -=== drop the job run from local state but keep the job (simulates a lost run id) +=== retry mid-run after a lost run id (deploy crashed before the id was recorded) >>> [CLI] bundle plan -o json { "depends_on": [ @@ -23,14 +23,23 @@ Deployment complete! } } -=== redeploy re-issues run-now; the token dedupes to the existing run >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-idempotent-recreate/default/files... Deploying resources... Updating deployment state... Deployment complete! -=== run-now was issued twice with the same token, but no duplicate run was created +=== retry mid-run after the run id was recorded (deploy crashed during the wait) +>>> [CLI] bundle plan -o json +"skip" + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-idempotent-recreate/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== run-now was issued exactly twice, both with the same token, and no duplicate run was created >>> print_requests.py //jobs/run-now { "method": "POST", @@ -48,7 +57,7 @@ Deployment complete! "job_id": [NUMID] } } -run id unchanged after recreate: the idempotency token deduped to the existing run +run id stable across both retries ([NUMID]): the idempotency token reused the existing run >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: diff --git a/acceptance/bundle/resources/job_runs/idempotent_recreate/script b/acceptance/bundle/resources/job_runs/idempotent_recreate/script index e25372521fa..82750d55a08 100644 --- a/acceptance/bundle/resources/job_runs/idempotent_recreate/script +++ b/acceptance/bundle/resources/job_runs/idempotent_recreate/script @@ -9,25 +9,32 @@ run_id() { jq -r '.state["resources.job_runs.my_run"].__id__' "$STATE" } -title "initial deploy triggers the run" +title "initial deploy triggers the run and waits for it to finish" trace $CLI bundle deploy before=$(run_id) -title "drop the job run from local state but keep the job (simulates a lost run id)" -# The job stays deployed, so job_id -- and therefore the derived idempotency -# token -- is unchanged, which is what lets the retried run-now dedupe. +title "retry mid-run after a lost run id (deploy crashed before the id was recorded)" +# Simulates a deploy that fired run-now but crashed before saving the run id. +# job_id (and thus the idempotency token) is unchanged, so the retried run-now +# dedupes to the existing run instead of starting a second one. jq 'del(.state["resources.job_runs.my_run"])' "$STATE" > "$STATE.new" mv "$STATE.new" "$STATE" trace $CLI bundle plan -o json | jq '.plan["resources.job_runs.my_run"]' +trace $CLI bundle deploy +after_lost=$(run_id) -title "redeploy re-issues run-now; the token dedupes to the existing run" +title "retry mid-run after the run id was recorded (deploy crashed during the wait)" +# The run id is still in state, so the retry is a no-op: no second run-now. +trace $CLI bundle plan -o json | jq '.plan["resources.job_runs.my_run"].action // "none"' trace $CLI bundle deploy -after=$(run_id) +after_kept=$(run_id) -title "run-now was issued twice with the same token, but no duplicate run was created" +title "run-now was issued exactly twice, both with the same token, and no duplicate run was created" +# Two run-now calls total (initial + lost-id retry); the recorded-id retry adds +# none. All share one token, so only one run ever exists. trace print_requests.py //jobs/run-now -if [ "$before" = "$after" ]; then - echo "run id unchanged after recreate: the idempotency token deduped to the existing run" +if [ "$before" = "$after_lost" ] && [ "$after_lost" = "$after_kept" ]; then + echo "run id stable across both retries ($before): the idempotency token reused the existing run" else - echo "run id changed ($before -> $after): a duplicate run was created" + echo "run id changed ($before -> $after_lost -> $after_kept): a duplicate run was created" fi diff --git a/acceptance/bundle/resources/job_runs/redeploy/output.txt b/acceptance/bundle/resources/job_runs/redeploy/output.txt index b39382c0a4a..35339304c37 100644 --- a/acceptance/bundle/resources/job_runs/redeploy/output.txt +++ b/acceptance/bundle/resources/job_runs/redeploy/output.txt @@ -68,7 +68,8 @@ Resources: "run_page_url": "[DATABRICKS_URL]/?o=[NUMID]#job/[MY_JOB_ID]/run/[NUMID]", "run_type": "JOB_RUN", "state": { - "life_cycle_state": "RUNNING" + "life_cycle_state": "TERMINATED", + "result_state": "SUCCESS" } }, "changes": { diff --git a/acceptance/bundle/resources/job_runs/wait_output/databricks.yml b/acceptance/bundle/resources/job_runs/wait_output/databricks.yml new file mode 100644 index 00000000000..a694ea66492 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/wait_output/databricks.yml @@ -0,0 +1,26 @@ +bundle: + name: job-runs-wait-output + +resources: + jobs: + my_job: + name: my-job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + # Reads the run's output. Must not be my_job (the run's target), or the + # reference would cycle: my_run already depends on my_job.id. + downstream_job: + name: downstream-job + tags: + run_result: ${resources.job_runs.my_run.state.result_state} + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + job_runs: + my_run: + job_id: ${resources.jobs.my_job.id} diff --git a/acceptance/bundle/resources/job_runs/wait_output/out.test.toml b/acceptance/bundle/resources/job_runs/wait_output/out.test.toml new file mode 100644 index 00000000000..1a3e24fa574 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/wait_output/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +GOOSOnPR.darwin = false +GOOSOnPR.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/wait_output/output.txt b/acceptance/bundle/resources/job_runs/wait_output/output.txt new file mode 100644 index 00000000000..f7cbfee9e59 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/wait_output/output.txt @@ -0,0 +1,84 @@ + +=== deploy waits for the run to finish, then the downstream job reads its result_state +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-wait-output/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== the downstream job was created with the run's result_state resolved into its tag +>>> print_requests.py //jobs/create +{ + "method": "POST", + "path": "/api/2.2/jobs/create", + "body": { + "deployment": { + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/job-runs-wait-output/default/state/metadata.json" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "my-job", + "queue": { + "enabled": true + }, + "tasks": [ + { + "notebook_task": { + "notebook_path": "/Workspace/test" + }, + "task_key": "main" + } + ] + } +} +{ + "method": "POST", + "path": "/api/2.2/jobs/create", + "body": { + "deployment": { + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/job-runs-wait-output/default/state/metadata.json" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "downstream-job", + "queue": { + "enabled": true + }, + "tags": { + "run_result": "SUCCESS" + }, + "tasks": [ + { + "notebook_task": { + "notebook_path": "/Workspace/test" + }, + "task_key": "main" + } + ] + } +} + +=== redeploy is a no-op: the resolved result_state tag is stable, not perpetual drift +>>> [CLI] bundle plan -o json +"skip" + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-wait-output/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.job_runs.my_run + delete resources.jobs.downstream_job + delete resources.jobs.my_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-wait-output/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/job_runs/wait_output/script b/acceptance/bundle/resources/job_runs/wait_output/script new file mode 100644 index 00000000000..37bfa63428a --- /dev/null +++ b/acceptance/bundle/resources/job_runs/wait_output/script @@ -0,0 +1,18 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +title "deploy waits for the run to finish, then the downstream job reads its result_state" +trace $CLI bundle deploy + +title "the downstream job was created with the run's result_state resolved into its tag" +# A concrete SUCCESS tag proves the run finished and WaitAfterCreate published +# its output before the downstream job was created. +trace print_requests.py //jobs/create + +title "redeploy is a no-op: the resolved result_state tag is stable, not perpetual drift" +# The resolved tag must not read back as drift, or the job recreates every deploy. +trace $CLI bundle plan -o json | jq '.plan["resources.jobs.downstream_job"].action // "none"' +trace $CLI bundle deploy diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index d1ca0659153..c7a4bc8b8b8 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "strconv" + "time" "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/databricks-sdk-go" @@ -14,6 +15,10 @@ import ( "github.com/databricks/databricks-sdk-go/service/jobs" ) +// jobRunTimeout bounds how long WaitAfterCreate waits for a run to finish. +// Matches the SDK's default RunNow waiter timeout. +const jobRunTimeout = 20 * time.Minute + // JobRunState is what we persist for a triggered run: the RunNow request. type JobRunState struct { jobs.RunNow @@ -150,12 +155,28 @@ func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (str return strconv.FormatInt(wait.RunId, 10), nil, nil } +func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobRunState) (*JobRunRemote, error) { + runID, err := parseRunID(id) + if err != nil { + return nil, err + } + // TERMINATED/SKIPPED succeed even on a FAILED/TIMEDOUT/CANCELED result_state: + // the outcome is surfaced as readable state, not a deploy failure. Only + // INTERNAL_ERROR fails the deploy. + run, err := r.client.Jobs.WaitGetRunJobTerminatedOrSkipped(ctx, runID, jobRunTimeout, nil) + if err != nil { + return nil, err + } + return makeJobRunRemote(run), nil +} + // DoUpdate is intentionally not implemented: a run can't be modified in place, // so any change recreates it (delete + a fresh RunNow). -// DoDelete deletes the run via jobs/runs/delete, on both destroy and the -// recreate path. The API rejects a still-active run; this milestone doesn't -// await completion, so that error surfaces to the user. +// DoDelete deletes the run via jobs/runs/delete, on both destroy and recreate. +// WaitAfterCreate leaves a run terminal by the end of its deploy, so on recreate +// the prior run is safe to delete; destroying a run still active from an +// interrupted deploy is rejected by the API and surfaces to the user. func (r *ResourceJobRun) DoDelete(ctx context.Context, id string, _ *JobRunState) error { runID, err := parseRunID(id) if err != nil { From 3d743393c054e9927f1209634200e5dac63f704e Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 14 Jul 2026 10:08:54 +0000 Subject: [PATCH 05/25] Add job_runs changelog and fix WaitAfterCreate test assertion Add a changelog fragment for the idempotency + wait-for-completion work. The shared CRUD harness compared WaitAfterCreate's result against the read taken right after DoCreate. A job run transitions RUNNING -> TERMINATED while we wait, so compare against a fresh read taken after the wait instead, mirroring how DoUpdate is already validated. --- .nextchanges/bundles/job-runs-idempotency-wait.md | 1 + bundle/direct/dresources/all_test.go | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .nextchanges/bundles/job-runs-idempotency-wait.md diff --git a/.nextchanges/bundles/job-runs-idempotency-wait.md b/.nextchanges/bundles/job-runs-idempotency-wait.md new file mode 100644 index 00000000000..ba8d8398df8 --- /dev/null +++ b/.nextchanges/bundles/job-runs-idempotency-wait.md @@ -0,0 +1 @@ +direct: the experimental `job_runs` resource now dedupes runs across deploy retries with an automatically computed `idempotency_token`, waits for the run to reach a terminal state, and exposes its output fields (e.g. `${resources.job_runs..state.result_state}`) for use by downstream resources. diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index d79c80c61b2..d4d3e9b9ea1 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -985,7 +985,13 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W remoteStateFromWaitCreate, err := adapter.WaitAfterCreate(ctx, createdID, newState) require.NoError(t, err) if remoteStateFromWaitCreate != nil { - require.Equal(t, remote, remoteStateFromWaitCreate) + // WaitAfterCreate returns the settled remote, which may differ from the + // read taken right after DoCreate (a job run transitions from RUNNING to + // TERMINATED while we wait). Compare against a fresh read taken after the + // wait, which reflects that same settled state. + remotePostWaitCreate, err := adapter.DoRead(ctx, createdID) + require.NoError(t, err) + require.Equal(t, remotePostWaitCreate, remoteStateFromWaitCreate) } if adapter.HasDoUpdate() { From 7faf08b90491bc29cf299437418c99df29e95bed Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 14 Jul 2026 10:59:21 +0000 Subject: [PATCH 06/25] Shorten job_runs changelog entry --- .nextchanges/bundles/job-runs-idempotency-wait.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nextchanges/bundles/job-runs-idempotency-wait.md b/.nextchanges/bundles/job-runs-idempotency-wait.md index ba8d8398df8..b1ca338e76e 100644 --- a/.nextchanges/bundles/job-runs-idempotency-wait.md +++ b/.nextchanges/bundles/job-runs-idempotency-wait.md @@ -1 +1 @@ -direct: the experimental `job_runs` resource now dedupes runs across deploy retries with an automatically computed `idempotency_token`, waits for the run to reach a terminal state, and exposes its output fields (e.g. `${resources.job_runs..state.result_state}`) for use by downstream resources. +direct: the experimental `job_runs` resource now waits for the run to finish, exposes its output fields (e.g. `${resources.job_runs..state.result_state}`), and dedupes runs on deploy retries. From 1742fac005fb097619fa07dc454859c03265a5e3 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 20 Jul 2026 09:45:42 +0000 Subject: [PATCH 07/25] Harden job_runs idempotency and add WaitAfterCreate failure tests Derive the idempotency token from the whole JobRunState so identity fields added later join dedup automatically. Clean up the fake's token map on run delete so a recreate starts a fresh run. Narrow the golden mask to the idempotency_token field only. Add unit tests covering WaitAfterCreate on FAILED (surfaced as state) and INTERNAL_ERROR (fails the deploy). --- .../bundle/resources/job_runs/test.toml | 9 ++- bundle/direct/dresources/job_run.go | 49 +++++++-------- bundle/direct/dresources/job_run_test.go | 63 ++++++++++++++++--- libs/testserver/jobs.go | 18 +++++- 4 files changed, 101 insertions(+), 38 deletions(-) diff --git a/acceptance/bundle/resources/job_runs/test.toml b/acceptance/bundle/resources/job_runs/test.toml index 0a29818c6e5..70162af7a72 100644 --- a/acceptance/bundle/resources/job_runs/test.toml +++ b/acceptance/bundle/resources/job_runs/test.toml @@ -3,9 +3,8 @@ EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] RecordRequests = true -# idempotency_token is a SHA-256 of the RunNow request, and the fake assigns -# job_id from a time-based counter, so the hash differs every run. Mask it so the -# run-now request goldens stay stable. +# idempotency_token is a SHA-256 that differs every run (job_id comes from a +# time-based counter). Mask just that field so run-now goldens stay stable. [[Repls]] -Old = '[0-9a-f]{64}' -New = '[IDEMPOTENCY_TOKEN]' +Old = '"idempotency_token": "[0-9a-f]{64}"' +New = '"idempotency_token": "[IDEMPOTENCY_TOKEN]"' diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index c7a4bc8b8b8..e8768b4b130 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -70,9 +70,8 @@ func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { } } -// makeJobRunRemote maps the GetRun response into the RunNow-shaped remote: GET -// nests the params under overriding_parameters and returns job_parameters as a -// list, so both are flattened back into RunNow. +// makeJobRunRemote maps GetRun into the RunNow-shaped remote, flattening +// overriding_parameters and the job_parameters list back into RunNow. func makeJobRunRemote(run *jobs.Run) *JobRunRemote { var overriding jobs.RunParameters if run.OverridingParameters != nil { @@ -114,8 +113,8 @@ func makeJobRunRemote(run *jobs.Run) *JobRunRemote { } // DoRead returns the run as GetRun reports it; a 404 lets the planner -// re-trigger. Root ignore_remote_changes suppresses all remote drift, so a run -// is recreated only on a local config change. +// re-trigger. ignore_remote_changes suppresses drift, so a run is recreated +// only on a local config change. func (r *ResourceJobRun) DoRead(ctx context.Context, id string) (*JobRunRemote, error) { runID, err := parseRunID(id) if err != nil { @@ -137,17 +136,17 @@ func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { } func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (string, *JobRunRemote, error) { - // Copy the request so the derived token is sent to the API but never - // persisted into state (state stays token-free, plans stay clean). - req := config.RunNow - token, err := idempotencyToken(req) + token, err := idempotencyToken(config) if err != nil { return "", nil, err } + // Copy so the token reaches the API but never lands in state (keeps state + // token-free and plans clean). + req := config.RunNow req.IdempotencyToken = token - // RunNow returns only the new run id, so we return a nil remote and let the - // framework read it back via DoRead. + // RunNow returns only the run id; return a nil remote and let the framework + // read it back via DoRead. wait, err := r.client.Jobs.RunNow(ctx, req) if err != nil { return "", nil, err @@ -160,9 +159,9 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR if err != nil { return nil, err } - // TERMINATED/SKIPPED succeed even on a FAILED/TIMEDOUT/CANCELED result_state: - // the outcome is surfaced as readable state, not a deploy failure. Only - // INTERNAL_ERROR fails the deploy. + // TERMINATED/SKIPPED succeed even with a FAILED/TIMEDOUT/CANCELED + // result_state (surfaced as state, not a deploy failure); only INTERNAL_ERROR + // fails the deploy. run, err := r.client.Jobs.WaitGetRunJobTerminatedOrSkipped(ctx, runID, jobRunTimeout, nil) if err != nil { return nil, err @@ -173,10 +172,9 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR // DoUpdate is intentionally not implemented: a run can't be modified in place, // so any change recreates it (delete + a fresh RunNow). -// DoDelete deletes the run via jobs/runs/delete, on both destroy and recreate. -// WaitAfterCreate leaves a run terminal by the end of its deploy, so on recreate -// the prior run is safe to delete; destroying a run still active from an -// interrupted deploy is rejected by the API and surfaces to the user. +// DoDelete deletes the run (on destroy and recreate). WaitAfterCreate leaves +// runs terminal, so a recreate's prior run is safe to delete; destroying a run +// still active from an interrupted deploy is rejected by the API and surfaces. func (r *ResourceJobRun) DoDelete(ctx context.Context, id string, _ *JobRunState) error { runID, err := parseRunID(id) if err != nil { @@ -193,13 +191,14 @@ func parseRunID(id string) (int64, error) { return result, nil } -// idempotencyToken derives a stable token from the RunNow request so that a -// retried run-now returns the existing run instead of creating a duplicate. -// The token is the hex SHA-256 of the request's JSON with idempotency_token -// cleared; hex SHA-256 is exactly 64 chars, the Jobs API maximum. -func idempotencyToken(req jobs.RunNow) (string, error) { - req.IdempotencyToken = "" - canonical, err := json.Marshal(req) +// idempotencyToken derives a stable token from the desired state so a retried +// run-now dedupes to the existing run. Hashing the whole JobRunState (not just +// RunNow) means fields we add later join dedup automatically. Token = hex +// SHA-256 of the state JSON with idempotency_token cleared (64 chars, API max). +func idempotencyToken(state *JobRunState) (string, error) { + toHash := *state + toHash.IdempotencyToken = "" + canonical, err := json.Marshal(toHash) if err != nil { return "", err } diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 8c10d8df85f..25dc5d95aa9 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -3,6 +3,8 @@ package dresources import ( "testing" + "github.com/databricks/cli/libs/testserver" + "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/service/jobs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -11,22 +13,22 @@ import ( func TestIdempotencyTokenIsStableHex(t *testing.T) { run := jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "prod"}} - got, err := idempotencyToken(run) + got, err := idempotencyToken(&JobRunState{RunNow: run}) require.NoError(t, err) // hex SHA-256 is always 64 lowercase hex chars (the Jobs API maximum). assert.Regexp(t, "^[0-9a-f]{64}$", got) // Deterministic: the same config yields the same token, so a retry dedupes. - again, err := idempotencyToken(run) + again, err := idempotencyToken(&JobRunState{RunNow: run}) require.NoError(t, err) assert.Equal(t, got, again) } func TestIdempotencyTokenIgnoresPresetToken(t *testing.T) { - a, err := idempotencyToken(jobs.RunNow{JobId: 123}) + a, err := idempotencyToken(&JobRunState{RunNow: jobs.RunNow{JobId: 123}}) require.NoError(t, err) - b, err := idempotencyToken(jobs.RunNow{JobId: 123, IdempotencyToken: "user-supplied"}) + b, err := idempotencyToken(&JobRunState{RunNow: jobs.RunNow{JobId: 123, IdempotencyToken: "user-supplied"}}) require.NoError(t, err) // The token is cleared before hashing, so a preset value cannot change it. @@ -34,13 +36,60 @@ func TestIdempotencyTokenIgnoresPresetToken(t *testing.T) { } func TestIdempotencyTokenChangesWithConfig(t *testing.T) { - dev, err := idempotencyToken(jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "dev"}}) + dev, err := idempotencyToken(&JobRunState{RunNow: jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "dev"}}}) require.NoError(t, err) - prod, err := idempotencyToken(jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "prod"}}) + prod, err := idempotencyToken(&JobRunState{RunNow: jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "prod"}}}) require.NoError(t, err) - otherJob, err := idempotencyToken(jobs.RunNow{JobId: 456}) + otherJob, err := idempotencyToken(&JobRunState{RunNow: jobs.RunNow{JobId: 456}}) require.NoError(t, err) assert.NotEqual(t, dev, prod) // different params --> different token assert.NotEqual(t, dev, otherJob) // different job_id --> different token } + +// jobRunClient returns a client whose GetRun always reports the given terminal +// run state, so WaitAfterCreate can be exercised without a real run. +func jobRunClient(t *testing.T, state *jobs.RunState) *databricks.WorkspaceClient { + t.Helper() + server := testserver.New(t) + // First registration wins, so this overrides the default runs/get handler. + server.Handle("GET", "/api/2.2/jobs/runs/get", func(req testserver.Request) any { + return jobs.Run{RunId: 123, JobId: 456, State: state} + }) + testserver.AddDefaultHandlers(server) + + client, err := databricks.NewWorkspaceClient(&databricks.Config{ + Host: server.URL, + Token: "testtoken", + }) + require.NoError(t, err) + return client +} + +func TestJobRunWaitAfterCreateSurfacesFailedResult(t *testing.T) { + client := jobRunClient(t, &jobs.RunState{ + LifeCycleState: jobs.RunLifeCycleStateTerminated, + ResultState: jobs.RunResultStateFailed, + }) + + r := (&ResourceJobRun{}).New(client) + remote, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) + + // A run that ends TERMINATED with a FAILED result is surfaced as readable + // state, not turned into a deploy failure. + require.NoError(t, err) + require.NotNil(t, remote.State) + assert.Equal(t, jobs.RunResultStateFailed, remote.State.ResultState) +} + +func TestJobRunWaitAfterCreateFailsOnInternalError(t *testing.T) { + client := jobRunClient(t, &jobs.RunState{ + LifeCycleState: jobs.RunLifeCycleStateInternalError, + }) + + r := (&ResourceJobRun{}).New(client) + _, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) + + // INTERNAL_ERROR is the one terminal state that fails the deploy. + require.Error(t, err) +} diff --git a/libs/testserver/jobs.go b/libs/testserver/jobs.go index b6bb3fa967b..7d4c599d014 100644 --- a/libs/testserver/jobs.go +++ b/libs/testserver/jobs.go @@ -912,7 +912,23 @@ func (s *FakeWorkspace) JobsDeleteRun(req Request) Response { Body: fmt.Sprintf("request parsing error: %s", err), } } - return MapDelete(s, s.JobRuns, request.RunId) + + defer s.LockUnlock()() + + if _, ok := s.JobRuns[request.RunId]; !ok { + return Response{StatusCode: 404} + } + delete(s.JobRuns, request.RunId) + + // Drop the run's token mapping so a later run-now with the same token starts + // a fresh run instead of returning this deleted one (matters on recreate). + for token, runID := range s.JobRunsByToken { + if runID == request.RunId { + delete(s.JobRunsByToken, token) + } + } + + return Response{} } func (s *FakeWorkspace) JobsGetRunOutput(req Request) Response { From 7d670494cb6a7542ce433d103aed736bb20ed52f Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 20 Jul 2026 11:04:41 +0000 Subject: [PATCH 08/25] Guard against nil job_runs entries in idempotency_token validator An empty `job_runs.:` entry unmarshals to a nil *resources.JobRun (convert.ToTyped), so dereferencing jr.IdempotencyToken would panic. Skip nil entries and add a regression test. --- .../validate/validate_job_run_idempotency_token.go | 4 +++- .../validate_job_run_idempotency_token_test.go | 14 ++++++++++++++ bundle/direct/dresources/job_run.go | 6 +++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/bundle/config/validate/validate_job_run_idempotency_token.go b/bundle/config/validate/validate_job_run_idempotency_token.go index 55ea913827e..f1f42acf691 100644 --- a/bundle/config/validate/validate_job_run_idempotency_token.go +++ b/bundle/config/validate/validate_job_run_idempotency_token.go @@ -27,7 +27,9 @@ func (v *validateJobRunIdempotencyToken) Apply(_ context.Context, b *bundle.Bund // (SHA-256 of the RunNow request) so retries dedupe. A user-provided value // would be overwritten, so reject it up front. for name, jr := range b.Config.Resources.JobRuns { - if jr.IdempotencyToken == "" { + // An empty `job_runs.:` entry unmarshals to a nil pointer + // (convert.ToTyped), so guard before dereferencing. + if jr == nil || jr.IdempotencyToken == "" { continue } path := "resources.job_runs." + name + ".idempotency_token" diff --git a/bundle/config/validate/validate_job_run_idempotency_token_test.go b/bundle/config/validate/validate_job_run_idempotency_token_test.go index 1bd8fddeb90..52a6418b0ef 100644 --- a/bundle/config/validate/validate_job_run_idempotency_token_test.go +++ b/bundle/config/validate/validate_job_run_idempotency_token_test.go @@ -37,6 +37,20 @@ func TestValidateJobRunIdempotencyTokenAllowsUnset(t *testing.T) { require.Empty(t, diags) } +func TestValidateJobRunIdempotencyTokenAllowsNilEntry(t *testing.T) { + // An empty `job_runs.:` entry unmarshals to a nil pointer; the + // validator must skip it rather than panic dereferencing. + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + JobRuns: map[string]*resources.JobRun{"my_run": nil}, + }, + }, + } + diags := ValidateJobRunIdempotencyToken().Apply(t.Context(), b) + require.Empty(t, diags) +} + func TestValidateJobRunIdempotencyTokenReportsAllSorted(t *testing.T) { b := &bundle.Bundle{ Config: config.Root{ diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index e8768b4b130..e5c61600968 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -192,9 +192,9 @@ func parseRunID(id string) (int64, error) { } // idempotencyToken derives a stable token from the desired state so a retried -// run-now dedupes to the existing run. Hashing the whole JobRunState (not just -// RunNow) means fields we add later join dedup automatically. Token = hex -// SHA-256 of the state JSON with idempotency_token cleared (64 chars, API max). +// run-now dedupes to the existing run. Hashes the whole JobRunState so future +// state fields join dedup automatically. Hex SHA-256 (64 chars, the Jobs API +// max) of the state JSON with idempotency_token cleared. func idempotencyToken(state *JobRunState) (string, error) { toHash := *state toHash.IdempotencyToken = "" From 32813599303dc8391c5db6e08aab077bcad6e3d8 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 20 Jul 2026 12:02:03 +0000 Subject: [PATCH 09/25] Wait up to 24h for job_runs completion instead of 20m The SDK's default RunNow waiter caps the wait at 20 minutes, so a legitimately long run (schema migration, model training) would fail the deploy even though the run itself is healthy. Raise the WaitAfterCreate budget to 24h, matching the budget `bundle run` already uses to wait on a job run, so deploy blocks until the run is ready or failed while still guaranteeing an unattended (CI) deploy can't hang forever. --- bundle/direct/dresources/job_run.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index e5c61600968..37b56806a34 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -15,9 +15,14 @@ import ( "github.com/databricks/databricks-sdk-go/service/jobs" ) -// jobRunTimeout bounds how long WaitAfterCreate waits for a run to finish. -// Matches the SDK's default RunNow waiter timeout. -const jobRunTimeout = 20 * time.Minute +// jobRunWaitTimeout bounds how long WaitAfterCreate polls GetRun for the run to +// reach a terminal state. It is deliberately large (not the SDK's 20m default) +// so a legitimately long run (schema migration, model training) doesn't fail the +// deploy just because it outlived a fixed budget, while still guaranteeing an +// unattended deploy (CI) can't hang forever on a run whose job has no server-side +// timeout_seconds. Matches the budget `bundle run` already uses to wait on a job +// run (see jobRunTimeout in bundle/run/job.go). +const jobRunWaitTimeout = 24 * time.Hour // JobRunState is what we persist for a triggered run: the RunNow request. type JobRunState struct { @@ -162,7 +167,7 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR // TERMINATED/SKIPPED succeed even with a FAILED/TIMEDOUT/CANCELED // result_state (surfaced as state, not a deploy failure); only INTERNAL_ERROR // fails the deploy. - run, err := r.client.Jobs.WaitGetRunJobTerminatedOrSkipped(ctx, runID, jobRunTimeout, nil) + run, err := r.client.Jobs.WaitGetRunJobTerminatedOrSkipped(ctx, runID, jobRunWaitTimeout, nil) if err != nil { return nil, err } From db46181995f7aef98a496c5baa00bfc355f940f4 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 20 Jul 2026 13:29:50 +0000 Subject: [PATCH 10/25] Model deleted idempotency_token tombstone in jobs testserver The Jobs API does not free an idempotency_token when its run is deleted: reusing the token then returns an error instead of starting a fresh run. Confirmed against a real workspace (HTTP 400, error_code BAD_REQUEST). Keep the token->run mapping as a tombstone on delete and error on reuse so the fake matches documented behavior. --- libs/testserver/jobs.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/libs/testserver/jobs.go b/libs/testserver/jobs.go index 7d4c599d014..e93a652a401 100644 --- a/libs/testserver/jobs.go +++ b/libs/testserver/jobs.go @@ -350,10 +350,19 @@ func (s *FakeWorkspace) JobsRunNow(req Request) Response { } // The Jobs API treats run-now as idempotent: the same idempotency_token - // returns the run already created for it instead of starting a new one. + // returns the run already created for it. Crucially, the token is not freed + // when its run is deleted: reusing it then returns an error rather than + // starting a fresh run ("If a run with the provided token is deleted, an + // error is returned" — https://docs.databricks.com/api/workspace/jobs/runnow). if request.IdempotencyToken != "" { if existing, ok := s.JobRunsByToken[request.IdempotencyToken]; ok { - return Response{Body: jobs.RunNowResponse{RunId: existing}} + if _, alive := s.JobRuns[existing]; alive { + return Response{Body: jobs.RunNowResponse{RunId: existing}} + } + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("idempotency_token %q was used for run %d, which has been deleted", request.IdempotencyToken, existing), + } } } @@ -920,13 +929,10 @@ func (s *FakeWorkspace) JobsDeleteRun(req Request) Response { } delete(s.JobRuns, request.RunId) - // Drop the run's token mapping so a later run-now with the same token starts - // a fresh run instead of returning this deleted one (matters on recreate). - for token, runID := range s.JobRunsByToken { - if runID == request.RunId { - delete(s.JobRunsByToken, token) - } - } + // The token mapping is intentionally kept as a tombstone: the Jobs API does + // not free an idempotency_token when its run is deleted, so a later run-now + // reusing that token errors instead of starting a fresh run (see JobsRunNow). + // Dropping the mapping here would incorrectly let the deleted token succeed. return Response{} } From 25e12f22fc22370b1c0c49331f688c0c14f913c9 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 21 Jul 2026 07:50:19 +0000 Subject: [PATCH 11/25] Make job_runs DoDelete a noop and add lifecycle tests A run is an immutable historical record, so destroy/recreate now leave it in place (the design doc specifies DoDelete -> noop). This also avoids tombstoning the deterministic idempotency_token: the Jobs API does not free a token when its run is deleted, so deleting would break re-running the same config after destroy or a value flip-back. Add acceptance tests for a FAILED run being surfaced (not fatal) and for destroy -> redeploy, and tighten comments across the resource, validator, and testserver. --- .../resources/job_runs/basic/output.txt | 9 --- .../bundle/resources/job_runs/basic/script | 5 +- .../job_runs/destroy_redeploy/databricks.yml | 15 ++++ .../job_runs/destroy_redeploy/out.test.toml | 5 ++ .../job_runs/destroy_redeploy/output.txt | 55 ++++++++++++++ .../job_runs/destroy_redeploy/script | 20 +++++ .../job_runs/failed_run/databricks.yml | 26 +++++++ .../job_runs/failed_run/out.test.toml | 5 ++ .../resources/job_runs/failed_run/output.txt | 74 +++++++++++++++++++ .../resources/job_runs/failed_run/script | 11 +++ .../resources/job_runs/failed_run/test.toml | 13 ++++ .../resources/job_runs/redeploy/output.txt | 9 +-- .../bundle/resources/job_runs/redeploy/script | 4 +- bundle/direct/dresources/all_test.go | 4 +- bundle/direct/dresources/job_run.go | 35 ++++----- bundle/phases/initialize.go | 2 +- libs/testserver/jobs.go | 16 ++-- 17 files changed, 257 insertions(+), 51 deletions(-) create mode 100644 acceptance/bundle/resources/job_runs/destroy_redeploy/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/destroy_redeploy/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/destroy_redeploy/output.txt create mode 100644 acceptance/bundle/resources/job_runs/destroy_redeploy/script create mode 100644 acceptance/bundle/resources/job_runs/failed_run/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/failed_run/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/failed_run/output.txt create mode 100644 acceptance/bundle/resources/job_runs/failed_run/script create mode 100644 acceptance/bundle/resources/job_runs/failed_run/test.toml diff --git a/acceptance/bundle/resources/job_runs/basic/output.txt b/acceptance/bundle/resources/job_runs/basic/output.txt index b5872e724b0..e5831b0973f 100644 --- a/acceptance/bundle/resources/job_runs/basic/output.txt +++ b/acceptance/bundle/resources/job_runs/basic/output.txt @@ -89,12 +89,3 @@ All files and directories at the following location will be deleted: /Workspace/ Deleting files... Destroy complete! - ->>> print_requests.py //jobs/runs/delete -{ - "method": "POST", - "path": "/api/2.2/jobs/runs/delete", - "body": { - "run_id": [MY_RUN_ID] - } -} diff --git a/acceptance/bundle/resources/job_runs/basic/script b/acceptance/bundle/resources/job_runs/basic/script index bb52888ea2b..18dff26b84c 100644 --- a/acceptance/bundle/resources/job_runs/basic/script +++ b/acceptance/bundle/resources/job_runs/basic/script @@ -1,7 +1,8 @@ cleanup() { + # destroy leaves the run in place (immutable historical record), so it makes + # no jobs/runs/delete call. trace $CLI bundle destroy --auto-approve - # destroy deletes the triggered run via jobs/runs/delete (also unlinks out.requests.txt) - trace print_requests.py //jobs/runs/delete + rm -f out.requests.txt } trap cleanup EXIT diff --git a/acceptance/bundle/resources/job_runs/destroy_redeploy/databricks.yml b/acceptance/bundle/resources/job_runs/destroy_redeploy/databricks.yml new file mode 100644 index 00000000000..322aea538f5 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/destroy_redeploy/databricks.yml @@ -0,0 +1,15 @@ +bundle: + name: job-runs-destroy-redeploy + +resources: + jobs: + my_job: + name: my-job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + job_runs: + my_run: + job_id: ${resources.jobs.my_job.id} diff --git a/acceptance/bundle/resources/job_runs/destroy_redeploy/out.test.toml b/acceptance/bundle/resources/job_runs/destroy_redeploy/out.test.toml new file mode 100644 index 00000000000..1a3e24fa574 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/destroy_redeploy/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +GOOSOnPR.darwin = false +GOOSOnPR.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/destroy_redeploy/output.txt b/acceptance/bundle/resources/job_runs/destroy_redeploy/output.txt new file mode 100644 index 00000000000..6e1dd9a253e --- /dev/null +++ b/acceptance/bundle/resources/job_runs/destroy_redeploy/output.txt @@ -0,0 +1,55 @@ + +=== initial deploy triggers the run +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-destroy-redeploy/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== destroy removes the resource from state but leaves the run in place (no jobs/runs/delete) +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.job_runs.my_run + delete resources.jobs.my_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-destroy-redeploy/default + +Deleting files... +Destroy complete! + +>>> print_requests.py --keep //jobs/runs/delete + +=== redeploy after destroy succeeds and triggers a fresh run +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-destroy-redeploy/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", + "job_id": [NUMID] + } +} +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", + "job_id": [NUMID] + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.job_runs.my_run + delete resources.jobs.my_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-destroy-redeploy/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/job_runs/destroy_redeploy/script b/acceptance/bundle/resources/job_runs/destroy_redeploy/script new file mode 100644 index 00000000000..fa53e1ec58d --- /dev/null +++ b/acceptance/bundle/resources/job_runs/destroy_redeploy/script @@ -0,0 +1,20 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +title "initial deploy triggers the run" +trace $CLI bundle deploy + +title "destroy removes the resource from state but leaves the run in place (no jobs/runs/delete)" +trace $CLI bundle destroy --auto-approve +trace print_requests.py --keep //jobs/runs/delete + +title "redeploy after destroy succeeds and triggers a fresh run" +# Destroy never deleted the run, so its idempotency token is not tombstoned and +# run-now cannot error on reuse. The bundle's job is recreated with a new id, so +# the run config differs and a new run is triggered (rather than deduping to the +# old one). +trace $CLI bundle deploy +trace print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/failed_run/databricks.yml b/acceptance/bundle/resources/job_runs/failed_run/databricks.yml new file mode 100644 index 00000000000..0e2cfc2216c --- /dev/null +++ b/acceptance/bundle/resources/job_runs/failed_run/databricks.yml @@ -0,0 +1,26 @@ +bundle: + name: job-runs-failed-run + +resources: + jobs: + my_job: + name: my-job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + # Reads the run's result_state. The run finishes FAILED (see test.toml stub), + # but the deploy still completes and this job is created with the resolved tag. + downstream_job: + name: downstream-job + tags: + run_result: ${resources.job_runs.my_run.state.result_state} + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + job_runs: + my_run: + job_id: ${resources.jobs.my_job.id} diff --git a/acceptance/bundle/resources/job_runs/failed_run/out.test.toml b/acceptance/bundle/resources/job_runs/failed_run/out.test.toml new file mode 100644 index 00000000000..1a3e24fa574 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/failed_run/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +GOOSOnPR.darwin = false +GOOSOnPR.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/failed_run/output.txt b/acceptance/bundle/resources/job_runs/failed_run/output.txt new file mode 100644 index 00000000000..db57260c347 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/failed_run/output.txt @@ -0,0 +1,74 @@ + +=== a run that finishes FAILED is surfaced, not fatal: the deploy still completes +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-failed-run/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== the downstream job was created with the run's FAILED result_state (deploy did not abort) +>>> print_requests.py //jobs/create +{ + "method": "POST", + "path": "/api/2.2/jobs/create", + "body": { + "deployment": { + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/job-runs-failed-run/default/state/metadata.json" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "my-job", + "queue": { + "enabled": true + }, + "tasks": [ + { + "notebook_task": { + "notebook_path": "/Workspace/test" + }, + "task_key": "main" + } + ] + } +} +{ + "method": "POST", + "path": "/api/2.2/jobs/create", + "body": { + "deployment": { + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/job-runs-failed-run/default/state/metadata.json" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "downstream-job", + "queue": { + "enabled": true + }, + "tags": { + "run_result": "FAILED" + }, + "tasks": [ + { + "notebook_task": { + "notebook_path": "/Workspace/test" + }, + "task_key": "main" + } + ] + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.job_runs.my_run + delete resources.jobs.downstream_job + delete resources.jobs.my_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-failed-run/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/job_runs/failed_run/script b/acceptance/bundle/resources/job_runs/failed_run/script new file mode 100644 index 00000000000..06c618d4372 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/failed_run/script @@ -0,0 +1,11 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +title "a run that finishes FAILED is surfaced, not fatal: the deploy still completes" +trace $CLI bundle deploy + +title "the downstream job was created with the run's FAILED result_state (deploy did not abort)" +trace print_requests.py //jobs/create diff --git a/acceptance/bundle/resources/job_runs/failed_run/test.toml b/acceptance/bundle/resources/job_runs/failed_run/test.toml new file mode 100644 index 00000000000..89b2d0fc4e8 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/failed_run/test.toml @@ -0,0 +1,13 @@ +# Force my_run's GetRun to report a terminal FAILED result so we can assert a +# failed run is surfaced (readable downstream), not turned into a deploy failure. +# The first handler registered for a pattern wins, so this overrides the default +# runs/get handler. +[[Server]] +Pattern = "GET /api/2.2/jobs/runs/get" +Response.Body = '''{ + "run_id": 1, + "state": { + "life_cycle_state": "TERMINATED", + "result_state": "FAILED" + } +}''' diff --git a/acceptance/bundle/resources/job_runs/redeploy/output.txt b/acceptance/bundle/resources/job_runs/redeploy/output.txt index 35339304c37..def88a83d56 100644 --- a/acceptance/bundle/resources/job_runs/redeploy/output.txt +++ b/acceptance/bundle/resources/job_runs/redeploy/output.txt @@ -105,15 +105,8 @@ Resources: Name: my-job URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]?w=[NUMID] -=== the config change deleted the previous run and triggered a second, different run +=== the config change triggered a second, different run; the previous run is left in place >>> print_requests.py --keep //jobs/runs/delete -{ - "method": "POST", - "path": "/api/2.2/jobs/runs/delete", - "body": { - "run_id": [NUMID] - } -} >>> print_requests.py //jobs/run-now { diff --git a/acceptance/bundle/resources/job_runs/redeploy/script b/acceptance/bundle/resources/job_runs/redeploy/script index 87d035b9eee..1eed52ca460 100644 --- a/acceptance/bundle/resources/job_runs/redeploy/script +++ b/acceptance/bundle/resources/job_runs/redeploy/script @@ -16,6 +16,8 @@ trace $CLI bundle plan -o json | jq '.plan["resources.job_runs.my_run"]' trace $CLI bundle deploy trace $CLI bundle summary -title "the config change deleted the previous run and triggered a second, different run" +title "the config change triggered a second, different run; the previous run is left in place" +# recreate leaves the old run untouched (no jobs/runs/delete) and fires a fresh +# run-now with the new parameters. trace print_requests.py --keep //jobs/runs/delete trace print_requests.py //jobs/run-now diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index d4d3e9b9ea1..7e5b60e2b17 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -1063,7 +1063,9 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W require.NoError(t, err) } - deleteIsNoop := strings.HasSuffix(group, "permissions") || strings.HasSuffix(group, "grants") + // job_runs, like permissions/grants, has a noop DoDelete: a run is an + // immutable historical record left in place, so DoRead still finds it. + deleteIsNoop := strings.HasSuffix(group, "permissions") || strings.HasSuffix(group, "grants") || group == "job_runs" remoteAfterDelete, err := adapter.DoRead(ctx, createdID) if deleteIsNoop { diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 37b56806a34..d64537f4f5b 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -15,13 +15,10 @@ import ( "github.com/databricks/databricks-sdk-go/service/jobs" ) -// jobRunWaitTimeout bounds how long WaitAfterCreate polls GetRun for the run to -// reach a terminal state. It is deliberately large (not the SDK's 20m default) -// so a legitimately long run (schema migration, model training) doesn't fail the -// deploy just because it outlived a fixed budget, while still guaranteeing an -// unattended deploy (CI) can't hang forever on a run whose job has no server-side -// timeout_seconds. Matches the budget `bundle run` already uses to wait on a job -// run (see jobRunTimeout in bundle/run/job.go). +// jobRunWaitTimeout bounds how long WaitAfterCreate waits for a run to reach a +// terminal state. Large on purpose so a long but legitimate run (migration, +// training) doesn't fail the deploy, while an unattended (CI) deploy still can't +// hang forever. Matches `bundle run`'s budget (jobRunTimeout in bundle/run/job.go). const jobRunWaitTimeout = 24 * time.Hour // JobRunState is what we persist for a triggered run: the RunNow request. @@ -175,17 +172,14 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR } // DoUpdate is intentionally not implemented: a run can't be modified in place, -// so any change recreates it (delete + a fresh RunNow). +// so any change recreates it (a fresh RunNow; the prior run is left in place). -// DoDelete deletes the run (on destroy and recreate). WaitAfterCreate leaves -// runs terminal, so a recreate's prior run is safe to delete; destroying a run -// still active from an interrupted deploy is rejected by the API and surfaces. -func (r *ResourceJobRun) DoDelete(ctx context.Context, id string, _ *JobRunState) error { - runID, err := parseRunID(id) - if err != nil { - return err - } - return r.client.Jobs.DeleteRunByRunId(ctx, runID) +// DoDelete is a noop: a run is an immutable historical record, so destroy and +// recreate leave it in place. Dedup relies on the run id in state and the +// idempotency_token, so deleting the run would only tombstone its token and +// break re-running the same config. +func (*ResourceJobRun) DoDelete(_ context.Context, _ string, _ *JobRunState) error { + return nil } func parseRunID(id string) (int64, error) { @@ -197,9 +191,10 @@ func parseRunID(id string) (int64, error) { } // idempotencyToken derives a stable token from the desired state so a retried -// run-now dedupes to the existing run. Hashes the whole JobRunState so future -// state fields join dedup automatically. Hex SHA-256 (64 chars, the Jobs API -// max) of the state JSON with idempotency_token cleared. +// run-now dedupes to the existing run instead of starting a duplicate. Hashing +// the whole JobRunState means future state fields (e.g. milestone-3 triggers) +// join dedup automatically. Hex SHA-256 (64 chars, the Jobs API max), computed +// with idempotency_token cleared. func idempotencyToken(state *JobRunState) (string, error) { toHash := *state toHash.IdempotencyToken = "" diff --git a/bundle/phases/initialize.go b/bundle/phases/initialize.go index 8d51491b30f..56b7a6138f7 100644 --- a/bundle/phases/initialize.go +++ b/bundle/phases/initialize.go @@ -177,7 +177,7 @@ func Initialize(ctx context.Context, b *bundle.Bundle) { // They are set by the CLI to track the bundle deployment and must not be set by the user. validate.ValidateDeploymentFields(), - // Validate that idempotency_token is not set on job runs. It is computed automatically and must not be set by the user. + // job_runs' idempotency_token is computed automatically, so reject a user-set value. validate.ValidateJobRunIdempotencyToken(), // Reads (dynamic): * (strings) (searches for ${resources.*} references) diff --git a/libs/testserver/jobs.go b/libs/testserver/jobs.go index e93a652a401..ef1c1f425ed 100644 --- a/libs/testserver/jobs.go +++ b/libs/testserver/jobs.go @@ -349,11 +349,10 @@ func (s *FakeWorkspace) JobsRunNow(req Request) Response { return Response{StatusCode: 404} } - // The Jobs API treats run-now as idempotent: the same idempotency_token - // returns the run already created for it. Crucially, the token is not freed - // when its run is deleted: reusing it then returns an error rather than - // starting a fresh run ("If a run with the provided token is deleted, an - // error is returned" — https://docs.databricks.com/api/workspace/jobs/runnow). + // run-now is idempotent: the same token returns the existing run. The token + // is not freed when its run is deleted, so reusing it then errors instead of + // starting a fresh run. + // https://docs.databricks.com/api/workspace/jobs/runnow if request.IdempotencyToken != "" { if existing, ok := s.JobRunsByToken[request.IdempotencyToken]; ok { if _, alive := s.JobRuns[existing]; alive { @@ -929,10 +928,9 @@ func (s *FakeWorkspace) JobsDeleteRun(req Request) Response { } delete(s.JobRuns, request.RunId) - // The token mapping is intentionally kept as a tombstone: the Jobs API does - // not free an idempotency_token when its run is deleted, so a later run-now - // reusing that token errors instead of starting a fresh run (see JobsRunNow). - // Dropping the mapping here would incorrectly let the deleted token succeed. + // Keep the token->run mapping as a tombstone: the Jobs API does not free an + // idempotency_token when its run is deleted, so a later reuse errors (see + // JobsRunNow) rather than starting a fresh run. return Response{} } From f77665ff390ee4571d1d1e39e0b1bcceae213e97 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 21 Jul 2026 08:54:26 +0000 Subject: [PATCH 12/25] Print job_run page URL during wait; add flip-back and tombstone tests Surface the run page URL before WaitAfterCreate blocks so a long (up to 24h) wait isn't opaque. Add an acceptance test for the dev->prod->dev flip-back (the noop delete keeps the original run alive, so its token dedupes back to it) and a testserver unit test asserting a deleted idempotency_token is tombstoned. --- .../bundles/job-runs-idempotency-wait.md | 2 +- .../resources/job_runs/basic/output.txt | 1 + .../job_runs/destroy_redeploy/output.txt | 2 + .../job_runs/flip_back/databricks.yml | 17 +++++++ .../job_runs/flip_back/out.test.toml | 5 ++ .../resources/job_runs/flip_back/output.txt | 42 ++++++++++++++++ .../resources/job_runs/flip_back/script | 36 ++++++++++++++ .../job_runs/idempotent_recreate/output.txt | 2 + .../job_runs/job_parameters/output.txt | 1 + .../resources/job_runs/redeploy/output.txt | 2 + .../resources/job_runs/wait_output/output.txt | 1 + bundle/direct/dresources/job_run.go | 28 +++++++++++ libs/testserver/jobs_test.go | 48 +++++++++++++++++++ 13 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 acceptance/bundle/resources/job_runs/flip_back/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/flip_back/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/flip_back/output.txt create mode 100644 acceptance/bundle/resources/job_runs/flip_back/script diff --git a/.nextchanges/bundles/job-runs-idempotency-wait.md b/.nextchanges/bundles/job-runs-idempotency-wait.md index b1ca338e76e..592e2166a36 100644 --- a/.nextchanges/bundles/job-runs-idempotency-wait.md +++ b/.nextchanges/bundles/job-runs-idempotency-wait.md @@ -1 +1 @@ -direct: the experimental `job_runs` resource now waits for the run to finish, exposes its output fields (e.g. `${resources.job_runs..state.result_state}`), and dedupes runs on deploy retries. +direct: the experimental `job_runs` resource now waits for the run to finish (printing the run page URL while it waits), exposes its output fields (e.g. `${resources.job_runs..state.result_state}`), and dedupes runs on deploy retries. diff --git a/acceptance/bundle/resources/job_runs/basic/output.txt b/acceptance/bundle/resources/job_runs/basic/output.txt index e5831b0973f..5711596fd2b 100644 --- a/acceptance/bundle/resources/job_runs/basic/output.txt +++ b/acceptance/bundle/resources/job_runs/basic/output.txt @@ -34,6 +34,7 @@ Resources: >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-basic/default/files... Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[MY_JOB_ID]/run/[MY_RUN_ID] Updating deployment state... Deployment complete! diff --git a/acceptance/bundle/resources/job_runs/destroy_redeploy/output.txt b/acceptance/bundle/resources/job_runs/destroy_redeploy/output.txt index 6e1dd9a253e..aaf301288e5 100644 --- a/acceptance/bundle/resources/job_runs/destroy_redeploy/output.txt +++ b/acceptance/bundle/resources/job_runs/destroy_redeploy/output.txt @@ -3,6 +3,7 @@ >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-destroy-redeploy/default/files... Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] Updating deployment state... Deployment complete! @@ -23,6 +24,7 @@ Destroy complete! >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-destroy-redeploy/default/files... Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] Updating deployment state... Deployment complete! diff --git a/acceptance/bundle/resources/job_runs/flip_back/databricks.yml b/acceptance/bundle/resources/job_runs/flip_back/databricks.yml new file mode 100644 index 00000000000..ead9c1df65e --- /dev/null +++ b/acceptance/bundle/resources/job_runs/flip_back/databricks.yml @@ -0,0 +1,17 @@ +bundle: + name: job-runs-flip-back + +resources: + jobs: + my_job: + name: my-job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + job_runs: + my_run: + job_id: ${resources.jobs.my_job.id} + job_parameters: + env: dev diff --git a/acceptance/bundle/resources/job_runs/flip_back/out.test.toml b/acceptance/bundle/resources/job_runs/flip_back/out.test.toml new file mode 100644 index 00000000000..1a3e24fa574 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/flip_back/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +GOOSOnPR.darwin = false +GOOSOnPR.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/flip_back/output.txt b/acceptance/bundle/resources/job_runs/flip_back/output.txt new file mode 100644 index 00000000000..13ca8157aa1 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/flip_back/output.txt @@ -0,0 +1,42 @@ + +=== deploy with env=dev triggers the first run +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-flip-back/default/files... +Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] +Updating deployment state... +Deployment complete! + +=== flip env dev -> prod: the config changed, so a fresh, different run is triggered +>>> update_file.py databricks.yml env: dev env: prod + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-flip-back/default/files... +Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] +Updating deployment state... +Deployment complete! + +=== flip env prod -> dev: dedupes back to the original dev run, does not start a new one +>>> update_file.py databricks.yml env: prod env: dev + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-flip-back/default/files... +Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] +Updating deployment state... +Deployment complete! + +=== no run was ever deleted (destroy/recreate leave runs in place), and the dev run id was reused +>>> print_requests.py --keep //jobs/runs/delete +flip-back reused the original dev run ([NUMID]); prod had its own run ([NUMID]) + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.job_runs.my_run + delete resources.jobs.my_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-flip-back/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/job_runs/flip_back/script b/acceptance/bundle/resources/job_runs/flip_back/script new file mode 100644 index 00000000000..6aa0939148e --- /dev/null +++ b/acceptance/bundle/resources/job_runs/flip_back/script @@ -0,0 +1,36 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +STATE=.databricks/bundle/default/resources.json +run_id() { + jq -r '.state["resources.job_runs.my_run"].__id__' "$STATE" +} + +title "deploy with env=dev triggers the first run" +trace $CLI bundle deploy +dev1=$(run_id) + +title "flip env dev -> prod: the config changed, so a fresh, different run is triggered" +trace update_file.py databricks.yml "env: dev" "env: prod" +trace $CLI bundle deploy +prod=$(run_id) + +# The dev->prod recreate left the original dev run in place (delete is a noop), +# so its idempotency token is still live. Flipping back to dev re-issues run-now +# with that same token, which dedupes to the original dev run instead of starting +# a new one. This is a direct consequence of the noop delete + content-hash token. +title "flip env prod -> dev: dedupes back to the original dev run, does not start a new one" +trace update_file.py databricks.yml "env: prod" "env: dev" +trace $CLI bundle deploy +dev2=$(run_id) + +title "no run was ever deleted (destroy/recreate leave runs in place), and the dev run id was reused" +trace print_requests.py --keep //jobs/runs/delete +if [ "$dev1" = "$dev2" ] && [ "$dev1" != "$prod" ]; then + echo "flip-back reused the original dev run ($dev1); prod had its own run ($prod)" +else + echo "unexpected run ids: dev1=$dev1 prod=$prod dev2=$dev2" +fi diff --git a/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt b/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt index 1cb7c2b4124..96e84c0b951 100644 --- a/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt +++ b/acceptance/bundle/resources/job_runs/idempotent_recreate/output.txt @@ -3,6 +3,7 @@ >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-idempotent-recreate/default/files... Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] Updating deployment state... Deployment complete! @@ -26,6 +27,7 @@ Deployment complete! >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-idempotent-recreate/default/files... Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] Updating deployment state... Deployment complete! diff --git a/acceptance/bundle/resources/job_runs/job_parameters/output.txt b/acceptance/bundle/resources/job_runs/job_parameters/output.txt index 5d4761bf2b6..c2a456016a5 100644 --- a/acceptance/bundle/resources/job_runs/job_parameters/output.txt +++ b/acceptance/bundle/resources/job_runs/job_parameters/output.txt @@ -3,6 +3,7 @@ >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-job-parameters/default/files... Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] Updating deployment state... Deployment complete! diff --git a/acceptance/bundle/resources/job_runs/redeploy/output.txt b/acceptance/bundle/resources/job_runs/redeploy/output.txt index def88a83d56..c3c6ee9d355 100644 --- a/acceptance/bundle/resources/job_runs/redeploy/output.txt +++ b/acceptance/bundle/resources/job_runs/redeploy/output.txt @@ -3,6 +3,7 @@ >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-redeploy/default/files... Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[MY_JOB_ID]/run/[NUMID] Updating deployment state... Deployment complete! @@ -86,6 +87,7 @@ Resources: >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-redeploy/default/files... Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[MY_JOB_ID]/run/[NUMID] Updating deployment state... Deployment complete! diff --git a/acceptance/bundle/resources/job_runs/wait_output/output.txt b/acceptance/bundle/resources/job_runs/wait_output/output.txt index f7cbfee9e59..82bbf90e1d7 100644 --- a/acceptance/bundle/resources/job_runs/wait_output/output.txt +++ b/acceptance/bundle/resources/job_runs/wait_output/output.txt @@ -3,6 +3,7 @@ >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-wait-output/default/files... Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] Updating deployment state... Deployment complete! diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index d64537f4f5b..3c1f53ffd48 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -10,6 +10,8 @@ import ( "time" "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/jobs" @@ -161,6 +163,7 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR if err != nil { return nil, err } + logRunPageURL(ctx, r.client, runID) // TERMINATED/SKIPPED succeed even with a FAILED/TIMEDOUT/CANCELED // result_state (surfaced as state, not a deploy failure); only INTERNAL_ERROR // fails the deploy. @@ -171,6 +174,31 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR return makeJobRunRemote(run), nil } +// logRunPageURL best-effort surfaces the run's UI link before WaitAfterCreate +// blocks. The wait can legitimately last hours (jobRunWaitTimeout), so an +// otherwise-silent deploy would look hung. A failure here is non-fatal: the URL +// is only a progress aid, and the wait below reports any real error. +func logRunPageURL(ctx context.Context, client *databricks.WorkspaceClient, runID int64) { + var req jobs.GetRunRequest + req.RunId = runID + run, err := client.Jobs.GetRun(ctx, req) + if err != nil { + log.Debugf(ctx, "job_run: could not fetch run page URL before waiting: %v", err) + return + } + if run.RunPageUrl == "" { + return + } + msg := "Waiting for job run to complete: " + run.RunPageUrl + // A deploy always has cmdIO installed (root command's PersistentPreRunE), but + // the direct-engine unit harness calls this without one, so guard the panic. + if cmdio.HasIO(ctx) { + cmdio.LogString(ctx, msg) + } else { + log.Debugf(ctx, "%s", msg) + } +} + // DoUpdate is intentionally not implemented: a run can't be modified in place, // so any change recreates it (a fresh RunNow; the prior run is left in place). diff --git a/libs/testserver/jobs_test.go b/libs/testserver/jobs_test.go index 38e47b68b08..05b9b24b382 100644 --- a/libs/testserver/jobs_test.go +++ b/libs/testserver/jobs_test.go @@ -87,6 +87,54 @@ func TestJobsSubmit_RunReachesTerminalStateOnPoll(t *testing.T) { assert.Equal(t, jobs.RunResultStateSuccess, second.State.ResultState) } +func createJob(t *testing.T, workspace *FakeWorkspace) int64 { + t.Helper() + body, err := json.Marshal(jobs.CreateJob{Name: "my-job"}) + require.NoError(t, err) + + response := workspace.JobsCreate(Request{Body: body}) + require.Equal(t, 0, response.StatusCode) + return response.Body.(jobs.CreateResponse).JobId +} + +func runNow(t *testing.T, workspace *FakeWorkspace, request jobs.RunNow) Response { + t.Helper() + body, err := json.Marshal(request) + require.NoError(t, err) + return workspace.JobsRunNow(Request{Body: body}) +} + +// The Jobs API does not free an idempotency_token when its run is deleted, so a +// later reuse errors instead of starting a fresh run. This is exactly why +// job_runs' DoDelete is a noop: deleting a run would tombstone the deterministic +// token and break re-running the same config after destroy or a value flip-back. +func TestJobsRunNow_IdempotencyTokenTombstonedAfterDelete(t *testing.T) { + workspace := NewFakeWorkspace("http://test", "dbapi123") + jobID := createJob(t, workspace) + + const token = "stable-token" + + first := runNow(t, workspace, jobs.RunNow{JobId: jobID, IdempotencyToken: token}) + require.Equal(t, 0, first.StatusCode) + runID := first.Body.(jobs.RunNowResponse).RunId + require.NotZero(t, runID) + + // A retry with the same token dedupes to the existing run (no duplicate). + second := runNow(t, workspace, jobs.RunNow{JobId: jobID, IdempotencyToken: token}) + require.Equal(t, 0, second.StatusCode) + assert.Equal(t, runID, second.Body.(jobs.RunNowResponse).RunId) + + body, err := json.Marshal(jobs.DeleteRun{RunId: runID}) + require.NoError(t, err) + del := workspace.JobsDeleteRun(Request{Body: body}) + require.Equal(t, 0, del.StatusCode) + + // Reusing the tombstoned token now errors rather than starting a fresh run. + third := runNow(t, workspace, jobs.RunNow{JobId: jobID, IdempotencyToken: token}) + assert.Equal(t, 400, third.StatusCode) + assert.Contains(t, third.Body.(string), "has been deleted") +} + func TestJobsSubmit_RejectsInvalidGitProvider(t *testing.T) { workspace := NewFakeWorkspace("http://test", "dbapi123") From a3bac9aca1bc92c5374ca192fbe4da44e834708c Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 22 Jul 2026 09:32:00 +0000 Subject: [PATCH 13/25] Revert cosmetic comment rewording in job_run.go --- bundle/direct/dresources/job_run.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 3c1f53ffd48..141b6528741 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -74,8 +74,9 @@ func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { } } -// makeJobRunRemote maps GetRun into the RunNow-shaped remote, flattening -// overriding_parameters and the job_parameters list back into RunNow. +// makeJobRunRemote maps the GetRun response into the RunNow-shaped remote: GET +// nests the params under overriding_parameters and returns job_parameters as a +// list, so both are flattened back into RunNow. func makeJobRunRemote(run *jobs.Run) *JobRunRemote { var overriding jobs.RunParameters if run.OverridingParameters != nil { @@ -117,8 +118,8 @@ func makeJobRunRemote(run *jobs.Run) *JobRunRemote { } // DoRead returns the run as GetRun reports it; a 404 lets the planner -// re-trigger. ignore_remote_changes suppresses drift, so a run is recreated -// only on a local config change. +// re-trigger. Root ignore_remote_changes suppresses all remote drift, so a run +// is recreated only on a local config change. func (r *ResourceJobRun) DoRead(ctx context.Context, id string) (*JobRunRemote, error) { runID, err := parseRunID(id) if err != nil { From fb84a04c2ed1196731bf8b79b8de15182b2a0dbb Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 22 Jul 2026 11:14:33 +0000 Subject: [PATCH 14/25] Log job_run page URL from wait callback instead of an extra GetRun --- bundle/direct/dresources/job_run.go | 50 ++++++++++------------------- 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 141b6528741..291fdb67db7 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -11,7 +11,6 @@ import ( "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/libs/cmdio" - "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/service/jobs" @@ -164,42 +163,29 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR if err != nil { return nil, err } - logRunPageURL(ctx, r.client, runID) - // TERMINATED/SKIPPED succeed even with a FAILED/TIMEDOUT/CANCELED + // The wait polls GetRun; surface the run's UI link on the first poll that + // reports one so an otherwise-silent wait (up to jobRunWaitTimeout) doesn't + // look hung. TERMINATED/SKIPPED succeed even with a FAILED/TIMEDOUT/CANCELED // result_state (surfaced as state, not a deploy failure); only INTERNAL_ERROR // fails the deploy. - run, err := r.client.Jobs.WaitGetRunJobTerminatedOrSkipped(ctx, runID, jobRunWaitTimeout, nil) + logged := false + run, err := r.client.Jobs.WaitGetRunJobTerminatedOrSkipped(ctx, runID, jobRunWaitTimeout, func(run *jobs.Run) { + if logged || run.RunPageUrl == "" { + return + } + logged = true + // A deploy always has cmdIO installed (root command's PersistentPreRunE), + // but the direct-engine unit harness calls this without one, so guard the panic. + if cmdio.HasIO(ctx) { + cmdio.LogString(ctx, "Waiting for job run to complete: "+run.RunPageUrl) + } + }) if err != nil { return nil, err } return makeJobRunRemote(run), nil } -// logRunPageURL best-effort surfaces the run's UI link before WaitAfterCreate -// blocks. The wait can legitimately last hours (jobRunWaitTimeout), so an -// otherwise-silent deploy would look hung. A failure here is non-fatal: the URL -// is only a progress aid, and the wait below reports any real error. -func logRunPageURL(ctx context.Context, client *databricks.WorkspaceClient, runID int64) { - var req jobs.GetRunRequest - req.RunId = runID - run, err := client.Jobs.GetRun(ctx, req) - if err != nil { - log.Debugf(ctx, "job_run: could not fetch run page URL before waiting: %v", err) - return - } - if run.RunPageUrl == "" { - return - } - msg := "Waiting for job run to complete: " + run.RunPageUrl - // A deploy always has cmdIO installed (root command's PersistentPreRunE), but - // the direct-engine unit harness calls this without one, so guard the panic. - if cmdio.HasIO(ctx) { - cmdio.LogString(ctx, msg) - } else { - log.Debugf(ctx, "%s", msg) - } -} - // DoUpdate is intentionally not implemented: a run can't be modified in place, // so any change recreates it (a fresh RunNow; the prior run is left in place). @@ -220,10 +206,8 @@ func parseRunID(id string) (int64, error) { } // idempotencyToken derives a stable token from the desired state so a retried -// run-now dedupes to the existing run instead of starting a duplicate. Hashing -// the whole JobRunState means future state fields (e.g. milestone-3 triggers) -// join dedup automatically. Hex SHA-256 (64 chars, the Jobs API max), computed -// with idempotency_token cleared. +// run-now dedupes to the existing run instead of starting a duplicate. +// Hex SHA-256 (64 chars, the Jobs API max), computed with idempotency_token cleared. func idempotencyToken(state *JobRunState) (string, error) { toHash := *state toHash.IdempotencyToken = "" From 6c12ab9aa8083066e3fb64201f4591dd557c7f07 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 22 Jul 2026 11:19:50 +0000 Subject: [PATCH 15/25] Shorten comments added for job_run idempotency and wait --- bundle/direct/dresources/job_run.go | 32 ++++++++++++----------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 291fdb67db7..8baf39b4669 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -16,10 +16,9 @@ import ( "github.com/databricks/databricks-sdk-go/service/jobs" ) -// jobRunWaitTimeout bounds how long WaitAfterCreate waits for a run to reach a -// terminal state. Large on purpose so a long but legitimate run (migration, -// training) doesn't fail the deploy, while an unattended (CI) deploy still can't -// hang forever. Matches `bundle run`'s budget (jobRunTimeout in bundle/run/job.go). +// jobRunWaitTimeout caps how long WaitAfterCreate waits for a terminal state: +// large enough for a legitimate long run, but bounded so a CI deploy can't hang +// forever. Matches `bundle run` (jobRunTimeout in bundle/run/job.go). const jobRunWaitTimeout = 24 * time.Hour // JobRunState is what we persist for a triggered run: the RunNow request. @@ -144,8 +143,7 @@ func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (str if err != nil { return "", nil, err } - // Copy so the token reaches the API but never lands in state (keeps state - // token-free and plans clean). + // Set the token on a copy so it reaches the API but never lands in state. req := config.RunNow req.IdempotencyToken = token @@ -163,19 +161,16 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR if err != nil { return nil, err } - // The wait polls GetRun; surface the run's UI link on the first poll that - // reports one so an otherwise-silent wait (up to jobRunWaitTimeout) doesn't - // look hung. TERMINATED/SKIPPED succeed even with a FAILED/TIMEDOUT/CANCELED - // result_state (surfaced as state, not a deploy failure); only INTERNAL_ERROR - // fails the deploy. + // Log the run's UI link on the first poll: the wait can last up to + // jobRunWaitTimeout, so a silent deploy would look hung. TERMINATED/SKIPPED + // pass even with a FAILED result_state; only INTERNAL_ERROR fails the deploy. logged := false run, err := r.client.Jobs.WaitGetRunJobTerminatedOrSkipped(ctx, runID, jobRunWaitTimeout, func(run *jobs.Run) { if logged || run.RunPageUrl == "" { return } logged = true - // A deploy always has cmdIO installed (root command's PersistentPreRunE), - // but the direct-engine unit harness calls this without one, so guard the panic. + // The direct-engine unit harness runs without cmdIO, so guard LogString. if cmdio.HasIO(ctx) { cmdio.LogString(ctx, "Waiting for job run to complete: "+run.RunPageUrl) } @@ -189,9 +184,8 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR // DoUpdate is intentionally not implemented: a run can't be modified in place, // so any change recreates it (a fresh RunNow; the prior run is left in place). -// DoDelete is a noop: a run is an immutable historical record, so destroy and -// recreate leave it in place. Dedup relies on the run id in state and the -// idempotency_token, so deleting the run would only tombstone its token and +// DoDelete is a noop: a run is immutable history, so destroy/recreate leave it +// in place. Deleting would tombstone its idempotency_token (see JobsRunNow) and // break re-running the same config. func (*ResourceJobRun) DoDelete(_ context.Context, _ string, _ *JobRunState) error { return nil @@ -205,9 +199,9 @@ func parseRunID(id string) (int64, error) { return result, nil } -// idempotencyToken derives a stable token from the desired state so a retried -// run-now dedupes to the existing run instead of starting a duplicate. -// Hex SHA-256 (64 chars, the Jobs API max), computed with idempotency_token cleared. +// idempotencyToken hashes the desired state into a stable token so a retried +// run-now dedupes to the existing run instead of duplicating it. Hex SHA-256 +// (64 chars, the Jobs API max), computed with idempotency_token cleared. func idempotencyToken(state *JobRunState) (string, error) { toHash := *state toHash.IdempotencyToken = "" From bea7652d6f7278b673b68ef4ae8c8d190b7ac813 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 22 Jul 2026 11:36:18 +0000 Subject: [PATCH 16/25] Clarify idempotency_token validator comment --- .../config/validate/validate_job_run_idempotency_token.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bundle/config/validate/validate_job_run_idempotency_token.go b/bundle/config/validate/validate_job_run_idempotency_token.go index f1f42acf691..1e45d203087 100644 --- a/bundle/config/validate/validate_job_run_idempotency_token.go +++ b/bundle/config/validate/validate_job_run_idempotency_token.go @@ -23,9 +23,9 @@ func (v *validateJobRunIdempotencyToken) Name() string { func (v *validateJobRunIdempotencyToken) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { var diags diag.Diagnostics - // idempotency_token is computed automatically from the run configuration - // (SHA-256 of the RunNow request) so retries dedupe. A user-provided value - // would be overwritten, so reject it up front. + // The idempotency_token is derived automatically in DoCreate so a retried + // deploy reuses the existing run instead of triggering a duplicate. A value + // set here would have no effect, so reject it up front with a clear error. for name, jr := range b.Config.Resources.JobRuns { // An empty `job_runs.:` entry unmarshals to a nil pointer // (convert.ToTyped), so guard before dereferencing. From b44d5246e3b698550728e420f22fa2ca680da924 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 22 Jul 2026 12:49:11 +0000 Subject: [PATCH 17/25] Test job_runs wait polls past RUNNING; tighten comments --- .../validate_job_run_idempotency_token.go | 4 +- bundle/direct/dresources/all_test.go | 6 +-- bundle/direct/dresources/job_run_test.go | 38 +++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/bundle/config/validate/validate_job_run_idempotency_token.go b/bundle/config/validate/validate_job_run_idempotency_token.go index 1e45d203087..9e3f99873b1 100644 --- a/bundle/config/validate/validate_job_run_idempotency_token.go +++ b/bundle/config/validate/validate_job_run_idempotency_token.go @@ -27,8 +27,8 @@ func (v *validateJobRunIdempotencyToken) Apply(_ context.Context, b *bundle.Bund // deploy reuses the existing run instead of triggering a duplicate. A value // set here would have no effect, so reject it up front with a clear error. for name, jr := range b.Config.Resources.JobRuns { - // An empty `job_runs.:` entry unmarshals to a nil pointer - // (convert.ToTyped), so guard before dereferencing. + // An empty `job_runs.:` entry loads as a present key with a nil + // pointer value, so guard before dereferencing. if jr == nil || jr.IdempotencyToken == "" { continue } diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 7e5b60e2b17..82daaab48f0 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -985,10 +985,8 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W remoteStateFromWaitCreate, err := adapter.WaitAfterCreate(ctx, createdID, newState) require.NoError(t, err) if remoteStateFromWaitCreate != nil { - // WaitAfterCreate returns the settled remote, which may differ from the - // read taken right after DoCreate (a job run transitions from RUNNING to - // TERMINATED while we wait). Compare against a fresh read taken after the - // wait, which reflects that same settled state. + // A job run reads RUNNING right after DoCreate but TERMINATED after the + // wait, so compare against a fresh post-wait read, not the earlier one. remotePostWaitCreate, err := adapter.DoRead(ctx, createdID) require.NoError(t, err) require.Equal(t, remotePostWaitCreate, remoteStateFromWaitCreate) diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 25dc5d95aa9..0b64056fa69 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -1,6 +1,7 @@ package dresources import ( + "sync/atomic" "testing" "github.com/databricks/cli/libs/testserver" @@ -93,3 +94,40 @@ func TestJobRunWaitAfterCreateFailsOnInternalError(t *testing.T) { // INTERNAL_ERROR is the one terminal state that fails the deploy. require.Error(t, err) } + +// Reports RUNNING before TERMINATED, so this is the only test that exercises the +// poll loop (the others stub an already-terminal state). +func TestJobRunWaitAfterCreatePollsUntilTerminal(t *testing.T) { + server := testserver.New(t) + + // logRunPageURL does one GET before the loop, so RUNNING must cover it plus + // the waiter's first poll. + var gets atomic.Int32 + server.Handle("GET", "/api/2.2/jobs/runs/get", func(req testserver.Request) any { + if gets.Add(1) <= 2 { + return jobs.Run{RunId: 123, JobId: 456, State: &jobs.RunState{ + LifeCycleState: jobs.RunLifeCycleStateRunning, + }} + } + return jobs.Run{RunId: 123, JobId: 456, State: &jobs.RunState{ + LifeCycleState: jobs.RunLifeCycleStateTerminated, + ResultState: jobs.RunResultStateSuccess, + }} + }) + testserver.AddDefaultHandlers(server) + + client, err := databricks.NewWorkspaceClient(&databricks.Config{ + Host: server.URL, + Token: "testtoken", + }) + require.NoError(t, err) + + r := (&ResourceJobRun{}).New(client) + remote, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) + require.NoError(t, err) + + // SUCCESS is only reachable by polling past the RUNNING reads. + require.NotNil(t, remote.State) + assert.Equal(t, jobs.RunResultStateSuccess, remote.State.ResultState) + assert.GreaterOrEqual(t, gets.Load(), int32(2), "expected WaitAfterCreate to poll more than once") +} From 6be209f011842be7d271872a463614489154c519 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 22 Jul 2026 12:57:32 +0000 Subject: [PATCH 18/25] Fix misleading post-wait read comment in CRUD harness --- bundle/direct/dresources/all_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 82daaab48f0..e137ed6ff78 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -985,8 +985,8 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W remoteStateFromWaitCreate, err := adapter.WaitAfterCreate(ctx, createdID, newState) require.NoError(t, err) if remoteStateFromWaitCreate != nil { - // A job run reads RUNNING right after DoCreate but TERMINATED after the - // wait, so compare against a fresh post-wait read, not the earlier one. + // WaitAfterCreate returns the settled state; the read right after DoCreate + // may still be non-terminal, so compare against a fresh read, not that one. remotePostWaitCreate, err := adapter.DoRead(ctx, createdID) require.NoError(t, err) require.Equal(t, remotePostWaitCreate, remoteStateFromWaitCreate) From 7f4e449d072e237a73f4f3442893e411f8860209 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 23 Jul 2026 12:13:58 +0000 Subject: [PATCH 19/25] Seed job_run idempotency token with the resource key The token was a hash of only the run config, so two distinct job_run resources with byte-identical config produced the same token and the second run-now deduped onto the first, collapsing two intended runs into one. Include the deployment-scoped resource key in the hash: distinct resources now get distinct tokens (both run), a config change still re-triggers, and an unchanged retry still dedupes. The key is threaded via a framework-owned context accessor (WithResourceIdentity) so it stays a cross-cutting deploy concern rather than a per-resource hack. --- .../job_runs/distinct_runs/databricks.yml | 19 ++++++++ .../job_runs/distinct_runs/out.test.toml | 5 +++ .../job_runs/distinct_runs/output.txt | 45 +++++++++++++++++++ .../resources/job_runs/distinct_runs/script | 22 +++++++++ bundle/direct/apply.go | 1 + bundle/direct/dresources/identity.go | 19 ++++++++ bundle/direct/dresources/job_run.go | 18 +++++--- bundle/direct/dresources/job_run_test.go | 28 +++++++++--- 8 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 acceptance/bundle/resources/job_runs/distinct_runs/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/distinct_runs/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/distinct_runs/output.txt create mode 100644 acceptance/bundle/resources/job_runs/distinct_runs/script create mode 100644 bundle/direct/dresources/identity.go diff --git a/acceptance/bundle/resources/job_runs/distinct_runs/databricks.yml b/acceptance/bundle/resources/job_runs/distinct_runs/databricks.yml new file mode 100644 index 00000000000..fc9ce302fa4 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/distinct_runs/databricks.yml @@ -0,0 +1,19 @@ +bundle: + name: job-runs-distinct-runs + +resources: + jobs: + my_job: + name: my-job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + # Two runs with byte-identical config. Each is a distinct resource, so each + # must get its own run rather than deduping onto a shared idempotency token. + job_runs: + my_run_a: + job_id: ${resources.jobs.my_job.id} + my_run_b: + job_id: ${resources.jobs.my_job.id} diff --git a/acceptance/bundle/resources/job_runs/distinct_runs/out.test.toml b/acceptance/bundle/resources/job_runs/distinct_runs/out.test.toml new file mode 100644 index 00000000000..1a3e24fa574 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/distinct_runs/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +GOOSOnPR.darwin = false +GOOSOnPR.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/distinct_runs/output.txt b/acceptance/bundle/resources/job_runs/distinct_runs/output.txt new file mode 100644 index 00000000000..e04209d2401 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/distinct_runs/output.txt @@ -0,0 +1,45 @@ + +=== deploy triggers a separate run for each resource +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-distinct-runs/default/files... +Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[MY_RUN_A_ID] +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[MY_RUN_B_ID] +Updating deployment state... +Deployment complete! + +=== two run-now requests were made +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", + "job_id": [NUMID] + } +} +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", + "job_id": [NUMID] + } +} + +=== each resource stored its own run id +>>> read_id.py my_run_a + +>>> read_id.py my_run_b +run ids differ: identical config did not collapse onto one run + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.job_runs.my_run_a + delete resources.job_runs.my_run_b + delete resources.jobs.my_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-distinct-runs/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/job_runs/distinct_runs/script b/acceptance/bundle/resources/job_runs/distinct_runs/script new file mode 100644 index 00000000000..72f2a056131 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/distinct_runs/script @@ -0,0 +1,22 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +title "deploy triggers a separate run for each resource" +trace $CLI bundle deploy + +# Tokens are masked in the golden output; the distinct run ids below are the +# real proof they differ, since the fake server dedupes by token. +title "two run-now requests were made" +trace print_requests.py //jobs/run-now + +title "each resource stored its own run id" +a=$(trace read_id.py my_run_a) +b=$(trace read_id.py my_run_b) +if [ "$a" != "$b" ]; then + echo "run ids differ: identical config did not collapse onto one run" +else + echo "run ids equal ($a): the two resources deduped onto a single run" +fi diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index d227d7f564c..dba41cfc9d6 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -27,6 +27,7 @@ func (d *DeploymentUnit) Destroy(ctx context.Context, db *dstate.DeploymentState func (d *DeploymentUnit) Deploy(ctx context.Context, db *dstate.DeploymentState, newState any, actionType deployplan.ActionType, planEntry *deployplan.PlanEntry) error { ctx = log.WithPrefix(ctx, "deploying "+d.ResourceKey) + ctx = dresources.WithResourceIdentity(ctx, d.ResourceKey) if actionType == deployplan.Create { return d.Create(ctx, db, newState) } diff --git a/bundle/direct/dresources/identity.go b/bundle/direct/dresources/identity.go new file mode 100644 index 00000000000..a21ff4d0c64 --- /dev/null +++ b/bundle/direct/dresources/identity.go @@ -0,0 +1,19 @@ +package dresources + +import "context" + +type resourceIdentityKey struct{} + +// WithResourceIdentity records the deployment-scoped key of the resource being +// deployed, so resources can derive a stable per-resource seed (e.g. a run-now +// idempotency token). Set once by the framework in DeploymentUnit.Deploy. +func WithResourceIdentity(ctx context.Context, key string) context.Context { + return context.WithValue(ctx, resourceIdentityKey{}, key) +} + +// ResourceIdentity returns the key set by WithResourceIdentity, or "" if unset +// (e.g. the unit-test harness that calls resources directly). +func ResourceIdentity(ctx context.Context) string { + key, _ := ctx.Value(resourceIdentityKey{}).(string) + return key +} diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 8baf39b4669..781f115b275 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -139,7 +139,7 @@ func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { } func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (string, *JobRunRemote, error) { - token, err := idempotencyToken(config) + token, err := idempotencyToken(ctx, config) if err != nil { return "", nil, err } @@ -199,16 +199,20 @@ func parseRunID(id string) (int64, error) { return result, nil } -// idempotencyToken hashes the desired state into a stable token so a retried -// run-now dedupes to the existing run instead of duplicating it. Hex SHA-256 -// (64 chars, the Jobs API max), computed with idempotency_token cleared. -func idempotencyToken(state *JobRunState) (string, error) { +// idempotencyToken hashes the run's identity (resource key) plus its config into +// a stable token. The resource key makes two resources with identical config +// produce distinct tokens (so both run); the config makes a change re-trigger; a +// retry with neither changed reuses the run. Hex SHA-256 (64 chars, Jobs API max). +func idempotencyToken(ctx context.Context, state *JobRunState) (string, error) { toHash := *state toHash.IdempotencyToken = "" canonical, err := json.Marshal(toHash) if err != nil { return "", err } - sum := sha256.Sum256(canonical) - return hex.EncodeToString(sum[:]), nil + h := sha256.New() + h.Write([]byte(ResourceIdentity(ctx))) + h.Write([]byte{0}) // separator so key‖config can't collide via a shifted boundary + h.Write(canonical) + return hex.EncodeToString(h.Sum(nil)), nil } diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 0b64056fa69..f5584442e99 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -12,24 +12,26 @@ import ( ) func TestIdempotencyTokenIsStableHex(t *testing.T) { + ctx := t.Context() run := jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "prod"}} - got, err := idempotencyToken(&JobRunState{RunNow: run}) + got, err := idempotencyToken(ctx, &JobRunState{RunNow: run}) require.NoError(t, err) // hex SHA-256 is always 64 lowercase hex chars (the Jobs API maximum). assert.Regexp(t, "^[0-9a-f]{64}$", got) // Deterministic: the same config yields the same token, so a retry dedupes. - again, err := idempotencyToken(&JobRunState{RunNow: run}) + again, err := idempotencyToken(ctx, &JobRunState{RunNow: run}) require.NoError(t, err) assert.Equal(t, got, again) } func TestIdempotencyTokenIgnoresPresetToken(t *testing.T) { - a, err := idempotencyToken(&JobRunState{RunNow: jobs.RunNow{JobId: 123}}) + ctx := t.Context() + a, err := idempotencyToken(ctx, &JobRunState{RunNow: jobs.RunNow{JobId: 123}}) require.NoError(t, err) - b, err := idempotencyToken(&JobRunState{RunNow: jobs.RunNow{JobId: 123, IdempotencyToken: "user-supplied"}}) + b, err := idempotencyToken(ctx, &JobRunState{RunNow: jobs.RunNow{JobId: 123, IdempotencyToken: "user-supplied"}}) require.NoError(t, err) // The token is cleared before hashing, so a preset value cannot change it. @@ -37,17 +39,29 @@ func TestIdempotencyTokenIgnoresPresetToken(t *testing.T) { } func TestIdempotencyTokenChangesWithConfig(t *testing.T) { - dev, err := idempotencyToken(&JobRunState{RunNow: jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "dev"}}}) + ctx := t.Context() + dev, err := idempotencyToken(ctx, &JobRunState{RunNow: jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "dev"}}}) require.NoError(t, err) - prod, err := idempotencyToken(&JobRunState{RunNow: jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "prod"}}}) + prod, err := idempotencyToken(ctx, &JobRunState{RunNow: jobs.RunNow{JobId: 123, JobParameters: map[string]string{"env": "prod"}}}) require.NoError(t, err) - otherJob, err := idempotencyToken(&JobRunState{RunNow: jobs.RunNow{JobId: 456}}) + otherJob, err := idempotencyToken(ctx, &JobRunState{RunNow: jobs.RunNow{JobId: 456}}) require.NoError(t, err) assert.NotEqual(t, dev, prod) // different params --> different token assert.NotEqual(t, dev, otherJob) // different job_id --> different token } +func TestIdempotencyTokenChangesWithResourceIdentity(t *testing.T) { + run := jobs.RunNow{JobId: 123} + a, err := idempotencyToken(WithResourceIdentity(t.Context(), "resources.job_runs.a"), &JobRunState{RunNow: run}) + require.NoError(t, err) + b, err := idempotencyToken(WithResourceIdentity(t.Context(), "resources.job_runs.b"), &JobRunState{RunNow: run}) + require.NoError(t, err) + + // Identical config under different resource keys must not collapse onto one run. + assert.NotEqual(t, a, b) +} + // jobRunClient returns a client whose GetRun always reports the given terminal // run state, so WaitAfterCreate can be exercised without a real run. func jobRunClient(t *testing.T, state *jobs.RunState) *databricks.WorkspaceClient { From b709b5bd18c42f3f9ed75e4fea57490606c7f315 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 23 Jul 2026 12:25:02 +0000 Subject: [PATCH 20/25] Fail job_run deploy unless the run finishes SUCCESS WaitAfterCreate previously let any TERMINATED/SKIPPED run pass, surfacing a FAILED result as readable state instead of a deploy failure. Now only a SUCCESS result_state completes the deploy; every other terminal outcome (FAILED, TIMEDOUT, CANCELED, SUCCESS_WITH_FAILURES, SKIPPED, ...) returns an error. INTERNAL_ERROR and timeout are still handled by the waiter. Rewrites the failed_run acceptance test to assert the deploy aborts (and dependents are not created) and updates the unit tests accordingly. --- .../job_runs/distinct_runs/output.txt | 11 +--- .../resources/job_runs/distinct_runs/script | 21 +++--- .../job_runs/failed_run/databricks.yml | 4 +- .../resources/job_runs/failed_run/output.txt | 66 ++----------------- .../resources/job_runs/failed_run/script | 7 +- .../resources/job_runs/failed_run/test.toml | 9 ++- bundle/direct/dresources/job_run.go | 17 ++++- bundle/direct/dresources/job_run_test.go | 32 +++++++-- 8 files changed, 77 insertions(+), 90 deletions(-) diff --git a/acceptance/bundle/resources/job_runs/distinct_runs/output.txt b/acceptance/bundle/resources/job_runs/distinct_runs/output.txt index e04209d2401..ea3447a3356 100644 --- a/acceptance/bundle/resources/job_runs/distinct_runs/output.txt +++ b/acceptance/bundle/resources/job_runs/distinct_runs/output.txt @@ -3,12 +3,12 @@ >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-distinct-runs/default/files... Deploying resources... -Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[MY_RUN_A_ID] -Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[MY_RUN_B_ID] +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] Updating deployment state... Deployment complete! -=== two run-now requests were made +=== two run-now requests were made; each resource stored its own run id >>> print_requests.py //jobs/run-now { "method": "POST", @@ -26,11 +26,6 @@ Deployment complete! "job_id": [NUMID] } } - -=== each resource stored its own run id ->>> read_id.py my_run_a - ->>> read_id.py my_run_b run ids differ: identical config did not collapse onto one run >>> [CLI] bundle destroy --auto-approve diff --git a/acceptance/bundle/resources/job_runs/distinct_runs/script b/acceptance/bundle/resources/job_runs/distinct_runs/script index 72f2a056131..b90c53d18e3 100644 --- a/acceptance/bundle/resources/job_runs/distinct_runs/script +++ b/acceptance/bundle/resources/job_runs/distinct_runs/script @@ -4,19 +4,24 @@ cleanup() { } trap cleanup EXIT +STATE=.databricks/bundle/default/resources.json +run_id() { + jq -r ".state[\"resources.job_runs.$1\"].__id__" "$STATE" +} + title "deploy triggers a separate run for each resource" trace $CLI bundle deploy +a=$(run_id my_run_a) +b=$(run_id my_run_b) -# Tokens are masked in the golden output; the distinct run ids below are the -# real proof they differ, since the fake server dedupes by token. -title "two run-now requests were made" +# Both run-now bodies are identical (same job_id, masked token), so their order in +# the recording doesn't matter. Comparing ids via state rather than printing them +# leaves the parallel "Waiting..." lines above as generic [NUMID], keeping the +# golden order-independent. +title "two run-now requests were made; each resource stored its own run id" trace print_requests.py //jobs/run-now - -title "each resource stored its own run id" -a=$(trace read_id.py my_run_a) -b=$(trace read_id.py my_run_b) if [ "$a" != "$b" ]; then echo "run ids differ: identical config did not collapse onto one run" else - echo "run ids equal ($a): the two resources deduped onto a single run" + echo "run ids equal: the two resources deduped onto a single run" fi diff --git a/acceptance/bundle/resources/job_runs/failed_run/databricks.yml b/acceptance/bundle/resources/job_runs/failed_run/databricks.yml index 0e2cfc2216c..91dde0f9ab7 100644 --- a/acceptance/bundle/resources/job_runs/failed_run/databricks.yml +++ b/acceptance/bundle/resources/job_runs/failed_run/databricks.yml @@ -10,8 +10,8 @@ resources: notebook_task: notebook_path: /Workspace/test - # Reads the run's result_state. The run finishes FAILED (see test.toml stub), - # but the deploy still completes and this job is created with the resolved tag. + # Depends on my_run's result_state. The run finishes FAILED (see test.toml + # stub), so the deploy aborts at the run and this job is never created. downstream_job: name: downstream-job tags: diff --git a/acceptance/bundle/resources/job_runs/failed_run/output.txt b/acceptance/bundle/resources/job_runs/failed_run/output.txt index db57260c347..77e07ddd0d7 100644 --- a/acceptance/bundle/resources/job_runs/failed_run/output.txt +++ b/acceptance/bundle/resources/job_runs/failed_run/output.txt @@ -1,74 +1,22 @@ -=== a run that finishes FAILED is surfaced, not fatal: the deploy still completes +=== a run that finishes FAILED fails the deploy >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-failed-run/default/files... Deploying resources... -Updating deployment state... -Deployment complete! +Error: cannot create resources.job_runs.my_run: waiting after creating id=[NUMID]: job run [NUMID] did not succeed: FAILED + +Error: cannot create resources.jobs.downstream_job: dependency failed: resources.job_runs.my_run -=== the downstream job was created with the run's FAILED result_state (deploy did not abort) ->>> print_requests.py //jobs/create -{ - "method": "POST", - "path": "/api/2.2/jobs/create", - "body": { - "deployment": { - "kind": "BUNDLE", - "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/job-runs-failed-run/default/state/metadata.json" - }, - "edit_mode": "UI_LOCKED", - "format": "MULTI_TASK", - "max_concurrent_runs": 1, - "name": "my-job", - "queue": { - "enabled": true - }, - "tasks": [ - { - "notebook_task": { - "notebook_path": "/Workspace/test" - }, - "task_key": "main" - } - ] - } -} -{ - "method": "POST", - "path": "/api/2.2/jobs/create", - "body": { - "deployment": { - "kind": "BUNDLE", - "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/job-runs-failed-run/default/state/metadata.json" - }, - "edit_mode": "UI_LOCKED", - "format": "MULTI_TASK", - "max_concurrent_runs": 1, - "name": "downstream-job", - "queue": { - "enabled": true - }, - "tags": { - "run_result": "FAILED" - }, - "tasks": [ - { - "notebook_task": { - "notebook_path": "/Workspace/test" - }, - "task_key": "main" - } - ] - } -} +Updating deployment state... >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: delete resources.job_runs.my_run - delete resources.jobs.downstream_job delete resources.jobs.my_job All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-failed-run/default Deleting files... Destroy complete! + +Exit code: 1 diff --git a/acceptance/bundle/resources/job_runs/failed_run/script b/acceptance/bundle/resources/job_runs/failed_run/script index 06c618d4372..9812b06ff20 100644 --- a/acceptance/bundle/resources/job_runs/failed_run/script +++ b/acceptance/bundle/resources/job_runs/failed_run/script @@ -4,8 +4,7 @@ cleanup() { } trap cleanup EXIT -title "a run that finishes FAILED is surfaced, not fatal: the deploy still completes" +# The run finishes FAILED, so the deploy aborts here; downstream_job depends on +# my_run and is reported as "dependency failed" rather than being created. +title "a run that finishes FAILED fails the deploy" trace $CLI bundle deploy - -title "the downstream job was created with the run's FAILED result_state (deploy did not abort)" -trace print_requests.py //jobs/create diff --git a/acceptance/bundle/resources/job_runs/failed_run/test.toml b/acceptance/bundle/resources/job_runs/failed_run/test.toml index 89b2d0fc4e8..8d54d089386 100644 --- a/acceptance/bundle/resources/job_runs/failed_run/test.toml +++ b/acceptance/bundle/resources/job_runs/failed_run/test.toml @@ -1,7 +1,10 @@ # Force my_run's GetRun to report a terminal FAILED result so we can assert a -# failed run is surfaced (readable downstream), not turned into a deploy failure. -# The first handler registered for a pattern wins, so this overrides the default -# runs/get handler. +# failed run fails the deploy. The first handler registered for a pattern wins, +# so this overrides the default runs/get handler. + +# The deploy fails mid-way, leaving local deployment state behind. +Ignore = [".databricks"] + [[Server]] Pattern = "GET /api/2.2/jobs/runs/get" Response.Body = '''{ diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 781f115b275..9f2d698c5f4 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -162,8 +162,7 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR return nil, err } // Log the run's UI link on the first poll: the wait can last up to - // jobRunWaitTimeout, so a silent deploy would look hung. TERMINATED/SKIPPED - // pass even with a FAILED result_state; only INTERNAL_ERROR fails the deploy. + // jobRunWaitTimeout, so a silent deploy would look hung. logged := false run, err := r.client.Jobs.WaitGetRunJobTerminatedOrSkipped(ctx, runID, jobRunWaitTimeout, func(run *jobs.Run) { if logged || run.RunPageUrl == "" { @@ -178,6 +177,20 @@ func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobR if err != nil { return nil, err } + // Only a SUCCESS result completes the deploy; every other terminal outcome + // (FAILED, TIMEDOUT, CANCELED, SUCCESS_WITH_FAILURES, SKIPPED, ...) fails it. + // The waiter already errored out on INTERNAL_ERROR and timeout above. + if run.State.ResultState != jobs.RunResultStateSuccess { + outcome := string(run.State.ResultState) + if outcome == "" { + // A skipped run has no result_state; report the lifecycle state. + outcome = string(run.State.LifeCycleState) + } + if run.State.StateMessage != "" { + return nil, fmt.Errorf("job run %d did not succeed: %s: %s", runID, outcome, run.State.StateMessage) + } + return nil, fmt.Errorf("job run %d did not succeed: %s", runID, outcome) + } return makeJobRunRemote(run), nil } diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index f5584442e99..82f331e1425 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -81,20 +81,44 @@ func jobRunClient(t *testing.T, state *jobs.RunState) *databricks.WorkspaceClien return client } -func TestJobRunWaitAfterCreateSurfacesFailedResult(t *testing.T) { +func TestJobRunWaitAfterCreateFailsOnFailedResult(t *testing.T) { client := jobRunClient(t, &jobs.RunState{ LifeCycleState: jobs.RunLifeCycleStateTerminated, ResultState: jobs.RunResultStateFailed, + StateMessage: "task failed", + }) + + r := (&ResourceJobRun{}).New(client) + _, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) + + // Only SUCCESS completes the deploy; a FAILED result fails it. + require.ErrorContains(t, err, "did not succeed: FAILED: task failed") +} + +func TestJobRunWaitAfterCreateFailsOnSkipped(t *testing.T) { + // A skipped run has no result_state, so the lifecycle state is reported. + client := jobRunClient(t, &jobs.RunState{ + LifeCycleState: jobs.RunLifeCycleStateSkipped, + }) + + r := (&ResourceJobRun{}).New(client) + _, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) + + require.ErrorContains(t, err, "did not succeed: SKIPPED") +} + +func TestJobRunWaitAfterCreateSucceeds(t *testing.T) { + client := jobRunClient(t, &jobs.RunState{ + LifeCycleState: jobs.RunLifeCycleStateTerminated, + ResultState: jobs.RunResultStateSuccess, }) r := (&ResourceJobRun{}).New(client) remote, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) - // A run that ends TERMINATED with a FAILED result is surfaced as readable - // state, not turned into a deploy failure. require.NoError(t, err) require.NotNil(t, remote.State) - assert.Equal(t, jobs.RunResultStateFailed, remote.State.ResultState) + assert.Equal(t, jobs.RunResultStateSuccess, remote.State.ResultState) } func TestJobRunWaitAfterCreateFailsOnInternalError(t *testing.T) { From 688c9ed5050a889023df8b0b78aa94cce5681908 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 23 Jul 2026 12:53:09 +0000 Subject: [PATCH 21/25] job_runs: wait for run completion inside DoCreate Move the terminal-state wait from WaitAfterCreate into DoCreate so a deploy interrupted mid-wait re-establishes the wait on resume. The framework persists state only after DoCreate returns, so an interrupted wait saves nothing and the next deploy re-triggers RunNow; the idempotency_token rejoins the same run and waits again, instead of the planner seeing the run as up-to-date and skipping the wait. A run whose wait fails is therefore no longer written to state, so the failed_run acceptance output drops the "waiting after creating" prefix and no longer lists the run for deletion on destroy. --- .../resources/job_runs/failed_run/output.txt | 3 +-- bundle/direct/dresources/all_test.go | 6 +---- bundle/direct/dresources/job_run.go | 22 +++++++++++------ bundle/direct/dresources/job_run_test.go | 24 +++++++++---------- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/acceptance/bundle/resources/job_runs/failed_run/output.txt b/acceptance/bundle/resources/job_runs/failed_run/output.txt index 77e07ddd0d7..dd84b9e187f 100644 --- a/acceptance/bundle/resources/job_runs/failed_run/output.txt +++ b/acceptance/bundle/resources/job_runs/failed_run/output.txt @@ -3,7 +3,7 @@ >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-failed-run/default/files... Deploying resources... -Error: cannot create resources.job_runs.my_run: waiting after creating id=[NUMID]: job run [NUMID] did not succeed: FAILED +Error: cannot create resources.job_runs.my_run: job run [NUMID] did not succeed: FAILED Error: cannot create resources.jobs.downstream_job: dependency failed: resources.job_runs.my_run @@ -11,7 +11,6 @@ Updating deployment state... >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: - delete resources.job_runs.my_run delete resources.jobs.my_job All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-failed-run/default diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index d93a62db0c3..6bb976e7d4a 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -1003,11 +1003,7 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W remoteStateFromWaitCreate, err := adapter.WaitAfterCreate(ctx, createdID, newState) require.NoError(t, err) if remoteStateFromWaitCreate != nil { - // WaitAfterCreate returns the settled state; the read right after DoCreate - // may still be non-terminal, so compare against a fresh read, not that one. - remotePostWaitCreate, err := adapter.DoRead(ctx, createdID) - require.NoError(t, err) - require.Equal(t, remotePostWaitCreate, remoteStateFromWaitCreate) + require.Equal(t, remote, remoteStateFromWaitCreate) } if adapter.HasDoUpdate() { diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 9f2d698c5f4..c1286fb7b1b 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -147,20 +147,28 @@ func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (str req := config.RunNow req.IdempotencyToken = token - // RunNow returns only the run id; return a nil remote and let the framework - // read it back via DoRead. wait, err := r.client.Jobs.RunNow(ctx, req) if err != nil { return "", nil, err } - return strconv.FormatInt(wait.RunId, 10), nil, nil -} -func (r *ResourceJobRun) WaitAfterCreate(ctx context.Context, id string, _ *JobRunState) (*JobRunRemote, error) { - runID, err := parseRunID(id) + // Wait for the run to finish here rather than in a separate WaitAfterCreate: + // the framework persists state only after DoCreate returns, so a deploy + // interrupted mid-wait saves nothing and the next deploy re-triggers RunNow. + // The idempotency_token makes that re-trigger rejoin the same run, so the wait + // is re-established instead of skipped. Persisting the run before the wait + // would let the planner see the run as up-to-date and skip the wait on resume. + remote, err := r.waitForRun(ctx, wait.RunId) if err != nil { - return nil, err + return "", nil, err } + return strconv.FormatInt(wait.RunId, 10), remote, nil +} + +// waitForRun blocks until the run reaches a terminal state and returns its +// remote view. Only SUCCESS completes the deploy; any other terminal outcome is +// an error. +func (r *ResourceJobRun) waitForRun(ctx context.Context, runID int64) (*JobRunRemote, error) { // Log the run's UI link on the first poll: the wait can last up to // jobRunWaitTimeout, so a silent deploy would look hung. logged := false diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 82f331e1425..c2d4b97a4f9 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -63,7 +63,7 @@ func TestIdempotencyTokenChangesWithResourceIdentity(t *testing.T) { } // jobRunClient returns a client whose GetRun always reports the given terminal -// run state, so WaitAfterCreate can be exercised without a real run. +// run state, so waitForRun can be exercised without a real run. func jobRunClient(t *testing.T, state *jobs.RunState) *databricks.WorkspaceClient { t.Helper() server := testserver.New(t) @@ -81,7 +81,7 @@ func jobRunClient(t *testing.T, state *jobs.RunState) *databricks.WorkspaceClien return client } -func TestJobRunWaitAfterCreateFailsOnFailedResult(t *testing.T) { +func TestJobRunWaitFailsOnFailedResult(t *testing.T) { client := jobRunClient(t, &jobs.RunState{ LifeCycleState: jobs.RunLifeCycleStateTerminated, ResultState: jobs.RunResultStateFailed, @@ -89,45 +89,45 @@ func TestJobRunWaitAfterCreateFailsOnFailedResult(t *testing.T) { }) r := (&ResourceJobRun{}).New(client) - _, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) + _, err := r.waitForRun(t.Context(), 123) // Only SUCCESS completes the deploy; a FAILED result fails it. require.ErrorContains(t, err, "did not succeed: FAILED: task failed") } -func TestJobRunWaitAfterCreateFailsOnSkipped(t *testing.T) { +func TestJobRunWaitFailsOnSkipped(t *testing.T) { // A skipped run has no result_state, so the lifecycle state is reported. client := jobRunClient(t, &jobs.RunState{ LifeCycleState: jobs.RunLifeCycleStateSkipped, }) r := (&ResourceJobRun{}).New(client) - _, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) + _, err := r.waitForRun(t.Context(), 123) require.ErrorContains(t, err, "did not succeed: SKIPPED") } -func TestJobRunWaitAfterCreateSucceeds(t *testing.T) { +func TestJobRunWaitSucceeds(t *testing.T) { client := jobRunClient(t, &jobs.RunState{ LifeCycleState: jobs.RunLifeCycleStateTerminated, ResultState: jobs.RunResultStateSuccess, }) r := (&ResourceJobRun{}).New(client) - remote, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) + remote, err := r.waitForRun(t.Context(), 123) require.NoError(t, err) require.NotNil(t, remote.State) assert.Equal(t, jobs.RunResultStateSuccess, remote.State.ResultState) } -func TestJobRunWaitAfterCreateFailsOnInternalError(t *testing.T) { +func TestJobRunWaitFailsOnInternalError(t *testing.T) { client := jobRunClient(t, &jobs.RunState{ LifeCycleState: jobs.RunLifeCycleStateInternalError, }) r := (&ResourceJobRun{}).New(client) - _, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) + _, err := r.waitForRun(t.Context(), 123) // INTERNAL_ERROR is the one terminal state that fails the deploy. require.Error(t, err) @@ -135,7 +135,7 @@ func TestJobRunWaitAfterCreateFailsOnInternalError(t *testing.T) { // Reports RUNNING before TERMINATED, so this is the only test that exercises the // poll loop (the others stub an already-terminal state). -func TestJobRunWaitAfterCreatePollsUntilTerminal(t *testing.T) { +func TestJobRunWaitPollsUntilTerminal(t *testing.T) { server := testserver.New(t) // logRunPageURL does one GET before the loop, so RUNNING must cover it plus @@ -161,11 +161,11 @@ func TestJobRunWaitAfterCreatePollsUntilTerminal(t *testing.T) { require.NoError(t, err) r := (&ResourceJobRun{}).New(client) - remote, err := r.WaitAfterCreate(t.Context(), "123", &JobRunState{}) + remote, err := r.waitForRun(t.Context(), 123) require.NoError(t, err) // SUCCESS is only reachable by polling past the RUNNING reads. require.NotNil(t, remote.State) assert.Equal(t, jobs.RunResultStateSuccess, remote.State.ResultState) - assert.GreaterOrEqual(t, gets.Load(), int32(2), "expected WaitAfterCreate to poll more than once") + assert.GreaterOrEqual(t, gets.Load(), int32(2), "expected waitForRun to poll more than once") } From 05c376256e60e7c21103f002b688ff42281aa845 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 23 Jul 2026 13:12:32 +0000 Subject: [PATCH 22/25] acc: regenerate job_runs out.test.toml after main merge --- .../bundle/resources/job_runs/destroy_redeploy/out.test.toml | 2 -- .../bundle/resources/job_runs/distinct_runs/out.test.toml | 2 -- acceptance/bundle/resources/job_runs/failed_run/out.test.toml | 2 -- acceptance/bundle/resources/job_runs/flip_back/out.test.toml | 2 -- .../bundle/resources/job_runs/idempotent_recreate/out.test.toml | 2 -- acceptance/bundle/resources/job_runs/wait_output/out.test.toml | 2 -- 6 files changed, 12 deletions(-) diff --git a/acceptance/bundle/resources/job_runs/destroy_redeploy/out.test.toml b/acceptance/bundle/resources/job_runs/destroy_redeploy/out.test.toml index 1a3e24fa574..e90b6d5d1ba 100644 --- a/acceptance/bundle/resources/job_runs/destroy_redeploy/out.test.toml +++ b/acceptance/bundle/resources/job_runs/destroy_redeploy/out.test.toml @@ -1,5 +1,3 @@ Local = true Cloud = false -GOOSOnPR.darwin = false -GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/distinct_runs/out.test.toml b/acceptance/bundle/resources/job_runs/distinct_runs/out.test.toml index 1a3e24fa574..e90b6d5d1ba 100644 --- a/acceptance/bundle/resources/job_runs/distinct_runs/out.test.toml +++ b/acceptance/bundle/resources/job_runs/distinct_runs/out.test.toml @@ -1,5 +1,3 @@ Local = true Cloud = false -GOOSOnPR.darwin = false -GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/failed_run/out.test.toml b/acceptance/bundle/resources/job_runs/failed_run/out.test.toml index 1a3e24fa574..e90b6d5d1ba 100644 --- a/acceptance/bundle/resources/job_runs/failed_run/out.test.toml +++ b/acceptance/bundle/resources/job_runs/failed_run/out.test.toml @@ -1,5 +1,3 @@ Local = true Cloud = false -GOOSOnPR.darwin = false -GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/flip_back/out.test.toml b/acceptance/bundle/resources/job_runs/flip_back/out.test.toml index 1a3e24fa574..e90b6d5d1ba 100644 --- a/acceptance/bundle/resources/job_runs/flip_back/out.test.toml +++ b/acceptance/bundle/resources/job_runs/flip_back/out.test.toml @@ -1,5 +1,3 @@ Local = true Cloud = false -GOOSOnPR.darwin = false -GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml b/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml index 1a3e24fa574..e90b6d5d1ba 100644 --- a/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml +++ b/acceptance/bundle/resources/job_runs/idempotent_recreate/out.test.toml @@ -1,5 +1,3 @@ Local = true Cloud = false -GOOSOnPR.darwin = false -GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/wait_output/out.test.toml b/acceptance/bundle/resources/job_runs/wait_output/out.test.toml index 1a3e24fa574..e90b6d5d1ba 100644 --- a/acceptance/bundle/resources/job_runs/wait_output/out.test.toml +++ b/acceptance/bundle/resources/job_runs/wait_output/out.test.toml @@ -1,5 +1,3 @@ Local = true Cloud = false -GOOSOnPR.darwin = false -GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] From 491a2d9388a43384a03ca43f7775b10712f36b0c Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 23 Jul 2026 13:15:39 +0000 Subject: [PATCH 23/25] Tighten job_run comments; fix stale WaitAfterCreate references --- bundle/direct/dresources/job_run.go | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index c1286fb7b1b..e8812f115b5 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -16,8 +16,8 @@ import ( "github.com/databricks/databricks-sdk-go/service/jobs" ) -// jobRunWaitTimeout caps how long WaitAfterCreate waits for a terminal state: -// large enough for a legitimate long run, but bounded so a CI deploy can't hang +// jobRunWaitTimeout caps how long waitForRun blocks for a terminal state: large +// enough for a legitimate long run, but bounded so a CI deploy can't hang // forever. Matches `bundle run` (jobRunTimeout in bundle/run/job.go). const jobRunWaitTimeout = 24 * time.Hour @@ -152,12 +152,10 @@ func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (str return "", nil, err } - // Wait for the run to finish here rather than in a separate WaitAfterCreate: - // the framework persists state only after DoCreate returns, so a deploy - // interrupted mid-wait saves nothing and the next deploy re-triggers RunNow. - // The idempotency_token makes that re-trigger rejoin the same run, so the wait - // is re-established instead of skipped. Persisting the run before the wait - // would let the planner see the run as up-to-date and skip the wait on resume. + // Wait here, not in WaitAfterCreate: state is persisted only after DoCreate + // returns, so a deploy interrupted mid-wait saves nothing and re-triggers + // RunNow, which the idempotency_token rejoins to the same run. Waiting after + // the run is persisted would instead let the planner skip the wait on resume. remote, err := r.waitForRun(ctx, wait.RunId) if err != nil { return "", nil, err @@ -185,9 +183,9 @@ func (r *ResourceJobRun) waitForRun(ctx context.Context, runID int64) (*JobRunRe if err != nil { return nil, err } - // Only a SUCCESS result completes the deploy; every other terminal outcome - // (FAILED, TIMEDOUT, CANCELED, SUCCESS_WITH_FAILURES, SKIPPED, ...) fails it. - // The waiter already errored out on INTERNAL_ERROR and timeout above. + // Every non-SUCCESS terminal outcome (FAILED, TIMEDOUT, CANCELED, + // SUCCESS_WITH_FAILURES, SKIPPED, ...) fails the deploy; the waiter already + // errored on INTERNAL_ERROR and timeout above. if run.State.ResultState != jobs.RunResultStateSuccess { outcome := string(run.State.ResultState) if outcome == "" { From f855d29e833af85c4799d22ba6d7ec96dddfbbca Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 23 Jul 2026 14:02:21 +0000 Subject: [PATCH 24/25] job_runs: add user-configurable `rerun` to force a fresh run The idempotency_token is a content hash, so identical config never re-runs and a run that finished unsuccessfully can't be retried by redeploying. Add an optional `rerun` field folded into the token (never sent to the API); changing it recreates the run. Point the idempotency_token validator at it. Also tighten job_run comments and regenerate the bundle schema. --- .../bundles/job-runs-idempotency-wait.md | 2 +- .../job_runs/destroy_redeploy/script | 6 +- .../resources/job_runs/distinct_runs/script | 7 +-- .../resources/job_runs/flip_back/script | 7 +-- .../resources/job_runs/rerun/databricks.yml | 19 ++++++ .../resources/job_runs/rerun/out.test.toml | 3 + .../resources/job_runs/rerun/output.txt | 58 +++++++++++++++++++ .../bundle/resources/job_runs/rerun/script | 35 +++++++++++ .../resources/job_runs/wait_output/script | 4 +- bundle/config/resources/job_run.go | 5 ++ .../validate_job_run_idempotency_token.go | 7 +-- ...validate_job_run_idempotency_token_test.go | 2 +- bundle/direct/dresources/job_run.go | 34 +++++++---- bundle/direct/dresources/job_run_test.go | 20 ++++++- bundle/internal/schema/annotations.yml | 3 + bundle/schema/jsonschema.json | 4 ++ 16 files changed, 183 insertions(+), 33 deletions(-) create mode 100644 acceptance/bundle/resources/job_runs/rerun/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/rerun/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/rerun/output.txt create mode 100644 acceptance/bundle/resources/job_runs/rerun/script diff --git a/.nextchanges/bundles/job-runs-idempotency-wait.md b/.nextchanges/bundles/job-runs-idempotency-wait.md index 592e2166a36..523b238cd59 100644 --- a/.nextchanges/bundles/job-runs-idempotency-wait.md +++ b/.nextchanges/bundles/job-runs-idempotency-wait.md @@ -1 +1 @@ -direct: the experimental `job_runs` resource now waits for the run to finish (printing the run page URL while it waits), exposes its output fields (e.g. `${resources.job_runs..state.result_state}`), and dedupes runs on deploy retries. +direct: the experimental `job_runs` resource now waits for the run to finish (printing the run page URL while it waits), exposes its output fields (e.g. `${resources.job_runs..state.result_state}`), and dedupes runs on deploy retries. Set the new `rerun` field to force a fresh run of an otherwise-unchanged config. diff --git a/acceptance/bundle/resources/job_runs/destroy_redeploy/script b/acceptance/bundle/resources/job_runs/destroy_redeploy/script index fa53e1ec58d..05a9f1e1161 100644 --- a/acceptance/bundle/resources/job_runs/destroy_redeploy/script +++ b/acceptance/bundle/resources/job_runs/destroy_redeploy/script @@ -12,9 +12,7 @@ trace $CLI bundle destroy --auto-approve trace print_requests.py --keep //jobs/runs/delete title "redeploy after destroy succeeds and triggers a fresh run" -# Destroy never deleted the run, so its idempotency token is not tombstoned and -# run-now cannot error on reuse. The bundle's job is recreated with a new id, so -# the run config differs and a new run is triggered (rather than deduping to the -# old one). +# Destroy never deleted the run, so its token isn't tombstoned. The job is +# recreated with a new id, so the run config differs and a fresh run is triggered. trace $CLI bundle deploy trace print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/distinct_runs/script b/acceptance/bundle/resources/job_runs/distinct_runs/script index b90c53d18e3..d8f774b1ffa 100644 --- a/acceptance/bundle/resources/job_runs/distinct_runs/script +++ b/acceptance/bundle/resources/job_runs/distinct_runs/script @@ -14,10 +14,9 @@ trace $CLI bundle deploy a=$(run_id my_run_a) b=$(run_id my_run_b) -# Both run-now bodies are identical (same job_id, masked token), so their order in -# the recording doesn't matter. Comparing ids via state rather than printing them -# leaves the parallel "Waiting..." lines above as generic [NUMID], keeping the -# golden order-independent. +# Both run-now bodies are identical (same job_id, masked token), so recording +# order doesn't matter. Compare ids via state, not stdout, so the golden stays +# order-independent. title "two run-now requests were made; each resource stored its own run id" trace print_requests.py //jobs/run-now if [ "$a" != "$b" ]; then diff --git a/acceptance/bundle/resources/job_runs/flip_back/script b/acceptance/bundle/resources/job_runs/flip_back/script index 6aa0939148e..243ac8fb7e0 100644 --- a/acceptance/bundle/resources/job_runs/flip_back/script +++ b/acceptance/bundle/resources/job_runs/flip_back/script @@ -18,10 +18,9 @@ trace update_file.py databricks.yml "env: dev" "env: prod" trace $CLI bundle deploy prod=$(run_id) -# The dev->prod recreate left the original dev run in place (delete is a noop), -# so its idempotency token is still live. Flipping back to dev re-issues run-now -# with that same token, which dedupes to the original dev run instead of starting -# a new one. This is a direct consequence of the noop delete + content-hash token. +# The dev->prod recreate left the original dev run in place (noop delete), so its +# token is still live. Flipping back to dev re-issues run-now with that token and +# dedupes onto the original dev run instead of starting a new one. title "flip env prod -> dev: dedupes back to the original dev run, does not start a new one" trace update_file.py databricks.yml "env: prod" "env: dev" trace $CLI bundle deploy diff --git a/acceptance/bundle/resources/job_runs/rerun/databricks.yml b/acceptance/bundle/resources/job_runs/rerun/databricks.yml new file mode 100644 index 00000000000..e819bf786bc --- /dev/null +++ b/acceptance/bundle/resources/job_runs/rerun/databricks.yml @@ -0,0 +1,19 @@ +bundle: + name: job-runs-rerun + +resources: + jobs: + my_job: + name: my-job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + job_runs: + my_run: + job_id: ${resources.jobs.my_job.id} + # rerun is folded into the computed idempotency_token but never sent to the + # API. Bumping its value is the supported way to force a fresh run of an + # otherwise-unchanged config. + rerun: v1 diff --git a/acceptance/bundle/resources/job_runs/rerun/out.test.toml b/acceptance/bundle/resources/job_runs/rerun/out.test.toml new file mode 100644 index 00000000000..e90b6d5d1ba --- /dev/null +++ b/acceptance/bundle/resources/job_runs/rerun/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/rerun/output.txt b/acceptance/bundle/resources/job_runs/rerun/output.txt new file mode 100644 index 00000000000..4a4b0577cdd --- /dev/null +++ b/acceptance/bundle/resources/job_runs/rerun/output.txt @@ -0,0 +1,58 @@ + +=== deploy triggers the first run +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-rerun/default/files... +Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] +Updating deployment state... +Deployment complete! + +=== redeploy with identical config is a no-op: the run is reused, no new run-now +>>> [CLI] bundle plan -o json +"skip" + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-rerun/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== bump rerun v1 -> v2: the config changed, so a fresh, different run is triggered +>>> update_file.py databricks.yml rerun: v1 rerun: v2 + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-rerun/default/files... +Deploying resources... +Waiting for job run to complete: [DATABRICKS_URL]/?o=[NUMID]#job/[NUMID]/run/[NUMID] +Updating deployment state... +Deployment complete! + +=== run-now was issued exactly twice (initial + bump); rerun itself is never sent to the API +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", + "job_id": [NUMID] + } +} +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[IDEMPOTENCY_TOKEN]", + "job_id": [NUMID] + } +} +identical config reused run [NUMID]; bumping rerun triggered a new run [NUMID] + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.job_runs.my_run + delete resources.jobs.my_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-rerun/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/job_runs/rerun/script b/acceptance/bundle/resources/job_runs/rerun/script new file mode 100644 index 00000000000..12f4931391a --- /dev/null +++ b/acceptance/bundle/resources/job_runs/rerun/script @@ -0,0 +1,35 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +STATE=.databricks/bundle/default/resources.json +run_id() { + jq -r '.state["resources.job_runs.my_run"].__id__' "$STATE" +} + +title "deploy triggers the first run" +trace $CLI bundle deploy +first=$(run_id) + +title "redeploy with identical config is a no-op: the run is reused, no new run-now" +# rerun is unchanged, so the token is unchanged and the planner skips the run. +trace $CLI bundle plan -o json | jq '.plan["resources.job_runs.my_run"].action // "none"' +trace $CLI bundle deploy +same=$(run_id) + +title "bump rerun v1 -> v2: the config changed, so a fresh, different run is triggered" +trace update_file.py databricks.yml "rerun: v1" "rerun: v2" +trace $CLI bundle deploy +bumped=$(run_id) + +title "run-now was issued exactly twice (initial + bump); rerun itself is never sent to the API" +# Two run-now calls total; the identical-config redeploy in between added none. +# Neither body carries `rerun` -- it only feeds the masked idempotency_token. +trace print_requests.py //jobs/run-now +if [ "$first" = "$same" ] && [ "$first" != "$bumped" ]; then + echo "identical config reused run $first; bumping rerun triggered a new run $bumped" +else + echo "unexpected run ids: first=$first same=$same bumped=$bumped" +fi diff --git a/acceptance/bundle/resources/job_runs/wait_output/script b/acceptance/bundle/resources/job_runs/wait_output/script index 37bfa63428a..a03e99c6777 100644 --- a/acceptance/bundle/resources/job_runs/wait_output/script +++ b/acceptance/bundle/resources/job_runs/wait_output/script @@ -8,8 +8,8 @@ title "deploy waits for the run to finish, then the downstream job reads its res trace $CLI bundle deploy title "the downstream job was created with the run's result_state resolved into its tag" -# A concrete SUCCESS tag proves the run finished and WaitAfterCreate published -# its output before the downstream job was created. +# A concrete SUCCESS tag proves the run finished and DoCreate published its +# output before the downstream job was created. trace print_requests.py //jobs/create title "redeploy is a no-op: the resolved result_state tag is stable, not perpetual drift" diff --git a/bundle/config/resources/job_run.go b/bundle/config/resources/job_run.go index 8db6ced76e1..b67b4c9a561 100644 --- a/bundle/config/resources/job_run.go +++ b/bundle/config/resources/job_run.go @@ -20,6 +20,11 @@ type JobRun struct { BaseResource jobs.RunNow + // Rerun forces a new run when its value changes; it is folded into the + // computed idempotency_token but never sent to the API. Leave it unset for + // normal deploys; bump it to re-run identical config or retry a failed run. + Rerun string `json:"rerun,omitempty"` + // ResolvedJobID holds the run's job_id loaded from state, used only to build // the run URL. Keeping it separate from RunNow.JobId (a ${resources.jobs.*.id} // reference) lets state loading preserve that reference and its plan dependency. diff --git a/bundle/config/validate/validate_job_run_idempotency_token.go b/bundle/config/validate/validate_job_run_idempotency_token.go index 9e3f99873b1..f198d51c4ce 100644 --- a/bundle/config/validate/validate_job_run_idempotency_token.go +++ b/bundle/config/validate/validate_job_run_idempotency_token.go @@ -23,9 +23,8 @@ func (v *validateJobRunIdempotencyToken) Name() string { func (v *validateJobRunIdempotencyToken) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { var diags diag.Diagnostics - // The idempotency_token is derived automatically in DoCreate so a retried - // deploy reuses the existing run instead of triggering a duplicate. A value - // set here would have no effect, so reject it up front with a clear error. + // idempotency_token is computed in DoCreate, so a user value has no effect. + // Reject it and point at the supported knob (`rerun`) for forcing a run. for name, jr := range b.Config.Resources.JobRuns { // An empty `job_runs.:` entry loads as a present key with a nil // pointer value, so guard before dereferencing. @@ -35,7 +34,7 @@ func (v *validateJobRunIdempotencyToken) Apply(_ context.Context, b *bundle.Bund path := "resources.job_runs." + name + ".idempotency_token" diags = append(diags, diag.Diagnostic{ Severity: diag.Error, - Summary: "idempotency_token is computed automatically and must not be set in bundle configuration", + Summary: "idempotency_token is computed automatically and must not be set in bundle configuration; set `rerun` to force a new run", Paths: []dyn.Path{dyn.MustPathFromString(path)}, Locations: b.Config.GetLocations(path), }) diff --git a/bundle/config/validate/validate_job_run_idempotency_token_test.go b/bundle/config/validate/validate_job_run_idempotency_token_test.go index 52a6418b0ef..8f4a36ccb33 100644 --- a/bundle/config/validate/validate_job_run_idempotency_token_test.go +++ b/bundle/config/validate/validate_job_run_idempotency_token_test.go @@ -28,7 +28,7 @@ func TestValidateJobRunIdempotencyTokenRejectsUserValue(t *testing.T) { diags := ValidateJobRunIdempotencyToken().Apply(t.Context(), jobRunBundle("x")) require.Len(t, diags, 1) assert.Equal(t, diag.Error, diags[0].Severity) - assert.Equal(t, "idempotency_token is computed automatically and must not be set in bundle configuration", diags[0].Summary) + assert.Equal(t, "idempotency_token is computed automatically and must not be set in bundle configuration; set `rerun` to force a new run", diags[0].Summary) assert.Equal(t, "resources.job_runs.my_run.idempotency_token", diags[0].Paths[0].String()) } diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index e8812f115b5..3e761b9cde2 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -16,14 +16,18 @@ import ( "github.com/databricks/databricks-sdk-go/service/jobs" ) -// jobRunWaitTimeout caps how long waitForRun blocks for a terminal state: large -// enough for a legitimate long run, but bounded so a CI deploy can't hang -// forever. Matches `bundle run` (jobRunTimeout in bundle/run/job.go). +// jobRunWaitTimeout bounds the wait so a deploy can't hang forever. Matches +// `bundle run` (jobRunTimeout in bundle/run/job.go). const jobRunWaitTimeout = 24 * time.Hour // JobRunState is what we persist for a triggered run: the RunNow request. type JobRunState struct { jobs.RunNow + + // Rerun is a user token folded into the idempotency token (see + // idempotencyToken); changing it recreates the run. Not a RunNow field, so + // never sent to the API. + Rerun string `json:"rerun,omitempty"` } func (s *JobRunState) UnmarshalJSON(b []byte) error { @@ -39,6 +43,11 @@ func (s JobRunState) MarshalJSON() ([]byte, error) { type JobRunRemote struct { jobs.RunNow + // Rerun keeps RemoteType a superset of StateType (TestRemoteSuperset). GetRun + // never returns it, so makeJobRunRemote zeroes it and root + // ignore_remote_changes hides the drift. + Rerun string `json:"rerun,omitempty"` + RunId int64 `json:"run_id,omitempty"` RunName string `json:"run_name,omitempty"` State *jobs.RunState `json:"state,omitempty"` @@ -69,6 +78,7 @@ func (*ResourceJobRun) New(client *databricks.WorkspaceClient) *ResourceJobRun { func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { return &JobRunState{ RunNow: input.RunNow, + Rerun: input.Rerun, } } @@ -107,6 +117,8 @@ func makeJobRunRemote(run *jobs.Run) *JobRunRemote { Queue: nil, ForceSendFields: nil, }, + // Bundle-only field GetRun never reports; see the field's doc comment. + Rerun: "", RunId: run.RunId, RunName: run.RunName, State: run.State, @@ -152,10 +164,10 @@ func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (str return "", nil, err } - // Wait here, not in WaitAfterCreate: state is persisted only after DoCreate - // returns, so a deploy interrupted mid-wait saves nothing and re-triggers - // RunNow, which the idempotency_token rejoins to the same run. Waiting after - // the run is persisted would instead let the planner skip the wait on resume. + // Wait here, not in WaitAfterCreate: state persists only after DoCreate + // returns, so an interrupted wait saves nothing and re-triggers RunNow, which + // the idempotency_token rejoins to the same run. Waiting after state is saved + // would let the planner skip the wait on resume. remote, err := r.waitForRun(ctx, wait.RunId) if err != nil { return "", nil, err @@ -218,10 +230,10 @@ func parseRunID(id string) (int64, error) { return result, nil } -// idempotencyToken hashes the run's identity (resource key) plus its config into -// a stable token. The resource key makes two resources with identical config -// produce distinct tokens (so both run); the config makes a change re-trigger; a -// retry with neither changed reuses the run. Hex SHA-256 (64 chars, Jobs API max). +// idempotencyToken hashes the resource key plus config into a stable token: the +// key keeps identical configs distinct (both run), any config change (including +// the user-set rerun field) re-triggers, and an unchanged retry reuses the run. +// Hex SHA-256 (64 chars, Jobs API max). func idempotencyToken(ctx context.Context, state *JobRunState) (string, error) { toHash := *state toHash.IdempotencyToken = "" diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index c2d4b97a4f9..36e9ec9183d 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -51,6 +51,21 @@ func TestIdempotencyTokenChangesWithConfig(t *testing.T) { assert.NotEqual(t, dev, otherJob) // different job_id --> different token } +func TestIdempotencyTokenChangesWithRerun(t *testing.T) { + ctx := t.Context() + run := jobs.RunNow{JobId: 123} + + base, err := idempotencyToken(ctx, &JobRunState{RunNow: run}) + require.NoError(t, err) + bumped, err := idempotencyToken(ctx, &JobRunState{RunNow: run, Rerun: "v2"}) + require.NoError(t, err) + bumpedAgain, err := idempotencyToken(ctx, &JobRunState{RunNow: run, Rerun: "v2"}) + require.NoError(t, err) + + assert.NotEqual(t, base, bumped) // bumping rerun forces a new run + assert.Equal(t, bumped, bumpedAgain) // the same rerun value stays stable +} + func TestIdempotencyTokenChangesWithResourceIdentity(t *testing.T) { run := jobs.RunNow{JobId: 123} a, err := idempotencyToken(WithResourceIdentity(t.Context(), "resources.job_runs.a"), &JobRunState{RunNow: run}) @@ -138,8 +153,9 @@ func TestJobRunWaitFailsOnInternalError(t *testing.T) { func TestJobRunWaitPollsUntilTerminal(t *testing.T) { server := testserver.New(t) - // logRunPageURL does one GET before the loop, so RUNNING must cover it plus - // the waiter's first poll. + // waitForRun's progress callback fires on every poll (there is no separate + // GET before the loop), so returning RUNNING for the first two polls just + // makes the waiter iterate more than once before it sees TERMINATED. var gets atomic.Int32 server.Handle("GET", "/api/2.2/jobs/runs/get", func(req testserver.Request) any { if gets.Add(1) <= 2 { diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 10832fe04a6..9748abc00e8 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -975,6 +975,9 @@ resources: "python_named_params": "description": |- PLACEHOLDER + "rerun": + "description": |- + An arbitrary token that forces a new run when its value changes. Leave it unset for normal deploys; change it to re-run the same job configuration or to retry a run that finished unsuccessfully. "jobs": "description": |- The job definitions for the bundle, where each key is the name of the job. diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 4c78bd7c384..8c647c01af1 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1232,6 +1232,10 @@ "description": "The queue settings of the run.", "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/jobs.QueueSettings" }, + "rerun": { + "description": "An arbitrary token that forces a new run when its value changes. Leave it unset for normal deploys; change it to re-run the same job configuration or to retry a run that finished unsuccessfully.", + "$ref": "#/$defs/string" + }, "spark_submit_params": { "description": "[Private Preview] A list of parameters for jobs with spark submit task, for example `\"spark_submit_params\": [\"--class\", \"org.apache.spark.examples.SparkPi\"]`.\nThe parameters are passed to spark-submit script as command-line parameters. If specified upon `run-now`, it would overwrite the\nparameters specified in job setting. The JSON representation of this field (for example `{\"python_params\":[\"john doe\",\"35\"]}`)\ncannot exceed 10,000 bytes.\n\n⚠ **Deprecation note** Use [job parameters](https://docs.databricks.com/jobs/job-parameters.html#job-parameter-pushdown) to pass information down to tasks.\n\nImportant\n\nThese parameters accept only Latin characters (ASCII character set). Using non-ASCII characters returns an error.\nExamples of invalid, non-ASCII characters are Chinese, Japanese kanjis, and emojis.", "$ref": "#/$defs/slice/string", From 38e07611b539360ec718b9470676ead2e14e93d2 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 23 Jul 2026 14:10:15 +0000 Subject: [PATCH 25/25] job_runs: fix CI - exhaustruct RemapState + refschema drift - Set Rerun in RemapState's JobRunState literal (exhaustruct lint). - Regenerate acceptance/bundle/refschema/out.fields.txt for the new rerun field. --- acceptance/bundle/refschema/out.fields.txt | 1 + bundle/direct/dresources/job_run.go | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index d35bfa350f0..1705dbb32f7 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -861,6 +861,7 @@ resources.job_runs.*.python_params []string ALL resources.job_runs.*.python_params[*] string ALL resources.job_runs.*.queue *jobs.QueueSettings ALL resources.job_runs.*.queue.enabled bool ALL +resources.job_runs.*.rerun string ALL resources.job_runs.*.resolved_job_id int64 INPUT resources.job_runs.*.run_id int64 REMOTE resources.job_runs.*.run_name string REMOTE diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 3e761b9cde2..d9602b5c702 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -145,9 +145,11 @@ func (r *ResourceJobRun) DoRead(ctx context.Context, id string) (*JobRunRemote, return makeJobRunRemote(run), nil } -// RemapState extracts the embedded RunNow as the state used for diffing. +// RemapState extracts the embedded RunNow as the state used for diffing. Rerun +// is always "" here (bundle-only, never in remote); root ignore_remote_changes +// suppresses the drift against the user's value in state. func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { - return &JobRunState{RunNow: remote.RunNow} + return &JobRunState{RunNow: remote.RunNow, Rerun: remote.Rerun} } func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (string, *JobRunRemote, error) {