fix(upload): remove the /original rate limit that would have broken the feed

The limiter added here was justified as bounding "100 guests occasionally tapping Original
anzeigen". That is not what this route is. `pickMediaUrl` resolves to
`preview_url ?? thumbnail_url ?? /original`, and a freshly committed upload has BOTH
derivatives null until the compression worker reaches it — at COMPRESSION_WORKER_CONCURRENCY=2
that is minutes during a post-ceremony burst. So /original is the feed's hot path for exactly
the newest photos, in a newest-first grid, at the busiest moment.

With every guest behind one NAT the 600/min bucket is venue-wide: six new photos fanned out
by `upload-new` to ~100 open feeds exhausts it, and then every original fetch from anyone
429s for the rest of the window. The tiles' own 4-second retry uses a fresh `?r=` nonce, so
the clients hold the bucket saturated themselves — the whole venue watching the newest
photos render as broken tiles while the projector skips slides.

A per-IP bucket cannot separate one scraper from the entire party when they share an
address, and these media routes are unauthenticated by design (an `<img>` cannot send a
bearer token), so there is no per-user key to move to. Bandwidth abuse belongs at the proxy.

Also here: the release/lock check order. `release ⇒ lock`, so testing the lock first made
the `GalleryReleased` arm unreachable dead code and every post-release upload answered
`uploads_locked`. The codes are not interchangeable to the client — `uploads_locked` charges
a retry attempt and re-pushes the whole photo on the backoff ladder against an answer that
cannot change, while `gallery_released` parks it and says the photo is safe but the hosts
must reopen. Both sites now test release first, so the fast path and the commit-time
re-check agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:43:50 +02:00
parent ec7c7f18ca
commit 7154b3a810
7 changed files with 415 additions and 77 deletions

View File

