transactions + unique-conflict

This commit is contained in:
nvrl committed 2026-06-15 17:32:26 +02:00
1 parent f8caef64c9
commit 1e0ccf53a4
4 files changed
+48 -10

No files matched your search

+11
View File
@@ -68,6 +68,17 @@ jobs:
backend-rs/target backend-rs/target
key: cargo-${{ hashFiles('backend-rs/Cargo.lock') }} key: cargo-${{ hashFiles('backend-rs/Cargo.lock') }}
- name: Fetch common-passwords list
run: |
mkdir -p assets
curl -fsSL -o assets/common-passwords.txt.gz \
"https://raw.githubusercontent.com/django/django/${DJANGO_PASSWORDS_TAG}/django/contrib/auth/common-passwords.txt.gz"
echo "${DJANGO_PASSWORDS_SHA256} assets/common-passwords.txt.gz" | sha256sum -c -
working-directory: backend-rs
env:
DJANGO_PASSWORDS_TAG: "5.2"
DJANGO_PASSWORDS_SHA256: "3c1baed62596de36860824eb3f436d5932d37ca8b06e59df78f5a44ec175afe4"
- name: Format Check - name: Format Check
run: cargo fmt --check run: cargo fmt --check
working-directory: backend-rs working-directory: backend-rs
+7
View File
@@ -89,3 +89,10 @@ impl IntoResponse for ApiError {
} }
pub type ApiResult<T> = Result<T, ApiError>; pub type ApiResult<T> = Result<T, ApiError>;
/// True if a `DbErr` is a SQLite UNIQUE-constraint violation. SeaORM surfaces the
/// underlying sqlx message verbatim; matching on it lets handlers turn a racing
/// duplicate insert into a clean 400 instead of a generic 500.
pub fn is_unique_violation(e: &sea_orm::DbErr) -> bool {
e.to_string().contains("UNIQUE constraint failed")
}
+3 -3
View File
@@ -5,7 +5,7 @@
use sea_orm::sea_query::{Expr, Func}; use sea_orm::sea_query::{Expr, Func};
use sea_orm::{ use sea_orm::{
ActiveValue::{NotSet, Set}, ActiveValue::{NotSet, Set},
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, ColumnTrait, Condition, ConnectionTrait, EntityTrait, QueryFilter,
}; };
use crate::entities::prelude::*; use crate::entities::prelude::*;
@@ -47,8 +47,8 @@ fn eq_or_null(col: notification::Column, value: Option<i32>) -> Condition {
} }
} }
pub async fn parse_mentions( pub async fn parse_mentions<C: ConnectionTrait>(
db: &DatabaseConnection, db: &C,
text: &str, text: &str,
actor_id: i32, actor_id: i32,
rating_id: Option<i32>, rating_id: Option<i32>,
+27 -7
View File
@@ -196,7 +196,13 @@ async fn create(
} }
let now = crate::datetime::now_micros(); let now = crate::datetime::now_micros();
let model = rating::ActiveModel { // Insert + mention fan-out in one transaction so a parse_mentions failure
// can't leave a rating with no notifications. The DB's UNIQUE(user_id,
// flavor_id) index is the real guard against a racing double-rate (the
// pre-check above is best-effort and not atomic): map that violation to the
// same clean validation error instead of a 500.
let txn = state.db.begin().await?;
let model = match (rating::ActiveModel {
id: NotSet, id: NotSet,
user_id: Set(uid), user_id: Set(uid),
flavor_id: Set(flavor_id), flavor_id: Set(flavor_id),
@@ -204,12 +210,22 @@ async fn create(
comment: Set(comment.clone()), comment: Set(comment.clone()),
created_at: Set(now), created_at: Set(now),
} }
.insert(&state.db) .insert(&txn)
.await?; .await)
{
Ok(m) => m,
Err(e) if crate::error::is_unique_violation(&e) => {
return Err(ApiError::Validation(json!([
"You have already rated this flavor."
])));
}
Err(e) => return Err(e.into()),
};
if let Some(text) = comment.as_deref().filter(|s| !s.is_empty()) { if let Some(text) = comment.as_deref().filter(|s| !s.is_empty()) {
parse_mentions(&state.db, text, uid, Some(model.id), None).await?; parse_mentions(&txn, text, uid, Some(model.id), None).await?;
} }
txn.commit().await?;
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?; let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
let out = dtos.pop().ok_or(ApiError::Internal)?; let out = dtos.pop().ok_or(ApiError::Internal)?;
@@ -430,6 +446,9 @@ async fn reply(
} }
let now = crate::datetime::now_micros(); let now = crate::datetime::now_micros();
// Reply + owner-notification + mention fan-out are one unit: a failure in any
// step must not persist a partial reply (orphaned reply, missing notification).
let txn = state.db.begin().await?;
let reply_model = reply::ActiveModel { let reply_model = reply::ActiveModel {
id: NotSet, id: NotSet,
user_id: Set(uid), user_id: Set(uid),
@@ -437,7 +456,7 @@ async fn reply(
text: Set(text.to_string()), text: Set(text.to_string()),
created_at: Set(now), created_at: Set(now),
} }
.insert(&state.db) .insert(&txn)
.await?; .await?;
if rating.user_id != uid { if rating.user_id != uid {
@@ -453,11 +472,12 @@ async fn reply(
notification_type: Set("reply".to_string()), notification_type: Set("reply".to_string()),
profile_comment_id: Set(None), profile_comment_id: Set(None),
} }
.insert(&state.db) .insert(&txn)
.await?; .await?;
} }
parse_mentions(&state.db, text, uid, Some(rating.id), Some(reply_model.id)).await?; parse_mentions(&txn, text, uid, Some(rating.id), Some(reply_model.id)).await?;
txn.commit().await?;
let mut dtos = build_replies(&state, vec![reply_model]).await?; let mut dtos = build_replies(&state, vec![reply_model]).await?;
let out = dtos.pop().ok_or(ApiError::Internal)?; let out = dtos.pop().ok_or(ApiError::Internal)?;