diff --git a/frontend/src/lib/components/AdminJobsPanel.svelte b/frontend/src/lib/components/AdminJobsPanel.svelte index 8ae1587c..a0404057 100644 --- a/frontend/src/lib/components/AdminJobsPanel.svelte +++ b/frontend/src/lib/components/AdminJobsPanel.svelte @@ -65,9 +65,32 @@ // ─── Loading + polling ───────────────────────────────────────────── + /** + * Stable render order for the jobs table. The backend snapshot + * iterates a HashMap so its order is non-deterministic — + * refreshing shuffles rows and hurts orientation. + * + * Two-tier sort: consistency tenants (including the + * `consistency_batch` coordinator) group first, other tenants + * follow. Alphabetical within each group. Keeps the consistency + * story visually together so an operator investigating + * corruption doesn't have to scan the full list to find the + * related tenants. + */ + function sortKey(name: string): [number, string] { + const isConsistency = name.endsWith('_consistency') || name === 'consistency_batch'; + return [isConsistency ? 0 : 1, name]; + } + async function loadJobs() { try { - jobs = await listJobs(); + const fetched = await listJobs(); + jobs = fetched.slice().sort((a, b) => { + const [ga, na] = sortKey(a.name); + const [gb, nb] = sortKey(b.name); + if (ga !== gb) return ga - gb; + return na.localeCompare(nb); + }); loadError = null; } catch (e) { loadError = errorMessage(e); diff --git a/src/infrastructure/services/blobs_consistency_service.rs b/src/infrastructure/services/blobs_consistency_service.rs index 342d643f..a321779d 100644 --- a/src/infrastructure/services/blobs_consistency_service.rs +++ b/src/infrastructure/services/blobs_consistency_service.rs @@ -213,6 +213,24 @@ impl RecoverableJobHandler for BlobsConsistencyCheck { // GIN index on `chunk_hashes` (migration // 20260628000000_delta_upload_gin_index) makes the // `= ANY(chunk_hashes)` probe cheap. + // `storage.blobs.ref_count` semantics — what the invariant + // dedup_service maintains actually is: + // + // ref_count = (number of chunk_manifests whose + // chunk_hashes[] contains this hash) + // + (number of files.blob_hash pointing at + // this hash on the LEGACY whole-file path + // — i.e. files with NO manifest for their + // blob_hash) + // + // Naively `COUNT(files) + COUNT(manifests referring)` + // double-counts single-chunk CDC files: for a file whose + // whole-file hash == its single chunk's hash (any file + // small enough to fit in one CDC chunk — under ~256 KB + // average), the file appears BOTH in `files.blob_hash` + // AND in the manifest's `chunk_hashes[]`. The `NOT + // EXISTS` clause below excludes CDC-path files from the + // legacy count so the two terms don't overlap. let rows: Vec = match sqlx::query_as( r#" SELECT @@ -222,7 +240,11 @@ impl RecoverableJobHandler for BlobsConsistencyCheck { b.created_at AS created_at, ( (SELECT COUNT(*) FROM storage.files f - WHERE f.blob_hash = b.hash) + WHERE f.blob_hash = b.hash + AND NOT EXISTS ( + SELECT 1 FROM storage.chunk_manifests m + WHERE m.file_hash = f.blob_hash + )) + (SELECT COUNT(*) FROM storage.chunk_manifests m WHERE b.hash = ANY(m.chunk_hashes)) )::bigint AS actual_ref_count