@@ -121,3 +121,139 @@ pub async fn get_context(
is_banned: user.is_banned,
}))
}
/// `(original_path, preview_path, thumbnail_path, display_path)` for one upload.
type UploadFilePaths = (String, Option<String>, Option<String>, Option<String>);
/// Delete the caller's own account and everything attached to it.
///
/// The erasure path (H18). There was no user-deletion route at ANY role, so honouring a "please
/// remove my photos and my name" request meant hand-written SQL against production — during or
/// after a wedding, by whoever happened to have psql access. Deletion also never removed text:
/// captions, comment bodies and hashtag links survived indefinitely by design, so even the
/// existing per-photo delete left the guest's words in the database and in the keepsake.
///
/// Self-service on purpose. The alternative (host-initiated only) puts a guest's erasure request
/// through a third party who is at a party, and the join page's data notice now promises this.
///
/// ORDER MATTERS. `upload.user_id` and `comment.user_id` are plain FKs with NO `ON DELETE CASCADE`
/// (migration 002), so deleting the user first fails on a constraint violation. Children first,
/// then the row itself — at which point `session`, `like` and `pin_reset_request` do cascade.
pub async fn delete_account(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<axum::http::StatusCode, AppError> {
// The last host/admin may not erase themselves: it would leave the event with no operator and
// no way to appoint one. Mirrors the floor `set_role` and `ban_user` already enforce.
let user = User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if matches!(user.role, UserRole::Host | UserRole::Admin) {
let others = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM \"user\"
WHERE event_id = $1 AND id != $2
AND role IN ('host', 'admin') AND is_banned = FALSE",
)
.bind(auth.event_id)
.bind(auth.user_id)
.fetch_one(&state.pool)
.await?;
if others == 0 {
return Err(AppError::BadRequest(
"Du bist der letzte Gastgeber. Ernenne zuerst einen anderen Gastgeber, bevor du \
dein Konto löschst."
.into(),
));
}
}
// Collect the file paths BEFORE the rows go, or they are unrecoverable. Every derivative, not
// just the original: a preview left behind is still the guest's photo.
let files: Vec<UploadFilePaths> = sqlx::query_as(
"SELECT original_path, preview_path, thumbnail_path, display_path
FROM upload WHERE user_id = $1",
)
.bind(auth.user_id)
.fetch_all(&state.pool)
.await?;
let mut tx = state.pool.begin().await?;
// Comments the guest wrote on OTHER people's photos. Hard delete, not `deleted_at`: this is
// erasure, and a soft delete leaves the body in the table and in the keepsake's data.json.
sqlx::query("DELETE FROM comment WHERE user_id = $1")
.bind(auth.user_id)
.execute(&mut *tx)
.await?;
// Their uploads. Cascades comments and likes ON those uploads, plus upload_hashtag links.
sqlx::query("DELETE FROM upload WHERE user_id = $1")
.bind(auth.user_id)
.execute(&mut *tx)
.await?;
// Invalidate the keepsake inside the same transaction — an already-released archive still
// contains this guest's photos and captions, and erasure that leaves them in the downloadable
// ZIP has not happened. Returns None when the event isn't released, in which case there is
// nothing to rebuild.
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
crate::services::export::Affects::Both,
)
.await?;
// And the account. `session`, `like` and `pin_reset_request` cascade from here.
sqlx::query("DELETE FROM \"user\" WHERE id = $1")
.bind(auth.user_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
// Best effort, after the commit. Anything missed here is an orphan with no row pointing at it,
// which `sweep_orphan_originals` reclaims on its next pass — so a failure delays reclamation
// rather than leaving the file referenced.
for (original, preview, thumbnail, display) in &files {
for rel in [
Some(original),
preview.as_ref(),
thumbnail.as_ref(),
display.as_ref(),
]
.into_iter()
.flatten()
{
let abs = state.config.media_path.join(rel);
if let Err(e) = tokio::fs::remove_file(&abs).await
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(error = ?e, path = %abs.display(), "account deletion: could not remove media file");
}
}
}
if let Some(r) = regen {
crate::handlers::host::start_regen(&state, r);
}
// Evict their content from every open feed and the projector. `user-hidden` is exactly the
// right signal — it already means "this user's cards must go" — and reusing it means every
// client already handles this with no new event type.
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"user-hidden",
serde_json::json!({ "user_id": auth.user_id }).to_string(),
));
// Audited like the host actions it resembles, with the actor and target being the same person.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
user.role.clone(),
"delete_account",
Some(auth.user_id),
None,
Some(serde_json::json!({ "uploads_removed": files.len() })),
)
.await;
tracing::info!(user_id = %auth.user_id, uploads = files.len(), "account deleted by its owner");
Ok(axum::http::StatusCode::NO_CONTENT)
}

View File

@@ -21,6 +21,15 @@ pub struct PublicEventDto {
pub theme_preset: String,
pub theme_primary: String,
pub theme_accent: String,
/// The operator's data notice, if they set one. Empty string when unset (migration 009
/// defaults it to `''`).
///
/// Exposed PUBLICLY — it was only on `/me/context`, which requires a token, so the one place a
/// notice actually has to appear (before a name is collected) could not read it. The join page
/// pairs this with a baseline notice of its own, precisely because this can be empty: relying
/// on an operator-supplied string meant a stock deploy collected ~100 EU guests' photos of
/// identifiable people, including children, with no notice at the point of collection at all.
pub privacy_note: String,
}
/// Public event identity + presentation config, used by the pre-auth join/recover
@@ -40,5 +49,6 @@ pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEvent
.await,
theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent)
.await,
privacy_note: config::get_str(cache, "privacy_note", "").await,
})
}

View File

