Skip to content

Commit f94540b

Browse files
trouzeclaude
andcommitted
feat: add cost_optimization_features to dbtcloud_job, fix CI/Merge SAO and deferral bugs (closes #664)
- Adds cost_optimization_features Set attribute as the preferred way to enable State-Aware Orchestration (SAO), replacing deprecated force_node_selection - Fixes CI/Merge jobs erroring with SAO validation (405 error) by skipping SAO validation for CI and Merge job types - Fixes CI/Merge jobs losing deferring_environment_id by preserving the value from the API response during Read - Adds 5 new acceptance tests Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 17814e7 commit f94540b

5 files changed

Lines changed: 387 additions & 51 deletions

File tree

pkg/dbt_cloud/job.go

Lines changed: 49 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -58,31 +58,32 @@ type JobCompletionTriggerCondition struct {
5858
}
5959

6060
type Job struct {
61-
ID *int `json:"id"`
62-
AccountId int64 `json:"account_id"`
63-
ProjectId int `json:"project_id"`
64-
EnvironmentId int `json:"environment_id"`
65-
Name string `json:"name"`
66-
CompareChangesFlags string `json:"compare_changes_flags"`
67-
DbtVersion *string `json:"dbt_version"`
68-
DeferringEnvironmentId *int `json:"deferring_environment_id"`
69-
DeferringJobId *int `json:"deferring_job_definition_id"`
70-
Description string `json:"description"`
71-
ErrorsOnLintFailure bool `json:"errors_on_lint_failure"`
72-
ExecuteSteps []string `json:"execute_steps"`
73-
Execution JobExecution `json:"execution"`
74-
ForceNodeSelection *bool `json:"force_node_selection,omitempty"`
75-
GenerateDocs bool `json:"generate_docs"`
76-
JobCompletionTrigger *JobCompletionTrigger `json:"job_completion_trigger_condition"`
77-
JobType string `json:"job_type,omitempty"`
78-
RunCompareChanges bool `json:"run_compare_changes"`
79-
RunGenerateSources bool `json:"run_generate_sources"`
80-
RunLint bool `json:"run_lint"`
81-
Schedule JobSchedule `json:"schedule"`
82-
Settings JobSettings `json:"settings"`
83-
State int `json:"state"`
84-
TriggersOnDraftPR bool `json:"triggers_on_draft_pr"`
85-
Triggers JobTrigger `json:"triggers"`
61+
ID *int `json:"id"`
62+
AccountId int64 `json:"account_id"`
63+
ProjectId int `json:"project_id"`
64+
EnvironmentId int `json:"environment_id"`
65+
Name string `json:"name"`
66+
CompareChangesFlags string `json:"compare_changes_flags"`
67+
CostOptimizationFeatures []string `json:"cost_optimization_features,omitempty"`
68+
DbtVersion *string `json:"dbt_version"`
69+
DeferringEnvironmentId *int `json:"deferring_environment_id"`
70+
DeferringJobId *int `json:"deferring_job_definition_id"`
71+
Description string `json:"description"`
72+
ErrorsOnLintFailure bool `json:"errors_on_lint_failure"`
73+
ExecuteSteps []string `json:"execute_steps"`
74+
Execution JobExecution `json:"execution"`
75+
ForceNodeSelection *bool `json:"force_node_selection,omitempty"`
76+
GenerateDocs bool `json:"generate_docs"`
77+
JobCompletionTrigger *JobCompletionTrigger `json:"job_completion_trigger_condition"`
78+
JobType string `json:"job_type,omitempty"`
79+
RunCompareChanges bool `json:"run_compare_changes"`
80+
RunGenerateSources bool `json:"run_generate_sources"`
81+
RunLint bool `json:"run_lint"`
82+
Schedule JobSchedule `json:"schedule"`
83+
Settings JobSettings `json:"settings"`
84+
State int `json:"state"`
85+
TriggersOnDraftPR bool `json:"triggers_on_draft_pr"`
86+
Triggers JobTrigger `json:"triggers"`
8687
}
8788

8889
type JobWithEnvironment struct {
@@ -144,6 +145,7 @@ func (c *Client) CreateJob(
144145
jobType string,
145146
compareChangesFlags string,
146147
forceNodeSelection *bool,
148+
costOptimizationFeatures []string,
147149
) (*Job, error) {
148150
state := STATE_ACTIVE
149151
if !isActive {
@@ -240,27 +242,28 @@ func (c *Client) CreateJob(
240242
}
241243

242244
newJob := Job{
243-
AccountId: c.AccountID,
244-
ProjectId: projectId,
245-
EnvironmentId: environmentId,
246-
Name: name,
247-
Description: description,
248-
ExecuteSteps: executeSteps,
249-
State: state,
250-
Triggers: jobTriggers,
251-
Settings: jobSettings,
252-
Schedule: jobSchedule,
253-
GenerateDocs: generateDocs,
254-
RunGenerateSources: runGenerateSources,
255-
Execution: jobExecution,
256-
ForceNodeSelection: forceNodeSelection,
257-
TriggersOnDraftPR: triggersOnDraftPR,
258-
JobCompletionTrigger: jobCompletionTrigger,
259-
JobType: finalJobType,
260-
RunCompareChanges: runCompareChanges,
261-
RunLint: runLint,
262-
ErrorsOnLintFailure: errorsOnLintFailure,
263-
CompareChangesFlags: compareChangesFlags,
245+
AccountId: c.AccountID,
246+
ProjectId: projectId,
247+
EnvironmentId: environmentId,
248+
Name: name,
249+
Description: description,
250+
ExecuteSteps: executeSteps,
251+
State: state,
252+
Triggers: jobTriggers,
253+
Settings: jobSettings,
254+
Schedule: jobSchedule,
255+
GenerateDocs: generateDocs,
256+
RunGenerateSources: runGenerateSources,
257+
Execution: jobExecution,
258+
ForceNodeSelection: forceNodeSelection,
259+
CostOptimizationFeatures: costOptimizationFeatures,
260+
TriggersOnDraftPR: triggersOnDraftPR,
261+
JobCompletionTrigger: jobCompletionTrigger,
262+
JobType: finalJobType,
263+
RunCompareChanges: runCompareChanges,
264+
RunLint: runLint,
265+
ErrorsOnLintFailure: errorsOnLintFailure,
266+
CompareChangesFlags: compareChangesFlags,
264267
}
265268
if dbtVersion != "" {
266269
newJob.DbtVersion = &dbtVersion

pkg/framework/objects/job/model.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ type JobResourceModel struct {
115115
ExecuteSteps []types.String `tfsdk:"execute_steps"` // exists
116116
ValidateExecuteSteps types.Bool `tfsdk:"validate_execute_steps"` // opt-in validation
117117
DeferringEnvironmentID types.Int64 `tfsdk:"deferring_environment_id"` // exists
118-
ForceNodeSelection types.Bool `tfsdk:"force_node_selection"` // exists
118+
ForceNodeSelection types.Bool `tfsdk:"force_node_selection"` // exists
119+
CostOptimizationFeatures types.Set `tfsdk:"cost_optimization_features"` // replaces force_node_selection
119120
Triggers *JobTriggers `tfsdk:"triggers"` // exists
120121
// Settings *JobSettings `tfsdk:"settings"` // has no of threads and target name
121122
// Schedule *JobSchedule `tfsdk:"schedule"` // has cron expression

pkg/framework/objects/job/resource.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud"
1212
"github.com/dbt-labs/terraform-provider-dbtcloud/pkg/helper"
1313
"github.com/dbt-labs/terraform-provider-dbtcloud/pkg/utils"
14+
"github.com/hashicorp/terraform-plugin-framework/attr"
1415
"github.com/hashicorp/terraform-plugin-framework/path"
1516
"github.com/hashicorp/terraform-plugin-framework/resource"
1617
"github.com/hashicorp/terraform-plugin-framework/types"
@@ -277,6 +278,11 @@ func (j *jobResource) Create(ctx context.Context, req resource.CreateRequest, re
277278
forceNodeSelection = &fns
278279
}
279280

281+
var costOptimizationFeatures []string
282+
if !plan.CostOptimizationFeatures.IsNull() && !plan.CostOptimizationFeatures.IsUnknown() {
283+
costOptimizationFeatures = helper.StringSetToStringSlice(plan.CostOptimizationFeatures)
284+
}
285+
280286
var jobCompletionTriggerCondition map[string]any
281287

282288
if len(plan.JobCompletionTriggerCondition) != 0 {
@@ -334,6 +340,7 @@ func (j *jobResource) Create(ctx context.Context, req resource.CreateRequest, re
334340
jobType,
335341
compareChangesFlags,
336342
forceNodeSelection,
343+
costOptimizationFeatures,
337344
)
338345
if err != nil {
339346
resp.Diagnostics.AddError(
@@ -391,6 +398,9 @@ func (j *jobResource) Create(ctx context.Context, req resource.CreateRequest, re
391398
}
392399
}
393400

401+
// Populate cost_optimization_features from API response
402+
plan.CostOptimizationFeatures = sliceStringToTypesSet(createdJob.CostOptimizationFeatures)
403+
394404
jobIDStr := strconv.FormatInt(int64(*createdJob.ID), 10)
395405

396406
// Check if DeferringJobId is set and matches this job's ID for self-deferring
@@ -610,6 +620,9 @@ func (j *jobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
610620
state.ForceNodeSelection = types.BoolNull()
611621
}
612622

623+
// Populate cost_optimization_features from API response
624+
state.CostOptimizationFeatures = sliceStringToTypesSet(retrievedJob.CostOptimizationFeatures)
625+
613626
if retrievedJob.JobType != "" {
614627
state.JobType = types.StringValue(retrievedJob.JobType)
615628
} else {
@@ -732,7 +745,11 @@ func (j *jobResource) Update(ctx context.Context, req resource.UpdateRequest, re
732745
}
733746

734747
if plan.DeferringEnvironmentID.IsNull() || plan.DeferringEnvironmentID.ValueInt64() == 0 {
735-
job.DeferringEnvironmentId = nil
748+
// For CI and Merge jobs, preserve the API's deferring_environment_id when the plan doesn't set it.
749+
// Other job types clear it when not specified.
750+
if job.JobType != JobTypeCI && job.JobType != JobTypeMerge {
751+
job.DeferringEnvironmentId = nil
752+
}
736753
} else {
737754
deferringEnvId := int(plan.DeferringEnvironmentID.ValueInt64())
738755
job.DeferringEnvironmentId = &deferringEnvId
@@ -784,11 +801,25 @@ func (j *jobResource) Update(ctx context.Context, req resource.UpdateRequest, re
784801
job.ErrorsOnLintFailure = plan.ErrorsOnLintFailure.ValueBool()
785802
job.CompareChangesFlags = plan.CompareChangesFlags.ValueString()
786803

787-
if plan.ForceNodeSelection.IsNull() {
804+
// CI and Merge jobs do not support SAO features (force_node_selection / cost_optimization_features).
805+
// Sending these fields for those job types results in a 405 from the API, so we skip them.
806+
isCIOrMerge := job.JobType == JobTypeCI || job.JobType == JobTypeMerge
807+
if isCIOrMerge {
788808
job.ForceNodeSelection = nil
809+
job.CostOptimizationFeatures = nil
789810
} else {
790-
fns := plan.ForceNodeSelection.ValueBool()
791-
job.ForceNodeSelection = &fns
811+
if plan.ForceNodeSelection.IsNull() {
812+
job.ForceNodeSelection = nil
813+
} else {
814+
fns := plan.ForceNodeSelection.ValueBool()
815+
job.ForceNodeSelection = &fns
816+
}
817+
818+
if !plan.CostOptimizationFeatures.IsNull() && !plan.CostOptimizationFeatures.IsUnknown() {
819+
job.CostOptimizationFeatures = helper.StringSetToStringSlice(plan.CostOptimizationFeatures)
820+
} else {
821+
job.CostOptimizationFeatures = nil
822+
}
792823
}
793824

794825
// Handle job_type updates with validation
@@ -873,6 +904,17 @@ func (j *jobResource) Update(ctx context.Context, req resource.UpdateRequest, re
873904
plan.ForceNodeSelection = types.BoolNull()
874905
}
875906

907+
// Populate cost_optimization_features from API response
908+
plan.CostOptimizationFeatures = sliceStringToTypesSet(updatedJob.CostOptimizationFeatures)
909+
910+
// For CI/Merge jobs, preserve deferring_environment_id from the API response
911+
// so it is not lost in state when the plan did not explicitly configure it.
912+
if updatedJob.DeferringEnvironmentId != nil {
913+
plan.DeferringEnvironmentID = types.Int64Value(int64(*updatedJob.DeferringEnvironmentId))
914+
} else if plan.DeferringEnvironmentID.IsNull() || plan.DeferringEnvironmentID.ValueInt64() == 0 {
915+
plan.DeferringEnvironmentID = types.Int64Null()
916+
}
917+
876918
updatedJobIDStr := strconv.FormatInt(jobID, 10)
877919
updatedSelfDeferring := updatedJob.DeferringJobId != nil && strconv.Itoa(*updatedJob.DeferringJobId) == updatedJobIDStr
878920
plan.SelfDeferring = types.BoolValue(updatedSelfDeferring)
@@ -884,6 +926,18 @@ func (j *jobResource) Update(ctx context.Context, req resource.UpdateRequest, re
884926
}
885927
}
886928

929+
// sliceStringToTypesSet returns a types.Set of strings; an empty set for nil/empty input.
930+
func sliceStringToTypesSet(values []string) types.Set {
931+
if len(values) == 0 {
932+
return types.SetValueMust(types.StringType, []attr.Value{})
933+
}
934+
elems := make([]attr.Value, len(values))
935+
for i, v := range values {
936+
elems[i] = types.StringValue(v)
937+
}
938+
return types.SetValueMust(types.StringType, elems)
939+
}
940+
887941
func (j *jobResource) validateExecuteSteps(executeSteps []string) error {
888942
dbt_flags := []string{
889943
"--warn-error",

0 commit comments

Comments
 (0)