Skip to content

Commit ae60c22

Browse files
committed
Support restarting without state in leader mode
1 parent 4ddc8fd commit ae60c22

6 files changed

Lines changed: 135 additions & 12 deletions

File tree

crates/arroyo-api/queries/api_queries.sql

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,32 @@ VALUES (:id, :organization_id, :pipeline_name, :created_by, :pipeline_id, :check
196196
--! create_job_status
197197
INSERT INTO job_statuses (pub_id, id, organization_id) VALUES (:pub_id, :id, :organization_id);
198198

199+
--! clone_job_for_restart
200+
INSERT INTO job_configs
201+
(id, organization_id, pipeline_name, created_by, updated_by, updated_at, ttl_micros, stop,
202+
pipeline_id, parallelism_overrides, checkpoint_interval_micros, env_vars, scheduler_config)
203+
SELECT :new_job_id, organization_id, pipeline_name, created_by, :updated_by, CURRENT_TIMESTAMP,
204+
ttl_micros, 'none', pipeline_id, parallelism_overrides, checkpoint_interval_micros,
205+
env_vars, scheduler_config
206+
FROM job_configs
207+
WHERE id = :old_job_id AND organization_id = :organization_id;
208+
209+
--! delete_job_checkpoints
210+
DELETE FROM checkpoints
211+
WHERE job_id = :job_id AND organization_id = :organization_id;
212+
213+
--! delete_job_log_messages
214+
DELETE FROM job_log_messages
215+
WHERE job_id = :job_id;
216+
217+
--! delete_job_status
218+
DELETE FROM job_statuses
219+
WHERE id = :job_id AND organization_id = :organization_id;
220+
221+
--! delete_job_config
222+
DELETE FROM job_configs
223+
WHERE id = :job_id AND organization_id = :organization_id;
224+
199225
--! get_jobs: (start_time?, finish_time?, state?, tasks?, textual_repr?, failure_message?, run_id?, udfs)
200226
SELECT job_configs.id as id, pipeline_name, stop, textual_repr, start_time, finish_time, state, tasks, pipeline_id, failure_message, run_id, udfs
201227
FROM job_configs

crates/arroyo-api/src/jobs.rs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ use crate::pipelines::{query_job_by_pub_id, query_pipeline_by_pub_id};
2929
use crate::rest::AppState;
3030
use crate::rest_utils::{
3131
BearerAuth, ErrorResp, PipelineJobCheckpointPath, PipelineJobPath, authenticate, bad_request,
32-
log_and_map, not_found, paginate_results, validate_pagination_params,
32+
conflict, internal_server_error, log_and_map, not_found, paginate_results,
33+
validate_pagination_params,
3334
};
3435
use crate::types::public::LogLevel;
3536
use crate::{AuthData, queries::api_queries, to_micros, types::public};
@@ -194,6 +195,74 @@ pub(crate) async fn create_job(
194195
Ok(job_id)
195196
}
196197

198+
fn replaceable_job(jobs: Vec<DbPipelineJob>) -> Result<String, ErrorResp> {
199+
let count = jobs.len();
200+
let Some(job) = jobs.into_iter().next() else {
201+
return Err(not_found("Job for pipeline"));
202+
};
203+
204+
if count != 1 {
205+
return Err(internal_server_error(format!(
206+
"expected one job for pipeline, found {count}"
207+
)));
208+
}
209+
210+
let state = job.state.unwrap_or_else(|| "Created".to_string());
211+
if state != "Stopped" && state != "Failed" {
212+
return Err(conflict(format!(
213+
"cannot restart job {} without state while it is in state {state}; stop the job first",
214+
job.id
215+
)));
216+
}
217+
218+
Ok(job.id)
219+
}
220+
221+
/// Replaces a pipeline's single terminal job with a fresh job.
222+
pub(crate) async fn replace_job_without_state(
223+
db: &DatabaseSource,
224+
pipeline_pub_id: &str,
225+
auth: &AuthData,
226+
) -> Result<String, ErrorResp> {
227+
let new_job_id = generate_id(IdTypes::JobConfig);
228+
let new_status_id = generate_id(IdTypes::JobStatus);
229+
let database = db.client().await?;
230+
let jobs =
231+
api_queries::fetch_get_pipeline_jobs(&database, &auth.organization_id, &pipeline_pub_id)
232+
.await?;
233+
let old_job_id = replaceable_job(jobs)?;
234+
235+
let inserted = api_queries::execute_clone_job_for_restart(
236+
&database,
237+
&new_job_id,
238+
&auth.user_id,
239+
&old_job_id,
240+
&auth.organization_id,
241+
)
242+
.await?;
243+
if inserted != 1 {
244+
return Err(internal_server_error("failed to clone job configuration"));
245+
}
246+
247+
api_queries::execute_create_job_status(
248+
&database,
249+
&new_status_id,
250+
&new_job_id,
251+
&auth.organization_id,
252+
)
253+
.await?;
254+
255+
// Delete children explicitly because the SQLite checkpoints table does not have the same
256+
// foreign-key cascade as PostgreSQL.
257+
api_queries::execute_delete_job_checkpoints(&database, &old_job_id, &auth.organization_id)
258+
.await?;
259+
api_queries::execute_delete_job_log_messages(&database, &old_job_id).await?;
260+
api_queries::execute_delete_job_status(&database, &old_job_id, &auth.organization_id).await?;
261+
api_queries::execute_delete_job_config(&database, &old_job_id, &auth.organization_id).await?;
262+
263+
Ok(new_job_id)
264+
}
265+
197266
pub(crate) fn get_action(state: &str, running_desired: &bool) -> (String, Option<StopType>, bool) {
198267
enum Progress {
199268
InProgress,

crates/arroyo-api/src/pipelines.rs

Lines changed: 12 additions & 1 deletion
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,6 +868,17 @@ 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+
872+
if req.ignore_state.unwrap_or(false)
873+
&& matches!(config().job_controller, JobControllerMode::Worker)
874+
{
875+
jobs::replace_job_without_state(&state.database, &id, &auth_data).await?;
876+
877+
let db = state.database.client().await?;
878+
let pipeline = query_pipeline_by_pub_id(&id, &db, &auth_data).await?;
879+
return Ok(Json(pipeline));
880+
}
881+
871882
let db = state.database.client().await?;
872883

873884
let job_id = api_queries::fetch_get_pipeline_jobs(&db, &auth_data.organization_id, &id)

crates/arroyo-api/src/rest_utils.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ pub(crate) fn bad_request(message: impl Into<String>) -> ErrorResp {
168168
}
169169
}
170170

171+
pub(crate) fn conflict(message: impl Into<String>) -> ErrorResp {
172+
ErrorResp {
173+
status_code: StatusCode::CONFLICT,
174+
message: message.into(),
175+
}
176+
}
177+
171178
pub(crate) fn service_unavailable(object: &str) -> ErrorResp {
172179
ErrorResp {
173180
status_code: StatusCode::SERVICE_UNAVAILABLE,

webui/src/lib/data_fetching.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@ export const usePipeline = (pipelineId?: string, refresh: boolean = false) => {
524524
params: { path: { id: pipelineId } },
525525
body: { ignore_state: ignoreState ?? false },
526526
});
527-
await mutate();
527+
await Promise.all([mutate(), globalMutate(pipelineJobsKey(pipelineId))]);
528528
};
529529

530530
const deletePipeline = async () => {

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)