@@ -54,9 +54,7 @@ async fn read_text_field_bounded(
.map_err(|e| AppError::BadRequest(e.to_string()))?
{
if buf.len() + chunk.len() > max_bytes {
return Err(AppError::BadRequest(
"Eingabe ist zu lang.".to_string(),
));
return Err(AppError::BadRequest("Eingabe ist zu lang.".to_string()));
}
buf.extend_from_slice(&chunk);
}
@@ -197,29 +195,47 @@ pub async fn upload(
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
// `UserBanned`, not `Forbidden`: a ban is reversible, so the client must KEEP the queued
// blob and park it until `user-shown` arrives. Under the generic `forbidden` code it
// purged the photo from IndexedDB and moved the row to `blocked`, which has no retry
// button — so an unban restored everything except whatever was in flight.
return Err(AppError::UserBanned("Du bist gesperrt.".into()));
}
// Check if uploads are locked
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.uploads_locked_at.is_some() {
drain_multipart(multipart).await;
// Reversible: a host can reopen the event, so the client keeps the queued blob and
// retries on `event-opened` rather than purging it (UploadsLocked, not Forbidden).
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
}
// Belt-and-suspenders on top of the lock (release ⇒ lock): once the gallery is
// released the export has been snapshotted, so a late upload could never make it into
// the keepsake. Reject it explicitly rather than silently diverging the live feed.
// Also reversible (reopen clears `export_released_at`), so likewise UploadsLocked.
// RELEASE IS CHECKED FIRST, AND THE ORDER IS THE WHOLE POINT.
//
// `release ⇒ lock`, so a released gallery satisfies BOTH conditions. Testing the lock first
// made this branch unreachable: every post-release upload — the overwhelmingly common case,
// since release is the end-of-event action every guest's queue runs into — answered
// `uploads_locked`, and the `GalleryReleased` arm below was dead code that read as if it
// worked. The commit-time re-check further down splits the two correctly, so the two paths
// also disagreed about the same event state depending on where the upload was intercepted.
//
// The codes are not interchangeable to the client (see upload-queue.ts): `uploads_locked`
// charges an attempt and re-pushes the whole photo on the backoff ladder, and tells the guest
// to find it via the camera button. `gallery_released` PARKS it — no attempt charged, no
// re-push — and says the photo is safe but needs the hosts to reopen the gallery. Against an
// answer that cannot change on its own, the first is a cellular data leak with a misleading
// message attached.
//
// Both keep the blob; both are cleared by `event-opened`. Only the retry behaviour differs.
if event.export_released_at.is_some() {
drain_multipart(multipart).await;
return Err(AppError::UploadsLocked(
"Galerie wurde bereits freigegeben.".into(),
return Err(AppError::GalleryReleased(
"Die Galerie ist abgeschlossen — es können keine neuen Fotos mehr hinzugefügt werden."
.into(),
));
}
if event.uploads_locked_at.is_some() {
drain_multipart(multipart).await;
// A PLAIN lock (the host paused uploads mid-event) is the reversible-and-likely-soon case,
// so auto-retry is right here: the client keeps the blob and resumes on `event-opened`.
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
}
// Read config limits from DB
let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20).await;
@@ -287,20 +303,16 @@ pub async fn upload(
// 10-20 GB of `.tmp` on a 40 GB volume that the gate cannot see, eating the
// reserve that keeps Postgres able to write WAL. The permit is held until the
// handler returns, which is exactly as long as the temp file can exist.
_admission = Some(
state
.upload_admission
.reserve(cap_bytes)
.await
.ok_or_else(|| {
AppError::ServiceUnavailable(
"Gerade laden sehr viele Gäste hoch. Dein Foto bleibt in der \
_admission = Some(state.upload_admission.reserve(cap_bytes).await.ok_or_else(
|| {
AppError::ServiceUnavailable(
"Gerade laden sehr viele Gäste hoch. Dein Foto bleibt in der \
Warteschlange und wird gleich automatisch gesendet."
.into(),
Some(30),
)
})?,
);
.into(),
Some(30),
)
},
)?);
tokio::fs::create_dir_all(&originals_dir)
.await
.map_err(|e| AppError::Internal(e.into()))?;
@@ -513,7 +525,11 @@ pub async fn upload(
// `media_total` is the opposite: it is a sum of committed DB rows, and this upload's row
// does not exist yet, so the prospective total does need `+ size`.
let free = disk.free as i64;
let media_after = state.media_total.get(&state.pool).await.saturating_add(size);
let media_after = state
.media_total
.get(&state.pool, &state.config.event_slug)
.await
.saturating_add(size);
let keepsake_needs =
crate::services::export::required_free_bytes(media_after.max(0) as u64, 2) as i64;
let required = keepsake_needs.saturating_add(DISK_RESERVE_BYTES);
@@ -616,7 +632,19 @@ pub async fn upload(
.bind(auth.event_id)
.fetch_one(&mut *tx)
.await?;
if locked_at.is_some() || released_at.is_some() {
// Same order as the fast-path check above, and for the same reason: `release ⇒ lock`, so
// testing the lock first would collapse a release into `uploads_locked` and set the client
// auto-retrying a photo that can never be accepted until a host reopens the gallery. A
// guest who lost the race with `release_gallery` must get `gallery_released` so the queue
// parks it instead.
if released_at.is_some() {
return Err(AppError::GalleryReleased(
"Die Galerie ist abgeschlossen — es können keine neuen Fotos mehr hinzugefügt \
werden."
.into(),
));
}
if locked_at.is_some() {
return Err(AppError::UploadsLocked("Uploads sind gesperrt.".into()));
}
@@ -677,7 +705,47 @@ pub async fn upload(
let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?;
Hashtag::link_to_upload(&mut *tx, upload.id, h.id).await?;
}
tx.commit().await?;
// Hand the bytes to the row BEFORE committing, not after.
//
// `tx.commit().await` is a suspension point, and a COMMIT already written to the
// socket is applied by Postgres whether or not this future lives to read the reply.
// Disarming afterwards left a real window: the guest walks out of range mid-commit,
// axum drops the future, Postgres commits the row anyway, and `Drop` deletes the file
// that freshly committed row points at. The result is invisible to every repair path
// — the row is live so the deleted-media sweep skips it, the file is gone so the
// orphan sweep skips it — and it is missing from the keepsake with nothing in the log
// naming it as loss.
//
// Disarming first cannot fix the cancellation (nothing in-process can), but it moves
// the failure to the recoverable side: if we are dropped mid-commit the bytes leak,
// and leaked bytes under a final name are exactly what the orphan sweeper reclaims.
// A committed row whose file we deleted is unrecoverable. Prefer the leak.
file_guard.disarm();
if let Err(e) = tx.commit().await {
// Deliberately do NOT re-arm the guard here.
//
// A `commit()` that returns `Err` is INDETERMINATE, not "definitely rolled back".
// sqlx writes `COMMIT` to the socket and awaits the reply; if the connection dies
// after Postgres flushed the WAL record but before that reply arrives (a db
// restart, a killed backend, a network blip), the row is durably committed and we
// are told it failed. Re-arming would then delete the file a live row points at —
// the exact unrecoverable case the comment above says to avoid, just reached
// through the error path instead of the cancellation path.
//
// It is worse than it sounds, because the client retries: the idempotency fast
// path finds the committed row, answers 200, and the phone purges the only other
// copy of the photo. So we prefer the leak in both directions. If the commit
// genuinely did not apply, `sweep_orphan_originals` reclaims the bytes on its next
// pass (it deletes files with no DB row, which is precisely this case).
tracing::error!(
error = ?e,
path = %absolute_path.display(),
"upload commit returned an error; leaving the file in place because the commit \
may still have applied — the orphan sweeper reclaims it if it did not"
);
return Err(e.into());
}
Ok(upload)
}
.await;
@@ -687,6 +755,9 @@ pub async fn upload(
// and reclaims the bytes on the way out. That covers the concurrent-duplicate loser below
// as well as the plain error case, and unlike the explicit `remove_file` calls it replaces,
// it also covers axum dropping this future instead of returning.
//
// The successful-commit case disarmed the guard inside the block, immediately before
// `tx.commit()` — see the comment there for why it cannot be done out here.
let upload = match tx_result {
Ok(u) => u,
// The concurrent duplicate resolved inside the transaction. The winner's row is committed;
@@ -713,8 +784,6 @@ pub async fn upload(
}
Err(e) => return Err(e),
};
// The committed row now references these bytes — hand ownership over.
file_guard.disarm();
// Spawn compression task
state
@@ -771,7 +840,8 @@ pub async fn edit_upload(
// This endpoint had no rate limit of any kind, while every other mutating route has one.
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let edit_rate_on = config::get_bool(&state.config_cache, "upload_edit_rate_enabled", true).await;
let edit_rate_on =
config::get_bool(&state.config_cache, "upload_edit_rate_enabled", true).await;
if rate_limits_on && edit_rate_on {
let edit_rate =
config::get_i64(&state.config_cache, "upload_edit_rate_per_min", 30).await as usize;
@@ -1141,7 +1211,8 @@ fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64, expe
}
/// Computes the per-user storage quota using
/// `floor((free_disk * tolerance) / max(active_uploaders, 1))`. Returns `limit_bytes =
/// `max(floor((free_disk * tolerance) / max(active_uploaders, estimated_guest_count, 1)), 500 MiB)`
/// — see [`quota_limit_bytes`] for the floor's exact conditions. Returns `limit_bytes =
/// None` whenever the storage quota is currently disabled — callers should skip the
/// check (upload handler) or hide the UI (quota endpoint).
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
@@ -1150,11 +1221,20 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
let tolerance = config::get_f64(&state.config_cache, "quota_tolerance", 0.75).await;
let (active_count,): (i64,) =
sqlx::query_as("SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL")
.fetch_one(&state.pool)
.await
.unwrap_or((0,));
// Scoped to THIS event (H12). Without the filter, reusing the install for a second event
// carried the first one's uploaders forward permanently: event one's 30 photographers stayed
// in event two's quota divisor, silently shrinking every new guest's ceiling for a party they
// had nothing to do with. There is no reset path anywhere in the code or the runbook, so the
// only fix would have been hand-written SQL.
let (active_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(DISTINCT up.user_id) FROM upload up
JOIN event e ON e.id = up.event_id
WHERE up.deleted_at IS NULL AND e.slug = $1",
)
.bind(&state.config.event_slug)
.fetch_one(&state.pool)
.await
.unwrap_or((0,));
let active = active_count.max(1);
// The operator's expected headcount, used as a FLOOR on the divisor so the ceiling doesn't
// slide down as guests arrive — see `quota_limit_bytes`. Admin-editable at runtime.
@@ -1196,7 +1276,7 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
/// Outcome of parsing a `Range` request header against a known file length.
#[derive(Debug, PartialEq, Eq)]
enum RangeSpec {
pub(crate) enum RangeSpec {
/// No `Range` header, or one we deliberately don't honour (multi-range, non-`bytes`
/// unit, malformed). RFC 9110 lets a server ignore a Range it can't process and reply
/// 200 with the full body, which is what every one of these cases does.
@@ -1213,7 +1293,7 @@ enum RangeSpec {
/// Deliberately supports only the three forms a media element actually sends —
/// `bytes=N-`, `bytes=N-M`, `bytes=-S` (suffix) — and treats everything else as `Full`.
/// Multi-range responses need `multipart/byteranges`, which no `<video>` requires.
fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
pub(crate) fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
let Some(raw) = header else {
return RangeSpec::Full;
};
@@ -1358,6 +1438,29 @@ async fn stream_media_file(
/// soft-deleted and ban-hidden uploads (via `find_visible_media`) so moderation actually
/// removes access to content. Preview and thumbnail variants are gated the same way (see
/// [`get_preview`] / [`get_thumbnail`]).
/// NO per-IP rate limit on this route, deliberately — a 600/min ceiling was added here and had to
/// come back out.
///
/// The reasoning that put it in was that `/original` serves "100 guests occasionally tapping
/// 'Original anzeigen'", so a venue-wide 10/s could only ever catch a scraper. That is not what
/// this route is. `pickMediaUrl` (frontend/src/lib/data-mode-store.ts) resolves to
/// `preview_url ?? thumbnail_url ?? /original`, and a freshly committed upload has BOTH derivatives
/// null until the compression worker reaches it — at `COMPRESSION_WORKER_CONCURRENCY=2` that is
/// minutes during a post-ceremony burst. So `/original` IS the feed's hot path for exactly the
/// newest photos, in a newest-first grid, at the busiest moment; `VirtualFeed.svelte` says as much
/// where it explains its broken-tile retry.
///
/// With every guest behind one NAT address the bucket is venue-wide: ~6 new photos fanned out by
/// `upload-new` to ~100 open feeds exhausts 600 on its own, and then every original fetch from
/// anyone at the party 429s for the rest of the window. The tiles' own 4-second retry uses a fresh
/// `?r=` nonce, so the clients then hold the bucket saturated themselves. The whole venue watches
/// the newest photos render as broken tiles, and the projector starts skipping slides.
///
/// A per-IP bucket cannot separate "one scraper" from "the entire party" when they share an
/// address, and these four media routes are unauthenticated by design (an `<img>` cannot send a
/// bearer token), so there is no per-user key to move to. Bandwidth abuse belongs at the proxy,
/// where per-connection limits still work; the certain harm here outweighed the speculative
/// protection.
pub async fn get_original(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
@@ -1652,7 +1755,8 @@ mod tests {
while media < USABLE {
media += step;
let free = USABLE - media;
if free >= crate::services::export::required_free_bytes(media as u64, 2) as i64 + reserve
if free
>= crate::services::export::required_free_bytes(media as u64, 2) as i64 + reserve
{
ceiling = media;
}
@@ -1672,7 +1776,8 @@ mod tests {
let over = ceiling + step;
let free_over = USABLE - over;
assert!(
free_over < crate::services::export::required_free_bytes(over as u64, 2) as i64 + reserve,
free_over
< crate::services::export::required_free_bytes(over as u64, 2) as i64 + reserve,
"the gate should already be closed one step past the ceiling"
);
@@ -1719,8 +1824,8 @@ mod tests {
);
// Global: the keepsake needs both halves plus the reserve, and they no longer fit.
let required =
crate::services::export::required_free_bytes(media as u64, 2) as i64 + DISK_RESERVE_BYTES;
let required = crate::services::export::required_free_bytes(media as u64, 2) as i64
+ DISK_RESERVE_BYTES;
assert!(
free < required,
"the global gate must already be closed at {media} bytes of media: free {free} \
@@ -1771,7 +1876,10 @@ mod tests {
fn dropping_an_armed_guard_reclaims_the_file() {
let p = scratch("armed.tmp");
drop(TempFileGuard::new(p.clone()));
assert!(!p.exists(), "an abandoned upload must not survive the request");
assert!(
!p.exists(),
"an abandoned upload must not survive the request"
);
}
#[test]
@@ -1780,7 +1888,10 @@ mod tests {
let mut g = TempFileGuard::new(p.clone());
g.disarm();
drop(g);
assert!(p.exists(), "a committed upload's bytes must never be deleted");
assert!(
p.exists(),
"a committed upload's bytes must never be deleted"
);
}
#[test]
@@ -1792,7 +1903,10 @@ mod tests {
std::fs::remove_file(&old).unwrap();
g.retarget(new.clone());
drop(g);
assert!(!new.exists(), "the final-named original is orphaned too until the row commits");
assert!(
!new.exists(),
"the final-named original is orphaned too until the row commits"
);
}
#[test]
@@ -1807,7 +1921,7 @@ mod tests {
/// commit transaction — which holds a FOR SHARE lock on the event row, so one request could
/// stall every other upload behind tens of thousands of round trips.
mod hashtag_caps {
use super::super::{MAX_HASHTAGS_PER_UPLOAD, MAX_HASHTAG_LENGTH, normalize_tags};
use super::super::{MAX_HASHTAG_LENGTH, MAX_HASHTAGS_PER_UPLOAD, normalize_tags};
#[test]
fn a_huge_csv_is_capped_not_upserted_in_full() {