Skip to content

Commit 9e3a755

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

8 files changed

Lines changed: 156 additions & 37 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: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,19 @@ 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 = ctx
448+
.config
449+
.ignore_state_before_epoch
450+
.and_then(|generation| generation.try_into().ok())
451+
== Some(ctx.status.generation);
452+
453+
if ignore_state {
454+
info!(
455+
message = "starting leader generation without state",
456+
job_id = *ctx.config.id,
457+
generation = ctx.status.generation,
458+
);
459+
}
447460

448461
let new_gen = initialize_generation(
449462
storage_provider.as_ref(),
@@ -452,6 +465,7 @@ async fn get_and_register_checkpoint_info_leader<'a>(
452465
job_id: JobId(ctx.config.id.clone()),
453466
generation: Generation(ctx.status.generation),
454467
updated_at: SystemTime::now(),
468+
ignore_state,
455469
},
456470
true,
457471
)
@@ -717,21 +731,25 @@ impl State for Scheduling {
717731
let worker_connects = Arc::try_unwrap(worker_connects).unwrap().into_inner();
718732
let program = api::ArrowProgram::from(ctx.program.clone());
719733

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);
734+
// In controller mode this is an epoch threshold. Leader mode uses the same field as a
735+
// generation number, so it must not affect the checkpoint epoch.
736+
let default_epoch = if leader_mode {
737+
0
738+
} else {
739+
ctx.config
740+
.ignore_state_before_epoch
741+
.filter(|&t| t > 0)
742+
.map(|t| {
743+
let epoch = (t - 1) as u64;
744+
info!(
745+
message = "starting from ignore_state_before_epoch threshold",
746+
job_id = *ctx.config.id,
747+
default_epoch = epoch,
748+
);
749+
epoch
750+
})
751+
.unwrap_or(0)
752+
};
735753

736754
let start_epoch = checkpoint_info
737755
.as_ref()

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

Lines changed: 64 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: false,
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: false,
519521
},
520522
false,
521523
)
@@ -547,6 +549,62 @@ mod tests {
547549
assert_eq!(written_manifest, expected_manifest);
548550
}
549551

552+
#[tokio::test]
553+
async fn initialize_generation_can_ignore_previous_checkpoint() {
554+
let store = MemoryProtocolStore::default();
555+
let paths = ProtocolPaths::new(PipelineId::new("P"), JobId::new("J"));
556+
write_current_generation(&store, &paths, Generation(2)).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+
571+
let initialization = initialize_generation(
572+
&store,
573+
InitializeGenerationRequest {
574+
pipeline_id: PipelineId::new("P"),
575+
job_id: JobId::new("J"),
576+
generation: Generation(2),
577+
updated_at: from_micros(456),
578+
ignore_state: true,
579+
},
580+
false,
581+
)
582+
.await
583+
.unwrap();
584+
585+
let expected_manifest = GenerationManifest::new(
586+
PipelineId::new("P"),
587+
JobId::new("J"),
588+
Generation(2),
589+
None,
590+
456,
591+
);
592+
assert_eq!(
593+
initialization,
594+
GenerationInitialization::Initialized {
595+
generation_manifest: expected_manifest.clone(),
596+
recovery: GenerationRecovery::NoCheckpoint,
597+
}
598+
);
599+
600+
let written_manifest: GenerationManifest =
601+
read_json(&store, &paths.generation_manifest(Generation(2)))
602+
.await
603+
.unwrap()
604+
.expect("new generation manifest should be written");
605+
assert_eq!(written_manifest, expected_manifest);
606+
}
607+
550608
#[tokio::test]
551609
async fn initialize_generation_restores_previous_checkpoint_requiring_commit_replay() {
552610
let store = MemoryProtocolStore::default();
@@ -573,6 +631,7 @@ mod tests {
573631
job_id: JobId::new("J"),
574632
generation: Generation(2),
575633
updated_at: from_micros(456),
634+
ignore_state: false,
576635
},
577636
false,
578637
)
@@ -621,6 +680,7 @@ mod tests {
621680
job_id: JobId::new("J"),
622681
generation: Generation(3),
623682
updated_at: from_micros(789),
683+
ignore_state: false,
624684
},
625685
false,
626686
)
@@ -668,6 +728,7 @@ mod tests {
668728
job_id: JobId::new("J"),
669729
generation: Generation(2),
670730
updated_at: from_micros(456),
731+
ignore_state: false,
671732
},
672733
false,
673734
)
@@ -724,6 +785,7 @@ mod tests {
724785
job_id: JobId::new("J"),
725786
generation: Generation(3),
726787
updated_at: from_micros(456),
788+
ignore_state: false,
727789
},
728790
false,
729791
)
@@ -789,6 +851,7 @@ mod tests {
789851
job_id: JobId::new("J"),
790852
generation: Generation(3),
791853
updated_at: from_micros(456),
854+
ignore_state: false,
792855
},
793856
false,
794857
)
@@ -826,6 +889,7 @@ mod tests {
826889
job_id: JobId::new("J"),
827890
generation: Generation(2),
828891
updated_at: from_micros(456),
892+
ignore_state: false,
829893
},
830894
false,
831895
)

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

