diff --git a/src/infrastructure/services/thumb_attached_import_service.rs b/src/infrastructure/services/thumb_attached_import_service.rs index 2733a2db..0486f660 100644 --- a/src/infrastructure/services/thumb_attached_import_service.rs +++ b/src/infrastructure/services/thumb_attached_import_service.rs @@ -130,14 +130,23 @@ impl ThumbAttachedImport { /// Does the file still exist? Checked explicitly rather than letting the /// foreign key reject the insert, so an orphaned sidecar is *counted* as /// an orphan instead of surfacing as an opaque constraint error. + /// `SELECT EXISTS(...)`, deliberately, rather than `SELECT 1 … LIMIT 1`. + /// + /// PostgreSQL types a bare `1` as `int4`, so decoding it as `i64` fails — + /// and because a decode error is indistinguishable from "no row" once + /// swallowed, every sidecar would be misreported as an orphan and nothing + /// would import. `EXISTS` yields a real `bool` and always returns exactly + /// one row, so absence means absence. + /// + /// A query error still degrades to `false`, which is the safe direction: + /// the file is reported as an orphan and left on disk for the operator, + /// rather than imported against a row that may not exist. async fn file_exists(&self, file_id: Uuid) -> bool { - sqlx::query_scalar::<_, i64>("SELECT 1 FROM storage.files WHERE id = $1") + sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM storage.files WHERE id = $1)") .bind(file_id) - .fetch_optional(self.pool.as_ref()) + .fetch_one(self.pool.as_ref()) .await - .ok() - .flatten() - .is_some() + .unwrap_or(false) } } diff --git a/tests/api/thumb_import_check.sh b/tests/api/thumb_import_check.sh index 6b42420f..f7978972 100755 --- a/tests/api/thumb_import_check.sh +++ b/tests/api/thumb_import_check.sh @@ -50,7 +50,32 @@ COMPOSE_FILE="$REPO_ROOT/tests/common/docker-compose.test.yml" source "$SCRIPT_DIR/test.env" log() { echo "[thumb-import] $*"; } -fail() { echo $'\e[31m'"[thumb-import] FAIL: $*"$'\e[0m' >&2; exit 1; } + +# Dump a job's findings before dying. Without this, an import that ran but +# imported nothing looks identical to one that never ran — and the jobs +# record precisely why they skipped a file (orphan, unreadable, store +# failed). The first failure of this script was a misreported orphan, and +# the finding naming it was sitting in the run the whole time. +dump_findings() { + local job="$1" run_id findings + run_id=$(curl -sf -H "$AUTH" "$base_url/api/admin/jobs/$job/runs?limit=1" 2>/dev/null \ + | jq -r 'if type == "array" then .[0].id else ((.runs // .items // [])[0].id) end // empty') + [[ -z "$run_id" ]] && { echo " ($job: no run found)" >&2; return; } + findings=$(curl -sf -H "$AUTH" \ + "$base_url/api/admin/jobs/$job/runs/$run_id/findings?limit=20" 2>/dev/null || echo '[]') + echo " $job findings:" >&2 + echo "$findings" | jq -r \ + 'if type == "array" then .[] else (.findings // .items // [])[] end + | " \(.kind // .finding_kind // "?") \(.details // {} | tostring)"' 2>/dev/null >&2 \ + || echo " (unparseable)" >&2 +} + +fail() { + echo $'\e[31m'"[thumb-import] FAIL: $*"$'\e[0m' >&2 + dump_findings thumb_derived_import + dump_findings thumb_attached_import + exit 1 +} # psql inside the compose container — no host psql dependency, matching # how spawn-db.sh probes readiness.