Skip to content

Commit 3f4169c

Browse files
committed
Add support for starting/restarting without state in leader mode
1 parent 4ddc8fd commit 3f4169c

8 files changed

Lines changed: 181 additions & 38 deletions

File tree

crates/arroyo-api/queries/api_queries.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ UPDATE job_configs
183183
SET
184184
updated_at = :updated_at,
185185
updated_by = :updated_by,
186+
stop = 'none',
186187
restart_nonce = restart_nonce + 1,
187188
restart_mode = :mode,
188189
ignore_state_before_epoch = :ignore_state_before_epoch

crates/arroyo-api/src/pipelines.rs

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ use crate::rest_utils::{
5252
use crate::types::public::{PipelineType, RestartMode, StopMode};
5353
use crate::udfs::build_udf;
5454
use crate::{connection_tables, to_micros};
55-
use arroyo_rpc::config::config;
55+
use arroyo_rpc::config::{JobControllerMode, config};
5656
use arroyo_rpc::errors::ErrorDomain;
5757
use arroyo_types::to_millis;
5858
use cornucopia_async::{Database, DatabaseSource};
@@ -868,29 +868,44 @@ pub async fn restart_pipeline(
868868
WithRejection(Json(req), _): WithRejection<Json<PipelineRestart>, ApiError>,
869869
) -> Result<Json<Pipeline>, ErrorResp> {
870870
let auth_data = authenticate(&state.database, bearer_auth).await?;
871+
871872
let db = state.database.client().await?;
872873

873-
let job_id = api_queries::fetch_get_pipeline_jobs(&db, &auth_data.organization_id, &id)
874+
let job = api_queries::fetch_get_pipeline_jobs(&db, &auth_data.organization_id, &id)
874875
.await?
875876
.into_iter()
876877
.next()
877-
.ok_or_else(|| bad_request("No jobs for pipeline"))?
878-
.id;
878+
.ok_or_else(|| bad_request("No jobs for pipeline"))?;
879879

880880
let mode = if req.force == Some(true) {
881881
RestartMode::force
882882
} else {
883883
RestartMode::safe
884884
};
885885

886-
// If user wants to ignore state, query max checkpoint epoch and compute threshold
887886
let ignore_before_epoch = if req.ignore_state.unwrap_or(false) {
888-
api_queries::fetch_max_checkpoint_epoch(&db, &job_id, &auth_data.organization_id)
889-
.await?
890-
.into_iter()
891-
.next()
892-
.and_then(|r| r.max_epoch)
893-
.map(|max_epoch| max_epoch + 1)
887+
match config().job_controller {
888+
JobControllerMode::Controller => {
889+
// Controller mode uses this as an epoch threshold.
890+
api_queries::fetch_max_checkpoint_epoch(&db, &job.id, &auth_data.organization_id)
891+
.await?
892+
.into_iter()
893+
.next()
894+
.and_then(|r| r.max_epoch)
895+
.map(|max_epoch| max_epoch + 1)
896+
}
897+
JobControllerMode::Worker => {
898+
// Leader mode uses this as the generation that should start without state.
899+
Some(
900+
job.run_id
901+
.unwrap_or(0)
902+
.max(0)
903+
.checked_add(1)
904+
.and_then(|generation| generation.try_into().ok())
905+
.ok_or_else(|| bad_request("Job generation is too large to restart"))?,
906+
)
907+
}
908+
}
894909
} else {
895910
None
896911
};
@@ -901,7 +916,7 @@ pub async fn restart_pipeline(
901916
&auth_data.user_id,
902917
&mode,
903918
&ignore_before_epoch,
904-
&job_id,
919+
&job.id,
905920
&auth_data.organization_id,
906921
)
907922
.await?;

crates/arroyo-controller/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ pub struct JobConfig {
7272
parallelism_overrides: HashMap<u32, usize>,
7373
restart_nonce: i32,
7474
restart_mode: RestartMode,
75+
/// Minimum checkpoint epoch in controller mode; generation to start without state in leader
76+
/// mode.
7577
ignore_state_before_epoch: Option<i32>,
7678
/// Per-job environment variables forwarded to workers at scheduling time.
7779
env_vars: serde_json::Value,

crates/arroyo-controller/src/states/scheduling.rs

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,20 @@ async fn get_and_register_checkpoint_info_leader<'a>(
444444
storage_url: ctx.pipeline_info.state_url.clone(),
445445
};
446446
let storage_provider = get_storage_provider(&storage_role).await?;
447+
let ignore_state_before_generation = ctx
448+
.config
449+
.ignore_state_before_epoch
450+
.and_then(|generation| generation.try_into().ok())
451+
.map(Generation);
452+
453+
if let Some(generation) = ignore_state_before_generation {
454+
info!(
455+
message = "ignoring state from before leader generation",
456+
job_id = *ctx.config.id,
457+
ignore_state_before_generation = generation.0,
458+
current_generation = ctx.status.generation,
459+
);
460+
}
447461

448462
let new_gen = initialize_generation(
449463
storage_provider.as_ref(),
@@ -452,6 +466,7 @@ async fn get_and_register_checkpoint_info_leader<'a>(
452466
job_id: JobId(ctx.config.id.clone()),
453467
generation: Generation(ctx.status.generation),
454468
updated_at: SystemTime::now(),
469+
ignore_state_before_generation,
455470
},
456471
true,
457472
)
@@ -717,21 +732,25 @@ impl State for Scheduling {
717732
let worker_connects = Arc::try_unwrap(worker_connects).unwrap().into_inner();
718733
let program = api::ArrowProgram::from(ctx.program.clone());
719734

720-
// Use ignore_state_before_epoch as default so new checkpoints exceed the threshold
721-
let default_epoch = ctx
722-
.config
723-
.ignore_state_before_epoch
724-
.filter(|&t| t > 0)
725-
.map(|t| {
726-
let epoch = (t - 1) as u64;
727-
info!(
728-
message = "starting from ignore_state_before_epoch threshold",
729-
job_id = *ctx.config.id,
730-
default_epoch = epoch,
731-
);
732-
epoch
733-
})
734-
.unwrap_or(0);
735+
// In controller mode this is an epoch threshold. Leader mode uses the same field as a
736+
// generation number, so it must not affect the checkpoint epoch.
737+
let default_epoch = if leader_mode {
738+
0
739+
} else {
740+
ctx.config
741+
.ignore_state_before_epoch
742+
.filter(|&t| t > 0)
743+
.map(|t| {
744+
let epoch = (t - 1) as u64;
745+
info!(
746+
message = "starting from ignore_state_before_epoch threshold",
747+
job_id = *ctx.config.id,
748+
default_epoch = epoch,
749+
);
750+
epoch
751+
})
752+
.unwrap_or(0)
753+
};
735754

736755
let start_epoch = checkpoint_info
737756
.as_ref()

crates/arroyo-state-protocol/src/lib.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ mod tests {
461461
job_id: JobId::new("J"),
462462
generation: Generation(1),
463463
updated_at: from_micros(123),
464+
ignore_state_before_generation: None,
464465
},
465466
false,
466467
)
@@ -516,6 +517,7 @@ mod tests {
516517
job_id: JobId::new("J"),
517518
generation: Generation(2),
518519
updated_at: from_micros(456),
520+
ignore_state_before_generation: None,
519521
},
520522
false,
521523
)
@@ -547,6 +549,75 @@ mod tests {
547549
assert_eq!(written_manifest, expected_manifest);
548550
}
549551

