fix(storage): make queue and object ownership durable
This commit is contained in:
@@ -2,6 +2,7 @@ use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::billing;
|
||||
use crate::services::compress;
|
||||
use crate::services::metrics;
|
||||
use crate::services::object_lifecycle;
|
||||
use crate::services::quota;
|
||||
use crate::services::storage;
|
||||
use crate::state::AppState;
|
||||
@@ -40,6 +41,10 @@ pub async fn run(state: AppState) -> Result<(), AppError> {
|
||||
let consumer = format!("worker_{worker_id}");
|
||||
ensure_group(&state).await?;
|
||||
tokio::spawn(maintenance_loop(state.clone()));
|
||||
tokio::spawn(crate::services::task_queue::dispatch_loop(state.clone()));
|
||||
tokio::spawn(crate::services::object_lifecycle::maintenance_loop(
|
||||
state.clone(),
|
||||
));
|
||||
|
||||
let task_concurrency = state.config.worker_task_concurrency.max(1) as usize;
|
||||
let mut inflight = JoinSet::new();
|
||||
@@ -675,21 +680,6 @@ struct TaskFileProcRow {
|
||||
output_format: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct CleanupFileRow {
|
||||
storage_backend: String,
|
||||
storage_endpoint_id: Option<Uuid>,
|
||||
storage_key: Option<String>,
|
||||
input_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct CleanupZipRow {
|
||||
zip_storage_backend: Option<String>,
|
||||
zip_storage_endpoint_id: Option<Uuid>,
|
||||
zip_storage_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TaskContext {
|
||||
api_key_id: Option<Uuid>,
|
||||
@@ -729,6 +719,7 @@ pub(crate) async fn process_task(
|
||||
lease_owner = $2,
|
||||
lease_until = NOW() + $3 * INTERVAL '1 second'
|
||||
WHERE id = $1
|
||||
AND deletion_started_at IS NULL
|
||||
AND (
|
||||
status = 'pending'
|
||||
OR (
|
||||
@@ -940,6 +931,7 @@ async fn file_attempt_is_current(state: &AppState, fence: &FileFence) -> Result<
|
||||
JOIN task_files f ON f.task_id = t.id
|
||||
WHERE t.id = $1
|
||||
AND t.status = 'processing'
|
||||
AND t.deletion_started_at IS NULL
|
||||
AND t.processing_attempt = $2
|
||||
AND t.lease_owner = $5
|
||||
AND t.lease_until > NOW()
|
||||
@@ -1103,10 +1095,13 @@ async fn process_task_file(
|
||||
file_attempt,
|
||||
format_out.extension(),
|
||||
);
|
||||
let stored = match storage::store_bytes(
|
||||
let tracked = match object_lifecycle::store_tracked_bytes(
|
||||
&state,
|
||||
task_id,
|
||||
Some(file.id),
|
||||
"result",
|
||||
&object_key,
|
||||
compressed,
|
||||
compressed.into(),
|
||||
format_out.content_type(),
|
||||
)
|
||||
.await
|
||||
@@ -1122,19 +1117,19 @@ async fn process_task_file(
|
||||
|
||||
if ctx.is_anonymous && charge_units && !ctx.anonymous_quota_reserved {
|
||||
let Some(session_id) = ctx.session_id.as_deref() else {
|
||||
let _ = storage::delete_object(&state, &stored_locator(&stored)).await;
|
||||
discard_tracked_result(&state, &tracked, None).await;
|
||||
mark_file_failed_and_cleanup(&state, &fence, "匿名任务缺少 session_id", &input_path)
|
||||
.await?;
|
||||
return Ok(());
|
||||
};
|
||||
let Some(ip) = ctx.anon_ip else {
|
||||
let _ = storage::delete_object(&state, &stored_locator(&stored)).await;
|
||||
discard_tracked_result(&state, &tracked, None).await;
|
||||
mark_file_failed_and_cleanup(&state, &fence, "匿名任务缺少 client_ip", &input_path)
|
||||
.await?;
|
||||
return Ok(());
|
||||
};
|
||||
if let Err(err) = quota::consume_anonymous_units(&state, session_id, ip, 1).await {
|
||||
let _ = storage::delete_object(&state, &stored_locator(&stored)).await;
|
||||
discard_tracked_result(&state, &tracked, Some(&err)).await;
|
||||
mark_file_failed_and_cleanup(&state, &fence, &err.message, &input_path).await?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1146,7 +1141,7 @@ async fn process_task_file(
|
||||
ctx.api_key_id,
|
||||
&ctx.source,
|
||||
&fence,
|
||||
&stored,
|
||||
&tracked,
|
||||
original_size as i64,
|
||||
compressed_size as i64,
|
||||
saved_percent,
|
||||
@@ -1160,16 +1155,85 @@ async fn process_task_file(
|
||||
let _ = tokio::fs::remove_file(&input_path).await;
|
||||
}
|
||||
Ok(FinalizeFileOutcome::LeaseLost) => {
|
||||
let _ = storage::delete_object(&state, &stored_locator(&stored)).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = storage::delete_object(&state, &stored_locator(&stored)).await;
|
||||
mark_file_failed_and_cleanup(&state, &fence, &err.message, &input_path).await?;
|
||||
discard_tracked_result(&state, &tracked, None).await;
|
||||
}
|
||||
Err(err) => match worker_result_was_committed(&state, &fence, &tracked).await {
|
||||
Ok(true) => {
|
||||
tracing::warn!(task_id = %task_id, file_id = %fence.file_id, error = %err, "worker result commit response was lost; recovered committed publication");
|
||||
let _ = tokio::fs::remove_file(&input_path).await;
|
||||
}
|
||||
Ok(false) => {
|
||||
discard_tracked_result(&state, &tracked, Some(&err)).await;
|
||||
mark_file_failed_and_cleanup(&state, &fence, &err.message, &input_path).await?;
|
||||
}
|
||||
Err(probe_err) => {
|
||||
tracing::error!(task_id = %task_id, file_id = %fence.file_id, error = %probe_err, original_error = %err, "worker result commit state is unknown; staging lease will reconcile object");
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn discard_tracked_result(
|
||||
state: &AppState,
|
||||
tracked: &object_lifecycle::TrackedStoredObject,
|
||||
error: Option<&AppError>,
|
||||
) {
|
||||
if let Err(schedule_err) =
|
||||
object_lifecycle::schedule_tracked_delete(state, tracked, error).await
|
||||
{
|
||||
tracing::error!(storage_object_id = %tracked.lifecycle_id, error = %schedule_err, "failed to persist discarded worker object cleanup");
|
||||
return;
|
||||
}
|
||||
if let Err(cleanup_err) =
|
||||
object_lifecycle::cleanup_ready_objects(state, 1, Some(tracked.task_id)).await
|
||||
{
|
||||
tracing::warn!(storage_object_id = %tracked.lifecycle_id, error = %cleanup_err, "discarded worker object cleanup deferred");
|
||||
}
|
||||
}
|
||||
|
||||
async fn worker_result_was_committed(
|
||||
state: &AppState,
|
||||
fence: &FileFence,
|
||||
tracked: &object_lifecycle::TrackedStoredObject,
|
||||
) -> Result<bool, AppError> {
|
||||
sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks AS task
|
||||
JOIN task_files AS file ON file.task_id = task.id
|
||||
JOIN storage_objects AS object ON object.id = $3
|
||||
WHERE task.id = $1
|
||||
AND file.id = $2
|
||||
AND file.status = 'completed'
|
||||
AND file.storage_backend = $4
|
||||
AND file.storage_endpoint_id IS NOT DISTINCT FROM $5
|
||||
AND COALESCE(file.storage_key, file.storage_path) = $6
|
||||
AND object.state = 'published'
|
||||
AND object.task_id = task.id
|
||||
AND object.task_file_id = file.id
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(fence.task_id)
|
||||
.bind(fence.file_id)
|
||||
.bind(tracked.lifecycle_id)
|
||||
.bind(&tracked.stored.backend)
|
||||
.bind(tracked.stored.endpoint_id)
|
||||
.bind(&tracked.stored.key)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(
|
||||
ErrorCode::StorageUnavailable,
|
||||
"核验 Worker 结果提交状态失败",
|
||||
)
|
||||
.with_source(err)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FinalizeFileOutcome {
|
||||
Committed,
|
||||
@@ -1183,7 +1247,7 @@ async fn finalize_file(
|
||||
api_key_id: Option<Uuid>,
|
||||
source: &str,
|
||||
fence: &FileFence,
|
||||
stored: &storage::StoredObject,
|
||||
tracked: &object_lifecycle::TrackedStoredObject,
|
||||
bytes_in: i64,
|
||||
bytes_out: i64,
|
||||
saved_percent: f64,
|
||||
@@ -1191,6 +1255,7 @@ async fn finalize_file(
|
||||
format_out: compress::ImageFmt,
|
||||
charge_units: bool,
|
||||
) -> Result<FinalizeFileOutcome, AppError> {
|
||||
let stored = &tracked.stored;
|
||||
let mut tx = state
|
||||
.db
|
||||
.begin()
|
||||
@@ -1332,6 +1397,8 @@ async fn finalize_file(
|
||||
return Ok(FinalizeFileOutcome::LeaseLost);
|
||||
}
|
||||
|
||||
object_lifecycle::publish_in_tx(&mut tx, tracked).await?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||
@@ -1840,6 +1907,12 @@ async fn cleanup_expired_records(state: &AppState) -> Result<(), AppError> {
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
let _ = sqlx::query(
|
||||
"DELETE FROM storage_objects WHERE state = 'deleted' AND deleted_at < NOW() - INTERVAL '7 days'",
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
let _ =
|
||||
sqlx::query("DELETE FROM webhook_events WHERE received_at < NOW() - INTERVAL '90 days'")
|
||||
.execute(&state.db)
|
||||
@@ -1851,6 +1924,7 @@ async fn cleanup_expired_records(state: &AppState) -> Result<(), AppError> {
|
||||
WHERE e.deleted_at < NOW() - INTERVAL '30 days'
|
||||
AND NOT EXISTS (SELECT 1 FROM task_files f WHERE f.storage_endpoint_id = e.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.zip_storage_endpoint_id = e.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM storage_objects o WHERE o.storage_endpoint_id = e.id)
|
||||
"#,
|
||||
)
|
||||
.execute(&state.db)
|
||||
@@ -1894,82 +1968,13 @@ async fn cleanup_expired_tasks(state: &AppState) -> Result<(), AppError> {
|
||||
}
|
||||
|
||||
async fn cleanup_expired_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
"UPDATE tasks SET status = 'cancelled', completed_at = COALESCE(completed_at, NOW()) WHERE id = $1 AND expires_at < NOW() AND status IN ('pending', 'processing')",
|
||||
)
|
||||
.bind(task_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "终止过期任务失败").with_source(err))?;
|
||||
quota::settle_anonymous_task_reservation(state, task_id).await?;
|
||||
|
||||
let files: Vec<CleanupFileRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT storage_backend, storage_endpoint_id,
|
||||
COALESCE(storage_key, storage_path) AS storage_key,
|
||||
input_path
|
||||
FROM task_files
|
||||
WHERE task_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(task_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询过期任务文件失败").with_source(err))?;
|
||||
|
||||
for file in files {
|
||||
if let Some(key) = file.storage_key {
|
||||
storage::delete_object(
|
||||
state,
|
||||
&storage::ObjectLocator {
|
||||
backend: file.storage_backend,
|
||||
endpoint_id: file.storage_endpoint_id,
|
||||
key,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if let Some(input_path) = file.input_path {
|
||||
let _ = tokio::fs::remove_file(input_path).await;
|
||||
}
|
||||
if object_lifecycle::mark_expired_task(state, task_id).await? {
|
||||
object_lifecycle::finalize_task_deletion(state, task_id).await?;
|
||||
}
|
||||
|
||||
let zip: Option<CleanupZipRow> = sqlx::query_as(
|
||||
"SELECT zip_storage_backend, zip_storage_endpoint_id, zip_storage_key FROM tasks WHERE id = $1",
|
||||
)
|
||||
.bind(task_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询过期 ZIP 失败").with_source(err))?;
|
||||
if let Some(zip) = zip {
|
||||
if let (Some(backend), Some(key)) = (zip.zip_storage_backend, zip.zip_storage_key) {
|
||||
storage::delete_object(
|
||||
state,
|
||||
&storage::ObjectLocator {
|
||||
backend,
|
||||
endpoint_id: zip.zip_storage_endpoint_id,
|
||||
key,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let legacy_zip_path = format!("{}/zips/{task_id}.zip", state.config.storage_path);
|
||||
let _ = tokio::fs::remove_file(legacy_zip_path).await;
|
||||
let orig_dir = format!("{}/orig/{task_id}", state.config.storage_path);
|
||||
let _ = tokio::fs::remove_dir_all(orig_dir).await;
|
||||
|
||||
sqlx::query("DELETE FROM tasks WHERE id = $1 AND expires_at < NOW()")
|
||||
.bind(task_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "删除过期任务记录失败").with_source(err)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn stored_locator(stored: &storage::StoredObject) -> storage::ObjectLocator {
|
||||
storage::ObjectLocator {
|
||||
backend: stored.backend.clone(),
|
||||
@@ -2148,16 +2153,22 @@ mod tests {
|
||||
|
||||
let stale_key = storage::result_attempt_key(24, task_id, file_id, 1, 1, "png");
|
||||
let winning_key = storage::result_attempt_key(24, task_id, file_id, 2, 2, "png");
|
||||
let stale_object = storage::store_bytes(
|
||||
let stale_object = object_lifecycle::store_tracked_bytes(
|
||||
&state,
|
||||
task_id,
|
||||
Some(file_id),
|
||||
"result",
|
||||
&stale_key,
|
||||
Bytes::from_static(b"stale-attempt"),
|
||||
"image/png",
|
||||
)
|
||||
.await
|
||||
.expect("store stale attempt object");
|
||||
let winning_object = storage::store_bytes(
|
||||
let winning_object = object_lifecycle::store_tracked_bytes(
|
||||
&state,
|
||||
task_id,
|
||||
Some(file_id),
|
||||
"result",
|
||||
&winning_key,
|
||||
Bytes::from_static(b"winning-attempt"),
|
||||
"image/png",
|
||||
@@ -2165,8 +2176,8 @@ mod tests {
|
||||
.await
|
||||
.expect("store winning attempt object");
|
||||
if let Ok(expected_backend) = std::env::var("IMAGEFORGE_TEST_EXPECT_STORAGE_BACKEND") {
|
||||
assert_eq!(stale_object.backend, expected_backend);
|
||||
assert_eq!(winning_object.backend, expected_backend);
|
||||
assert_eq!(stale_object.stored.backend, expected_backend);
|
||||
assert_eq!(winning_object.stored.backend, expected_backend);
|
||||
}
|
||||
|
||||
let period_start = Utc::now() - chrono::Duration::hours(1);
|
||||
@@ -2259,14 +2270,14 @@ mod tests {
|
||||
assert_eq!(stale_result, FinalizeFileOutcome::LeaseLost);
|
||||
assert_eq!(winning_result, FinalizeFileOutcome::Committed);
|
||||
|
||||
storage::delete_object(&state, &stored_locator(&stale_object))
|
||||
.await
|
||||
.expect("delete stale attempt object");
|
||||
assert!(storage::read_bytes(&state, &stored_locator(&stale_object))
|
||||
.await
|
||||
.is_err());
|
||||
discard_tracked_result(&state, &stale_object, None).await;
|
||||
assert!(
|
||||
storage::read_bytes(&state, &stored_locator(&stale_object.stored))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
storage::read_bytes(&state, &stored_locator(&winning_object))
|
||||
storage::read_bytes(&state, &stored_locator(&winning_object.stored))
|
||||
.await
|
||||
.expect("read winning object"),
|
||||
b"winning-attempt"
|
||||
@@ -2292,7 +2303,11 @@ mod tests {
|
||||
.expect("query test file");
|
||||
assert_eq!(
|
||||
file,
|
||||
("completed".to_string(), winning_object.key.clone(), 40)
|
||||
(
|
||||
"completed".to_string(),
|
||||
winning_object.stored.key.clone(),
|
||||
40
|
||||
)
|
||||
);
|
||||
let usage_event_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM usage_events WHERE task_file_id = $1")
|
||||
@@ -2312,9 +2327,7 @@ mod tests {
|
||||
.expect("query used units");
|
||||
assert_eq!(used_units, 1);
|
||||
|
||||
storage::delete_object(&state, &stored_locator(&winning_object))
|
||||
.await
|
||||
.expect("delete winning object");
|
||||
discard_tracked_result(&state, &winning_object, None).await;
|
||||
sqlx::query("DELETE FROM usage_events WHERE task_id = $1")
|
||||
.bind(task_id)
|
||||
.execute(&pool)
|
||||
@@ -2325,6 +2338,11 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete test task");
|
||||
sqlx::query("DELETE FROM storage_objects WHERE task_id = $1")
|
||||
.bind(task_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete test storage lifecycle rows");
|
||||
sqlx::query("DELETE FROM usage_periods WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&pool)
|
||||
|
||||
Reference in New Issue
Block a user