minor bugs
This commit is contained in:
@@ -72,15 +72,27 @@ pub async fn ingest_project(db: &PgPool, project_id: i32, dir: &Path) -> anyhow:
|
||||
|
||||
// Never clobber a verified push (Phase 3): a worker's pushed annotations +
|
||||
// completer/verify-time are authoritative, so skip re-ingesting this video.
|
||||
let existing_status: Option<String> =
|
||||
sqlx::query_scalar("SELECT status FROM videos WHERE project_id=$1 AND rel_path=$2")
|
||||
.bind(project_id)
|
||||
.bind(&rel_path)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
if existing_status.as_deref() == Some("verified") {
|
||||
tracing::debug!("skip re-ingest of verified video: {rel_path}");
|
||||
continue;
|
||||
// Also never clobber a video that is *actively claimed* (a worker is mid-edit)
|
||||
// — a stale NAS JSON must not overwrite in-progress work. Both checks read the
|
||||
// current row's status + claim lease.
|
||||
let existing: Option<(String, Option<String>, Option<DateTime<Utc>>)> = sqlx::query_as(
|
||||
"SELECT status, claimed_by, lease_expires_at FROM videos WHERE project_id=$1 AND rel_path=$2",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(&rel_path)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
if let Some((status, claimed_by, lease)) = existing.as_ref() {
|
||||
if status == "verified" {
|
||||
tracing::debug!("skip re-ingest of verified video: {rel_path}");
|
||||
continue;
|
||||
}
|
||||
let active_claim = claimed_by.is_some()
|
||||
&& lease.map(|l| l > Utc::now()).unwrap_or(false);
|
||||
if active_claim {
|
||||
tracing::debug!("skip re-ingest of actively-claimed video: {rel_path}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let json_path = sibling_json(path);
|
||||
@@ -138,6 +150,10 @@ pub async fn ingest_project(db: &PgPool, project_id: i32, dir: &Path) -> anyhow:
|
||||
std::fs::read_to_string(jp).ok().and_then(|t| serde_json::from_str(&t).ok())
|
||||
});
|
||||
|
||||
// Upsert the video + replace its annotation rows atomically: either the new
|
||||
// metadata AND the new annotation set both land, or neither does. Prevents a
|
||||
// failed insert mid-loop from leaving the video with partial/zero annotations.
|
||||
let mut tx = db.begin().await?;
|
||||
// Upsert the video. Keep 'verified' status sticky (Phase 3 pushes).
|
||||
let video_id: i32 = sqlx::query_scalar(
|
||||
r#"
|
||||
@@ -179,18 +195,19 @@ pub async fn ingest_project(db: &PgPool, project_id: i32, dir: &Path) -> anyhow:
|
||||
.bind(latest)
|
||||
.bind(raw_json)
|
||||
.bind(ann_count)
|
||||
.fetch_one(db)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Replace detailed annotation rows for this video.
|
||||
// Replace detailed annotation rows for this video (same transaction).
|
||||
if let Some(d) = doc.as_ref() {
|
||||
replace_annotations(db, video_id, d).await?;
|
||||
replace_annotations(&mut tx, video_id, d).await?;
|
||||
} else {
|
||||
sqlx::query("DELETE FROM annotations WHERE video_id=$1")
|
||||
.bind(video_id)
|
||||
.execute(db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
@@ -199,27 +216,33 @@ pub async fn ingest_project(db: &PgPool, project_id: i32, dir: &Path) -> anyhow:
|
||||
/// Replace all annotation rows for a video with the contents of an export doc.
|
||||
/// Shared by ingest (NAS JSON) and the Phase 3 worker push. Returns the row count
|
||||
/// written (each range annotation contributes 1–2 rows: start, and end if present).
|
||||
///
|
||||
/// Runs on a caller-supplied transaction connection so the delete + all inserts are
|
||||
/// **atomic** — either every annotation row lands or none do. Callers (`ingest`, worker
|
||||
/// `push`) own the transaction so the surrounding metadata update commits together with
|
||||
/// the annotation set. A failed insert mid-loop rolls back the delete, so annotations
|
||||
/// can never be partially lost.
|
||||
pub async fn replace_annotations(
|
||||
db: &PgPool,
|
||||
conn: &mut sqlx::PgConnection,
|
||||
video_id: i32,
|
||||
d: &ExportDoc,
|
||||
) -> anyhow::Result<i32> {
|
||||
sqlx::query("DELETE FROM annotations WHERE video_id=$1")
|
||||
.bind(video_id)
|
||||
.execute(db)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
let mut written = 0i32;
|
||||
for a in &d.fixed_annotations {
|
||||
insert_ann(db, video_id, a.frame_number, &a.label, &a.side, &a.shape_type,
|
||||
insert_ann(&mut *conn, video_id, a.frame_number, &a.label, &a.side, &a.shape_type,
|
||||
&a.vertices, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?;
|
||||
written += 1;
|
||||
}
|
||||
for a in &d.range_annotations {
|
||||
insert_ann(db, video_id, a.start_frame, &a.label, &a.side, &a.shape_type,
|
||||
insert_ann(&mut *conn, video_id, a.start_frame, &a.label, &a.side, &a.shape_type,
|
||||
&a.start_vertices, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?;
|
||||
written += 1;
|
||||
if let (Some(ef), Some(ev)) = (a.end_frame, a.end_vertices.as_ref()) {
|
||||
insert_ann(db, video_id, ef, &a.label, &a.side, &a.shape_type,
|
||||
insert_ann(&mut *conn, video_id, ef, &a.label, &a.side, &a.shape_type,
|
||||
ev, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?;
|
||||
written += 1;
|
||||
}
|
||||
@@ -229,7 +252,7 @@ pub async fn replace_annotations(
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn insert_ann(
|
||||
db: &PgPool, video_id: i32, frame: i64, label: &str, side: &str, shape: &str,
|
||||
conn: &mut sqlx::PgConnection, video_id: i32, frame: i64, label: &str, side: &str, shape: &str,
|
||||
vertices: &serde_json::Value, by: &str, review: &str, remark: &str, subclass: &str, created_at: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
@@ -248,7 +271,7 @@ async fn insert_ann(
|
||||
.bind(remark)
|
||||
.bind(subclass)
|
||||
.bind(created_at)
|
||||
.execute(db)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user