552+
#[tokio::test]
553+
async fn initialize_generation_keeps_ignoring_old_state_on_retry() {
554+
let store = MemoryProtocolStore::default();
555+
let paths = ProtocolPaths::new(PipelineId::new("P"), JobId::new("J"));
556+
write_current_generation(&store, &paths, Generation(3)).await;
557+
558+
let checkpoint_ref = paths.checkpoint_manifest(Generation(1), Epoch(1));
559+
let checkpoint = checkpoint_for_generation(Generation(1), 1, None, false);
560+
write_canonical_checkpoint(&store, &paths, &checkpoint_ref, &checkpoint).await;
561+
let previous_manifest =
562+
generation_manifest_for_generation(Generation(1), None, Some(checkpoint_ref));
563+
put_json(
564+
&store,
565+
&paths.generation_manifest(Generation(1)),
566+
&previous_manifest,
567+
)
568+
.await
569+
.unwrap();
570+
put_json(
571+
&store,
572+
&paths.generation_manifest(Generation(2)),
573+
&GenerationManifest::new(
574+
PipelineId::new("P"),
575+
JobId::new("J"),
576+
Generation(2),
577+
None,
578+
123,
579+
),
580+
)
581+
.await
582+
.unwrap();
583+
584+
let initialization = initialize_generation(
585+
&store,
586+
InitializeGenerationRequest {
587+
pipeline_id: PipelineId::new("P"),
588+
job_id: JobId::new("J"),
589+
generation: Generation(3),
590+
updated_at: from_micros(456),
591+
ignore_state_before_generation: Some(Generation(2)),
592+
},
593+
false,
594+
)
595+
.await
596+
.unwrap();
597+
598+
let expected_manifest = GenerationManifest::new(
599+
PipelineId::new("P"),
600+
JobId::new("J"),
601+
Generation(3),
602+
None,
603+
456,
604+
);
605+
assert_eq!(
606+
initialization,
607+
GenerationInitialization::Initialized {
608+
generation_manifest: expected_manifest.clone(),
609+
recovery: GenerationRecovery::NoCheckpoint,
610+
}
611+
);
612+
613+
let written_manifest: GenerationManifest =
614+
read_json(&store, &paths.generation_manifest(Generation(3)))
615+
.await
616+
.unwrap()
617+
.expect("new generation manifest should be written");
618+
assert_eq!(written_manifest, expected_manifest);
619+
}
620+
550621
#[tokio::test]
551622
async fn initialize_generation_restores_previous_checkpoint_requiring_commit_replay() {
552623
let store = MemoryProtocolStore::default();
@@ -573,6 +644,7 @@ mod tests {
573644
job_id: JobId::new("J"),
574645
generation: Generation(2),
575646
updated_at: from_micros(456),
647+
ignore_state_before_generation: None,
576648
},
577649
false,
578650
)
@@ -621,6 +693,7 @@ mod tests {
621693
job_id: JobId::new("J"),
622694
generation: Generation(3),
623695
updated_at: from_micros(789),
696+
ignore_state_before_generation: None,
624697
},
625698
false,
626699
)
@@ -668,6 +741,7 @@ mod tests {
668741
job_id: JobId::new("J"),
669742
generation: Generation(2),
670743
updated_at: from_micros(456),
744+
ignore_state_before_generation: None,
671745
},
672746
false,
673747
)
@@ -724,6 +798,7 @@ mod tests {
724798
job_id: JobId::new("J"),
725799
generation: Generation(3),
726800
updated_at: from_micros(456),
801+
ignore_state_before_generation: None,
727802
},
728803
false,
729804
)
@@ -789,6 +864,7 @@ mod tests {
789864
job_id: JobId::new("J"),
790865
generation: Generation(3),
791866
updated_at: from_micros(456),
867+
ignore_state_before_generation: None,
792868
},
793869
false,
794870
)
@@ -826,6 +902,7 @@ mod tests {
826902
job_id: JobId::new("J"),
827903
generation: Generation(2),
828904
updated_at: from_micros(456),
905+
ignore_state_before_generation: None,
829906
},
830907
false,
831908
)

