Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dlq-replay-env-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chimely/client": patch
---

Generated admin API types: DLQ replay endpoints gain an optional `environment` query parameter (scope a replay to one environment by slug) and `replay-all` gains a 404 for an unknown scope.
28 changes: 26 additions & 2 deletions docs/openapi/chimely.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,15 @@ paths:
tags:
- admin
summary: Replay all dead letters
description: Requeue every parked job. Optionally scoped to one environment by slug.
operationId: adminReplayAllDeadLetters
parameters:
- name: environment
in: query
description: Environment slug to scope the replay to.
required: false
schema:
type: string
responses:
'200':
description: Replayed.
Expand Down Expand Up @@ -123,14 +131,24 @@ paths:
error:
code: forbidden
message: your role does not permit this action
'404':
description: Unknown environment scope.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: not_found
message: no such environment
security:
- AdminSession: []
/admin/api/dlq/{job_id}/replay:
post:
tags:
- admin
summary: Replay dead letter
description: Requeue one failed job for another attempt.
description: Requeue one failed job for another attempt. Optionally scoped to one environment by slug.
operationId: adminReplayDeadLetter
parameters:
- name: job_id
Expand All @@ -139,6 +157,12 @@ paths:
required: true
schema:
type: string
- name: environment
in: query
description: Environment slug to scope the replay to.
required: false
schema:
type: string
responses:
'200':
description: Replayed.
Expand Down Expand Up @@ -167,7 +191,7 @@ paths:
code: forbidden
message: your role does not permit this action
'404':
description: No such parked job.
description: No such parked job, or unknown environment scope.
content:
application/json:
schema:
Expand Down
36 changes: 31 additions & 5 deletions packages/client/src/generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ export interface paths {
};
get?: never;
put?: never;
/** Replay all dead letters */
/**
* Replay all dead letters
* @description Requeue every parked job. Optionally scoped to one environment by slug.
*/
post: operations["adminReplayAllDeadLetters"];
delete?: never;
options?: never;
Expand All @@ -49,7 +52,7 @@ export interface paths {
put?: never;
/**
* Replay dead letter
* @description Requeue one failed job for another attempt.
* @description Requeue one failed job for another attempt. Optionally scoped to one environment by slug.
*/
post: operations["adminReplayDeadLetter"];
delete?: never;
Expand Down Expand Up @@ -1238,7 +1241,10 @@ export interface operations {
};
adminReplayAllDeadLetters: {
parameters: {
query?: never;
query?: {
/** @description Environment slug to scope the replay to. */
environment?: string;
};
header?: never;
path?: never;
cookie?: never;
Expand Down Expand Up @@ -1288,11 +1294,31 @@ export interface operations {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unknown environment scope. */
404: {
headers: {
[name: string]: unknown;
};
content: {
/**
* @example {
* "error": {
* "code": "not_found",
* "message": "no such environment"
* }
* }
*/
"application/json": components["schemas"]["Error"];
};
};
};
};
adminReplayDeadLetter: {
parameters: {
query?: never;
query?: {
/** @description Environment slug to scope the replay to. */
environment?: string;
};
header?: never;
path: {
/** @description Job TypeID (job_…). */
Expand Down Expand Up @@ -1345,7 +1371,7 @@ export interface operations {
"application/json": components["schemas"]["Error"];
};
};
/** @description No such parked job. */
/** @description No such parked job, or unknown environment scope. */
404: {
headers: {
[name: string]: unknown;
Expand Down
56 changes: 48 additions & 8 deletions server/src/api/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,30 @@ pub struct AdminReplayResult {
pub replayed: i64,
}

/// Optional environment scope for a replay, mirroring the CLI's `--env`.
/// environment_id is part of every key. Without a scope the instance-wide
/// admin plane matches the bare job id across environments.
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct AdminReplayQuery {
/// Environment slug to scope the replay to.
pub environment: Option<String>,
}

/// Resolve an optional environment slug to its id, 404 if the slug is unknown.
/// A blank value (an empty `?environment=` from a form field or unset selector)
/// is treated as no scope, not a lookup of the empty slug.
async fn replay_scope(state: &AppState, slug: Option<String>) -> Result<Option<Uuid>, ApiError> {
let Some(slug) = slug.filter(|s| !s.is_empty()) else {
return Ok(None);
};
let id = dlq::environment_by_slug(&state.pool, &slug)
.await
.map_err(ApiError::from)?
.ok_or_else(|| ApiError::not_found("no such environment"))?;
Ok(Some(id))
}

#[utoipa::path(
get,
path = "/admin/api/dlq",
Expand Down Expand Up @@ -1265,8 +1289,11 @@ pub async fn list_dlq(
tag = "admin",
operation_id = "adminReplayDeadLetter",
summary = "Replay dead letter",
description = "Requeue one failed job for another attempt.",
params(("job_id" = String, Path, description = "Job TypeID (job_…).")),
description = "Requeue one failed job for another attempt. Optionally scoped to one environment by slug.",
params(
("job_id" = String, Path, description = "Job TypeID (job_…)."),
AdminReplayQuery,
),
responses(
(status = 200, description = "Replayed.", body = AdminReplayResult),
(
Expand All @@ -1283,7 +1310,7 @@ pub async fn list_dlq(
),
(
status = 404,
description = "No such parked job.",
description = "No such parked job, or unknown environment scope.",
body = crate::api::contract::Error,
example = json!({"error": {"code": "not_found", "message": "no such parked job"}}),
),
Expand All @@ -1294,18 +1321,21 @@ pub async fn replay_dead_letter(
auth: AdminAuth,
State(state): State<AppState>,
Path(job_id): Path<String>,
ApiQuery(query): ApiQuery<AdminReplayQuery>,
) -> Result<Json<AdminReplayResult>, ApiError> {
auth.require(Capability::DlqReplay)?;
let id = ids::parse_typeid(ids::JOB, &job_id)
.ok_or_else(|| ApiError::not_found("no such parked job"))?;
// Cross-environment admin path: job ids are globally unique UUIDv7s.
let replayed = dlq::replay(&state.pool, id, None)
let environment = replay_scope(&state, query.environment).await?;
let replayed = dlq::replay(&state.pool, id, environment)
.await
.map_err(ApiError::from)?;
if !replayed {
if replayed == 0 {
return Err(ApiError::not_found("no such parked job"));
}
Ok(Json(AdminReplayResult { replayed: 1 }))
Ok(Json(AdminReplayResult {
replayed: replayed as i64,
}))
}

#[utoipa::path(
Expand All @@ -1314,6 +1344,8 @@ pub async fn replay_dead_letter(
tag = "admin",
operation_id = "adminReplayAllDeadLetters",
summary = "Replay all dead letters",
description = "Requeue every parked job. Optionally scoped to one environment by slug.",
params(AdminReplayQuery),
responses(
(status = 200, description = "Replayed.", body = AdminReplayResult),
(
Expand All @@ -1328,15 +1360,23 @@ pub async fn replay_dead_letter(
body = crate::api::contract::Error,
example = json!({"error": {"code": "forbidden", "message": "your role does not permit this action"}}),
),
(
status = 404,
description = "Unknown environment scope.",
body = crate::api::contract::Error,
example = json!({"error": {"code": "not_found", "message": "no such environment"}}),
),
),
security(("AdminSession" = []))
)]
pub async fn replay_all_dead_letters(
auth: AdminAuth,
State(state): State<AppState>,
ApiQuery(query): ApiQuery<AdminReplayQuery>,
) -> Result<Json<AdminReplayResult>, ApiError> {
auth.require(Capability::DlqReplay)?;
let replayed = dlq::replay_all(&state.pool, None)
let environment = replay_scope(&state, query.environment).await?;
let replayed = dlq::replay_all(&state.pool, environment)
.await
.map_err(ApiError::from)? as i64;
Ok(Json(AdminReplayResult { replayed }))
Expand Down
10 changes: 5 additions & 5 deletions server/src/dlq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ pub async fn list(pool: &PgPool) -> anyhow::Result<Vec<DeadLetter>> {
}

/// Replay one parked job by id, optionally pinned to one environment.
/// environment_id is part of every key. An unscoped id match would reach
/// across environments. Returns false if no such dead letter.
pub async fn replay(pool: &PgPool, id: Uuid, environment: Option<Uuid>) -> anyhow::Result<bool> {
let moved = replay_where(pool, Some(id), environment).await?;
Ok(moved > 0)
/// environment_id is part of every key. Without a pin an id-only match reaches
/// across environments, so this moves every same-id row and returns the count
/// moved. Zero means no such dead letter.
pub async fn replay(pool: &PgPool, id: Uuid, environment: Option<Uuid>) -> anyhow::Result<u64> {
replay_where(pool, Some(id), environment).await
}

/// Replay every parked job, optionally only one environment's.
Expand Down
2 changes: 1 addition & 1 deletion server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ async fn dlq_command(args: Vec<String>) -> anyhow::Result<()> {
let id = ids::parse_typeid(ids::JOB, id)
.or_else(|| id.parse().ok())
.context("expected a job_… TypeID or a raw UUID")?;
if dlq::replay(&pool, id, environment).await? {
if dlq::replay(&pool, id, environment).await? > 0 {
println!("replayed {}", ids::typeid(ids::JOB, id));
} else {
eprintln!("no such dead letter");
Expand Down
Loading
Loading