Lines changed: 7 additions & 1 deletion
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+
/// Start this generation without restoring a checkpoint from an earlier generation.
75+
pub ignore_state: bool,
7476
}
7577

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

262-
let recovery = find_recovery_checkpoint(store, &paths, request.generation).await?;
264+
let recovery = if request.ignore_state {
265+
RecoverySearch::Found(GenerationRecovery::NoCheckpoint)
266+
} else {
267+
find_recovery_checkpoint(store, &paths, request.generation).await?
268+
};
263269
let base_checkpoint_ref = match &recovery {
264270
RecoverySearch::Found(recovery) => match recovery {
265271
GenerationRecovery::NoCheckpoint => None,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ impl WorkerJobController {
160160
job_id: worker_context.job_id.clone(),
161161
generation: Generation(worker_context.generation),
162162
updated_at: SystemTime::now(),
163+
// The controller passes no parent checkpoint when this generation should start
164+
// without state (or when there is no state available).
165+
ignore_state: parent_ref.is_none(),
163166
},
164167
false,
165168
)

webui/src/routes/pipelines/PipelineDetails.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export function PipelineDetails() {
138138

139139
async function updateJobState(stop: StopType) {
140140
console.log(`Setting pipeline stop_mode=${stop}`);
141-
updatePipeline({ stop });
141+
await updatePipeline({ stop });
142142
}
143143

144144
async function updateJobParallelism(parallelism: number) {
@@ -352,23 +352,29 @@ export function PipelineDetails() {
352352
let actionButton = <></>;
353353
if (pipeline) {
354354
editPipelineButton = <Button onClick={onConfigModalOpen}>Edit</Button>;
355-
if (job.state == 'Failed') {
355+
const isStopped = job.state === 'Stopped' && pipeline.action != null;
356+
if (job.state === 'Failed' || isStopped) {
357+
const actionText = isStopped ? 'Start' : 'Restart';
356358
actionButton = (
357359
<Popover trigger="hover" placement="bottom-start">
358360
<PopoverTrigger>
359361
<Button
360362
onClick={async () => {
361-
await restartPipeline(false);
363+
if (isStopped) {
364+
await updateJobState(pipeline.action!);
365+
} else {
366+
await restartPipeline(false);
367+
}
362368
}}
363369
>
364-
Restart
370+
{actionText}
365371
</Button>
366372
</PopoverTrigger>
367373
<PopoverContent width="auto">
368374
<PopoverArrow />
369375
<PopoverBody p={4}>
370376
<Button size="sm" colorScheme="red" onClick={onRestartWithoutStateModalOpen}>
371-
Restart Without State
377+
{actionText} Without State
372378
</Button>
373379
</PopoverBody>
374380
</PopoverContent>
@@ -389,6 +395,10 @@ export function PipelineDetails() {
389395
}
390396
}
391397

398+
const startingWithoutState = job.state === 'Stopped';
399+
const withoutStateAction = startingWithoutState ? 'Start' : 'Restart';
400+
const withoutStateActionPresentParticiple = startingWithoutState ? 'Starting' : 'Restarting';
401+
392402
const headerArea = (
393403
<Flex>
394404
<Box p={5}>
@@ -422,13 +432,13 @@ export function PipelineDetails() {
422432
<AlertDialogOverlay>
423433
<AlertDialogContent>
424434
<AlertDialogHeader fontSize="lg" fontWeight="bold">
425-
Restart Without State
435+
{withoutStateAction} Without State
426436
</AlertDialogHeader>
427437

428438
<AlertDialogBody>
429439
<Text>
430-
Restarting without state could lead to data loss, duplication, and incorrect
431-
results. Are you sure you want to continue?
440+
{withoutStateActionPresentParticiple} without state could lead to data loss,
441+
duplication, and incorrect results. Are you sure you want to continue?
432442
</Text>
433443
</AlertDialogBody>
434444

@@ -444,7 +454,7 @@ export function PipelineDetails() {
444454
}}
445455
ml={3}
446456
>
447-
Restart Without State
457+
{withoutStateAction} Without State
448458
</Button>
449459
</AlertDialogFooter>
450460
</AlertDialogContent>

0 commit comments

Comments
 (0)