crates/arroyo-state-protocol/src/workflow.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ pub struct InitializeGenerationRequest {
7171
pub job_id: JobId,
7272
pub generation: Generation,
7373
pub updated_at: SystemTime,
74+
/// Do not restore checkpoints written before this generation.
75+
pub ignore_state_before_generation: Option<Generation>,
7476
}
7577

7678
/// Checkpoint, if any, that a newly initialized generation should restore from.
@@ -259,7 +261,13 @@ where
259261
});
260262
}
261263

262-
let recovery = find_recovery_checkpoint(store, &paths, request.generation).await?;
264+
let recovery = find_recovery_checkpoint(
265+
store,
266+
&paths,
267+
request.generation,
268+
request.ignore_state_before_generation,
269+
)
270+
.await?;
263271
let base_checkpoint_ref = match &recovery {
264272
RecoverySearch::Found(recovery) => match recovery {
265273
GenerationRecovery::NoCheckpoint => None,
@@ -307,15 +315,21 @@ async fn find_recovery_checkpoint<S>(
307315
store: &S,
308316
paths: &ProtocolPaths,
309317
generation: Generation,
318+
ignore_state_before_generation: Option<Generation>,
310319
) -> Result<RecoverySearch, StoreError>
311320
where
312321
S: ProtocolStore + ?Sized,
313322
{
314323
let Some(previous_generation) = generation.0.checked_sub(1) else {
315324
return Ok(RecoverySearch::Found(GenerationRecovery::NoCheckpoint));
316325
};
326+
let first_generation = ignore_state_before_generation.unwrap_or(Generation(0)).0;
327+
328+
if first_generation > previous_generation {
329+
return Ok(RecoverySearch::Found(GenerationRecovery::NoCheckpoint));
330+
}
317331

318-
for previous_generation in (0..=previous_generation).rev() {
332+
for previous_generation in (first_generation..=previous_generation).rev() {
319333
let manifest_ref = paths.generation_manifest(Generation(previous_generation));
320334
let Some(manifest): Option<GenerationManifest> = read_json(store, &manifest_ref).await?
321335
else {

crates/arroyo-worker/src/job_controller/controller.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ impl WorkerJobController {
160160
job_id: worker_context.job_id.clone(),
161161
generation: Generation(worker_context.generation),
162162
updated_at: SystemTime::now(),
163+
// When the controller found no parent, do not let the leader independently fall
164+
// back to state from an earlier generation.
165+
ignore_state_before_generation: parent_ref
166+
.is_none()
167+
.then_some(Generation(worker_context.generation)),
163168
},
164169
false,
165170
)

0 commit comments

Comments
 (0)