Skip to content

Commit e4870b6

Browse files
authored
Introduce scheduler_config column to the job_configs table (ArroyoSystems#1064)
* Introduce `scheduler_config` column to the job_configs table Adds a per-job scheduler configuration that overlays the controller's global scheduler config at scheduling time. For the Kubernetes scheduler, jobs can now override image, image_pull_policy, image_pull_secrets, command, service_account_name, resources, task_slots, env, volumes, volume_mounts, node_selector, tolerations, labels, and annotations. Persistence * Postgres migration V31 (SQLite V9) adds a nullable JSONB `scheduler_config` column to `job_configs`. NULL means "no per-job config — use the controller's global scheduler config". Wire format * Payload is versioned: { "version": 1, "type": "kubernetes", ... } * `version` is mandatory on the wire; mirrors the `state_context` pattern (see V29). Unknown versions deserialize but are rejected by `SchedulerConfig::ensure_supported_version()`. Validation * arroyo-api/src/jobs.rs::validate_scheduler_config rejects payloads whose version is unknown or whose variant doesn't match the controller's active scheduler (HTTP 400). Called early in create_pipeline_inner so bad payloads short-circuit before SQL compilation. Read path * Job API responses surface scheduler_config as raw serde_json::Value (with #[schema(value_type = SchedulerConfig)] keeping the OpenAPI spec strongly typed). No silent rewriting or dropping on read. * Controller stores the raw JSON on JobConfig and deserializes at the consumer (states::Scheduling) with fatal() on failure, mirroring how env_vars is handled. A malformed row fails only that job's state machine, not the whole updater loop. K8s merge semantics (resolved_config) * image, image_pull_policy, command, service_account_name, task_slots, resources → replace * labels, annotations, node_selector → merge map (per-job wins, intrinsic labels still applied last) * image_pull_secrets, volumes, volume_mounts, tolerations, env → append (global first, per-job last) * Address Micah's comments * Pass the configs as an opaque blob through the API * Use figment for config merging * Introduce fatal error within the scehduler * Move is_empty_overlay to the schedulers/mod.rs file * Fix SQLite NOT NULL constraint on scheduler_config The scheduler_config column is NOT NULL, but the API was passing serde_json::Value::Null (the default of serde_json::Value) when the client omitted the field. Postgres jsonb accepts a JSON null as a valid value, but SQLite TEXT does not distinguish between SQL NULL and JSON null, causing the integ tests to fail with: NOT NULL constraint failed: job_configs.scheduler_config Change PipelinePost.scheduler_config to Option<serde_json::Value> (mirroring the env_vars pattern) and collapse None / Some(Null) into an empty object at the API boundary so the column always carries a valid JSON object.
1 parent 8ead2cb commit e4870b6

13 files changed

Lines changed: 229 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Per-job scheduler config overlay. Same shape as the controller's
2+
-- global scheduler config (e.g. kubernetes-scheduler.*); an empty
3+
-- object means "no overrides, use the global config as-is".
4+
ALTER TABLE job_configs ADD COLUMN scheduler_config JSONB NOT NULL DEFAULT '{}'::jsonb;

crates/arroyo-api/queries/api_queries.sql

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ WHERE id = :job_id AND organization_id = :organization_id;
188188

189189
--! create_job(ttl_micros?)
190190
INSERT INTO job_configs
191-
(id, organization_id, pipeline_name, created_by, pipeline_id, checkpoint_interval_micros, ttl_micros, env_vars)
192-
VALUES (:id, :organization_id, :pipeline_name, :created_by, :pipeline_id, :checkpoint_interval_micros, :ttl_micros, :env_vars);
191+
(id, organization_id, pipeline_name, created_by, pipeline_id, checkpoint_interval_micros, ttl_micros, env_vars, scheduler_config)
192+
VALUES (:id, :organization_id, :pipeline_name, :created_by, :pipeline_id, :checkpoint_interval_micros, :ttl_micros, :env_vars, :scheduler_config);
193193

194194
--! create_job_status
195195
INSERT INTO job_statuses (pub_id, id, organization_id) VALUES (:pub_id, :id, :organization_id);
@@ -203,23 +203,23 @@ WHERE job_configs.organization_id = :organization_id AND ttl_micros IS NULL
203203
ORDER BY COALESCE(job_configs.updated_at, job_configs.created_at) DESC;
204204

205205
--! get_pipeline_jobs : DbPipelineJob(start_time?, finish_time?, state?, tasks?, failure_message?, failure_domain?, run_id?, state_context?)
206-
SELECT job_configs.id, stop, start_time, finish_time, state, tasks, failure_message, failure_domain, run_id, checkpoint_interval_micros, job_configs.created_at, state_context
206+
SELECT job_configs.id, stop, start_time, finish_time, state, tasks, failure_message, failure_domain, run_id, checkpoint_interval_micros, job_configs.created_at, state_context, scheduler_config
207207
FROM job_configs
208208
INNER JOIN job_statuses ON job_configs.id = job_statuses.id
209209
INNER JOIN pipelines ON pipelines.id = job_configs.pipeline_id
210210
WHERE job_configs.organization_id = :organization_id AND pipelines.pub_id = :pub_id
211211
ORDER BY job_configs.created_at DESC;
212212

213213
--! get_all_jobs : DbPipelineJob(start_time?, finish_time?, state?, tasks?, failure_message?, failure_domain?, run_id?, state_context?)
214-
SELECT job_configs.id, stop, start_time, finish_time, state, tasks, failure_message, failure_domain, run_id, checkpoint_interval_micros, job_configs.created_at, state_context
214+
SELECT job_configs.id, stop, start_time, finish_time, state, tasks, failure_message, failure_domain, run_id, checkpoint_interval_micros, job_configs.created_at, state_context, scheduler_config
215215
FROM job_configs
216216
INNER JOIN job_statuses ON job_configs.id = job_statuses.id
217217
INNER JOIN pipelines ON pipelines.id = job_configs.pipeline_id
218218
WHERE job_configs.organization_id = :organization_id AND ttl_micros IS NULL
219219
ORDER BY job_configs.created_at DESC;
220220

221221
--! get_pipeline_job : DbPipelineJob(start_time?, finish_time?, state?, tasks?, failure_message?, failure_domain?, run_id?, state_context?)
222-
SELECT job_configs.id, stop, start_time, finish_time, state, tasks, failure_message, failure_domain, run_id, checkpoint_interval_micros, job_configs.created_at, state_context
222+
SELECT job_configs.id, stop, start_time, finish_time, state, tasks, failure_message, failure_domain, run_id, checkpoint_interval_micros, job_configs.created_at, state_context, scheduler_config
223223
FROM job_configs
224224
INNER JOIN job_statuses ON job_configs.id = job_statuses.id
225225
INNER JOIN pipelines ON pipelines.id = job_configs.pipeline_id
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE job_configs ADD COLUMN scheduler_config TEXT NOT NULL DEFAULT '{}';

crates/arroyo-api/src/jobs.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ fn operator_checkpoint_groups(
113113
operators
114114
}
115115

116+
#[allow(clippy::too_many_arguments)]
116117
pub(crate) async fn create_job(
117118
pipeline_name: &str,
118119
pipeline_id: i64,
@@ -121,6 +122,7 @@ pub(crate) async fn create_job(
121122
auth: &AuthData,
122123
db: &DatabaseSource,
123124
env_vars: HashMap<String, String>,
125+
scheduler_config: serde_json::Value,
124126
) -> Result<String, ErrorResp> {
125127
let checkpoint_interval = if preview {
126128
Duration::from_secs(24 * 60 * 60)
@@ -177,6 +179,7 @@ pub(crate) async fn create_job(
177179
None
178180
}),
179181
&env_vars_json,
182+
&scheduler_config,
180183
)
181184
.await?;
182185

crates/arroyo-api/src/pipelines.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ pub(crate) async fn create_pipeline_int(
306306
state_url: Option<String>,
307307
tags: HashMap<String, String>,
308308
env_vars: HashMap<String, String>,
309+
scheduler_config: serde_json::Value,
309310
) -> Result<String, ErrorResp> {
310311
if parallelism > auth.org_metadata.max_parallelism as u64 {
311312
return Err(bad_request(format!(
@@ -443,6 +444,7 @@ pub(crate) async fn create_pipeline_int(
443444
&auth,
444445
db,
445446
env_vars,
447+
scheduler_config,
446448
)
447449
.await?;
448450

@@ -532,6 +534,7 @@ impl From<DbPipelineJob> for Job {
532534
.unwrap_or_default(),
533535
}),
534536
created_at: to_micros(val.created_at),
537+
scheduler_config: val.scheduler_config,
535538
}
536539
}
537540
}
@@ -671,6 +674,10 @@ async fn create_pipeline_inner(
671674
pipeline_post.state_url,
672675
pipeline_post.tags.unwrap_or_default(),
673676
pipeline_post.env_vars.unwrap_or_default(),
677+
match pipeline_post.scheduler_config {
678+
None | Some(serde_json::Value::Null) => serde_json::Value::Object(Default::default()),
679+
Some(v) => v,
680+
},
674681
)
675682
.await?;
676683

@@ -725,6 +732,7 @@ pub async fn create_preview_pipeline(
725732
None,
726733
HashMap::default(),
727734
HashMap::default(),
735+
serde_json::Value::Object(Default::default()),
728736
)
729737
.await?;
730738

crates/arroyo-controller/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ kube = { version = "0.99", features = ["runtime", "derive"] }
3434
k8s-openapi = { workspace = true, features = ["v1_30"] }
3535
shlex = "1.3"
3636

37+
figment = { version = "0.10", features = ["json"] }
38+
3739
# json-schema support
3840
serde_json = { workspace = true }
3941

crates/arroyo-controller/queries/controller_queries.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ SELECT
2323
restart_mode,
2424
ignore_state_before_epoch,
2525
state_context,
26-
env_vars
26+
env_vars,
27+
scheduler_config
2728
FROM job_configs c
2829
INNER JOIN job_statuses s ON c.id = s.id;
2930

crates/arroyo-controller/src/lib.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ use types::public::{RestartMode, StopMode};
6060

6161
pub const CHECKPOINTS_TO_KEEP: u32 = 5;
6262

63-
#[derive(Eq, PartialEq, Clone, Debug)]
63+
#[derive(PartialEq, Clone, Debug)]
6464
pub struct JobConfig {
6565
id: Arc<String>,
6666
organization_id: String,
@@ -75,6 +75,11 @@ pub struct JobConfig {
7575
ignore_state_before_epoch: Option<i32>,
7676
/// Per-job environment variables forwarded to workers at scheduling time.
7777
env_vars: serde_json::Value,
78+
/// Per-job scheduler configuration overlay as raw JSON (same
79+
/// shape as the controller-wide scheduler config). The scheduler
80+
/// interprets this; the controller treats it as opaque. An empty
81+
/// object is the no-override case.
82+
scheduler_config: serde_json::Value,
7883
}
7984

8085
/// Per-pipeline data that doesn't change for the lifetime of a job.
@@ -628,6 +633,7 @@ impl ControllerServer {
628633
restart_mode: p.restart_mode,
629634
ignore_state_before_epoch: p.ignore_state_before_epoch,
630635
env_vars: p.env_vars,
636+
scheduler_config: p.scheduler_config,
631637
};
632638

633639
let mut jobs = jobs.lock().await;

0 commit comments

Comments
 (0)