mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-09-21 05:21:43 +00:00
feat(worker): notify on ambiguous provenance candidates instead of guessing
When multiple placeholder packages share the same name+size, try to disambiguate via ZIP fingerprint; if that can't uniquely resolve a single match, emit a SystemNotification and skip the backfill rather than attributing provenance to the wrong package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+32
-15
@@ -1022,15 +1022,18 @@ export interface PlaceholderCandidate {
|
|||||||
destChannel: { telegramId: bigint } | null;
|
destChannel: { telegramId: bigint } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findPlaceholderCandidate(
|
/**
|
||||||
|
* Find every placeholder Package matching name+size (oldest first). Package
|
||||||
|
* has no direct `destChannel` relation (only the scalar `destChannelId`), so
|
||||||
|
* each row's destination TelegramChannel telegramId is resolved with a
|
||||||
|
* follow-up lookup rather than a Prisma include.
|
||||||
|
*/
|
||||||
|
export async function findPlaceholderCandidates(
|
||||||
destChannelId: string,
|
destChannelId: string,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
fileSize: bigint,
|
fileSize: bigint,
|
||||||
): Promise<PlaceholderCandidate | null> {
|
): Promise<PlaceholderCandidate[]> {
|
||||||
// Package has no direct `destChannel` relation (only the scalar
|
const rows = await db.package.findMany({
|
||||||
// `destChannelId`), so resolve the destination TelegramChannel's
|
|
||||||
// telegramId with a follow-up lookup rather than a Prisma include.
|
|
||||||
const row = await db.package.findFirst({
|
|
||||||
where: {
|
where: {
|
||||||
fileName,
|
fileName,
|
||||||
fileSize,
|
fileSize,
|
||||||
@@ -1048,22 +1051,36 @@ export async function findPlaceholderCandidate(
|
|||||||
},
|
},
|
||||||
orderBy: { indexedAt: "asc" },
|
orderBy: { indexedAt: "asc" },
|
||||||
});
|
});
|
||||||
if (!row) return null;
|
if (rows.length === 0) return [];
|
||||||
const destChannel = row.destChannelId
|
|
||||||
? await db.telegramChannel.findUnique({
|
const destChannelIds = [...new Set(rows.map((r) => r.destChannelId).filter((id): id is string => !!id))];
|
||||||
where: { id: row.destChannelId },
|
const channels = destChannelIds.length
|
||||||
select: { telegramId: true },
|
? await db.telegramChannel.findMany({
|
||||||
|
where: { id: { in: destChannelIds } },
|
||||||
|
select: { id: true, telegramId: true },
|
||||||
})
|
})
|
||||||
: null;
|
: [];
|
||||||
return {
|
const telegramIdById = new Map(channels.map((c) => [c.id, c.telegramId]));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
archiveType: row.archiveType,
|
archiveType: row.archiveType,
|
||||||
fileCount: row.fileCount,
|
fileCount: row.fileCount,
|
||||||
fileSize: row.fileSize,
|
fileSize: row.fileSize,
|
||||||
destMessageId: row.destMessageId,
|
destMessageId: row.destMessageId,
|
||||||
destMessageIds: row.destMessageIds,
|
destMessageIds: row.destMessageIds,
|
||||||
destChannel,
|
destChannel: row.destChannelId && telegramIdById.has(row.destChannelId)
|
||||||
};
|
? { telegramId: telegramIdById.get(row.destChannelId)! }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findPlaceholderCandidate(
|
||||||
|
destChannelId: string,
|
||||||
|
fileName: string,
|
||||||
|
fileSize: bigint,
|
||||||
|
): Promise<PlaceholderCandidate | null> {
|
||||||
|
return (await findPlaceholderCandidates(destChannelId, fileName, fileSize))[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPackageFileCrcs(packageId: string): Promise<(string | null)[]> {
|
export async function getPackageFileCrcs(packageId: string): Promise<(string | null)[]> {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { db } from "./db/client.js";
|
||||||
import { childLogger } from "./util/logger.js";
|
import { childLogger } from "./util/logger.js";
|
||||||
import { downloadFileRange } from "./tdlib/range-download.js";
|
import { downloadFileRange } from "./tdlib/range-download.js";
|
||||||
import { invokeWithTimeout } from "./tdlib/download.js";
|
import { invokeWithTimeout } from "./tdlib/download.js";
|
||||||
import { parseZipCentralDirectoryFromTail, MIN_ZIP_TAIL_BYTES } from "./archive/central-directory.js";
|
import { parseZipCentralDirectoryFromTail, MIN_ZIP_TAIL_BYTES } from "./archive/central-directory.js";
|
||||||
import { fingerprintsMatch, crcFingerprint } from "./archive/fingerprint.js";
|
import { fingerprintsMatch, crcFingerprint } from "./archive/fingerprint.js";
|
||||||
import {
|
import {
|
||||||
findPlaceholderCandidate,
|
findPlaceholderCandidates,
|
||||||
getPackageFileCrcs,
|
getPackageFileCrcs,
|
||||||
backfillProvenance,
|
backfillProvenance,
|
||||||
|
type PlaceholderCandidate,
|
||||||
} from "./db/queries.js";
|
} from "./db/queries.js";
|
||||||
import type { FileEntry } from "./archive/zip-reader.js";
|
import type { FileEntry } from "./archive/zip-reader.js";
|
||||||
import type { Client } from "tdl";
|
import type { Client } from "tdl";
|
||||||
@@ -72,18 +74,16 @@ async function readZipListingFromDestination(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tryProvenanceBackfill(
|
/**
|
||||||
args: BackfillArgs,
|
* Build the CRC fingerprint entries for a placeholder candidate: start from
|
||||||
): Promise<{ backfilled: boolean; confidence?: "fingerprint" | "name-size" }> {
|
* its stored PackageFile CRCs, and if those are incomplete (e.g. a rebuild
|
||||||
const candidate = await findPlaceholderCandidate(args.destChannelId, args.fileName, args.fileSize);
|
* candidate with fileCount === 0), fall back to a fresh ranged read of the
|
||||||
if (!candidate) return { backfilled: false };
|
* candidate's own copy in the destination channel (Task 9).
|
||||||
|
*/
|
||||||
let entries: FileEntry[] | null = null;
|
async function resolveCandidateFingerprintEntries(
|
||||||
let confidence: "fingerprint" | "name-size" = "name-size";
|
client: Client,
|
||||||
|
candidate: PlaceholderCandidate,
|
||||||
if (args.archiveType === "ZIP") {
|
): Promise<FileEntry[]> {
|
||||||
entries = await readScannedZipListing(args.client, args.scannedFileId, args.fileSize);
|
|
||||||
if (entries) {
|
|
||||||
const candidateCrcs = await getPackageFileCrcs(candidate.id);
|
const candidateCrcs = await getPackageFileCrcs(candidate.id);
|
||||||
let candidateEntries: FileEntry[] = candidateCrcs.map((crc) => ({
|
let candidateEntries: FileEntry[] = candidateCrcs.map((crc) => ({
|
||||||
path: "", fileName: "", extension: null, compressedSize: 0n, uncompressedSize: 0n, crc32: crc,
|
path: "", fileName: "", extension: null, compressedSize: 0n, uncompressedSize: 0n, crc32: crc,
|
||||||
@@ -94,7 +94,7 @@ export async function tryProvenanceBackfill(
|
|||||||
: candidate.destMessageId;
|
: candidate.destMessageId;
|
||||||
if (!crcFingerprint(candidateEntries).complete && destMessageId && candidate.destChannel) {
|
if (!crcFingerprint(candidateEntries).complete && destMessageId && candidate.destChannel) {
|
||||||
const destEntries = await readZipListingFromDestination(
|
const destEntries = await readZipListingFromDestination(
|
||||||
args.client,
|
client,
|
||||||
candidate.destChannel.telegramId,
|
candidate.destChannel.telegramId,
|
||||||
destMessageId,
|
destMessageId,
|
||||||
candidate.fileSize,
|
candidate.fileSize,
|
||||||
@@ -103,18 +103,74 @@ export async function tryProvenanceBackfill(
|
|||||||
candidateEntries = destEntries;
|
candidateEntries = destEntries;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fingerprintsMatch(entries, candidateEntries)) {
|
return candidateEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tryProvenanceBackfill(
|
||||||
|
args: BackfillArgs,
|
||||||
|
): Promise<{ backfilled: boolean; confidence?: "fingerprint" | "name-size" }> {
|
||||||
|
const candidates = await findPlaceholderCandidates(args.destChannelId, args.fileName, args.fileSize);
|
||||||
|
if (candidates.length === 0) return { backfilled: false };
|
||||||
|
|
||||||
|
let scannedEntries: FileEntry[] | null = null;
|
||||||
|
if (args.archiveType === "ZIP") {
|
||||||
|
scannedEntries = await readScannedZipListing(args.client, args.scannedFileId, args.fileSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
let chosen = candidates[0];
|
||||||
|
let confidence: "fingerprint" | "name-size" = "name-size";
|
||||||
|
|
||||||
|
if (candidates.length > 1) {
|
||||||
|
// Multiple placeholder packages share this name+size. Try to
|
||||||
|
// disambiguate by fingerprint (ZIP only); if we can't uniquely resolve
|
||||||
|
// it, notify instead of guessing which one is the real match.
|
||||||
|
if (args.archiveType === "ZIP" && scannedEntries) {
|
||||||
|
const matches: PlaceholderCandidate[] = [];
|
||||||
|
for (const c of candidates) {
|
||||||
|
const candidateEntries = await resolveCandidateFingerprintEntries(args.client, c);
|
||||||
|
if (fingerprintsMatch(scannedEntries, candidateEntries)) matches.push(c);
|
||||||
|
}
|
||||||
|
if (matches.length === 1) {
|
||||||
|
chosen = matches[0];
|
||||||
|
confidence = "fingerprint";
|
||||||
|
} else {
|
||||||
|
await db.systemNotification.create({
|
||||||
|
data: {
|
||||||
|
type: "INTEGRITY_AUDIT",
|
||||||
|
severity: "WARNING",
|
||||||
|
title: `Ambiguous provenance match: ${args.fileName}`,
|
||||||
|
message: `${candidates.length} placeholder packages share this name+size and the fingerprint did not uniquely disambiguate. No provenance was backfilled.`,
|
||||||
|
context: { fileName: args.fileName, candidateIds: candidates.map((c) => c.id) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { backfilled: false };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Can't disambiguate without a fingerprint — notify, don't guess.
|
||||||
|
await db.systemNotification.create({
|
||||||
|
data: {
|
||||||
|
type: "INTEGRITY_AUDIT",
|
||||||
|
severity: "WARNING",
|
||||||
|
title: `Ambiguous provenance match: ${args.fileName}`,
|
||||||
|
message: `${candidates.length} placeholder packages share this name+size (archive type ${args.archiveType} — no cheap fingerprint). No provenance was backfilled.`,
|
||||||
|
context: { fileName: args.fileName, candidateIds: candidates.map((c) => c.id) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { backfilled: false };
|
||||||
|
}
|
||||||
|
} else if (scannedEntries) {
|
||||||
|
const candidateEntries = await resolveCandidateFingerprintEntries(args.client, chosen);
|
||||||
|
if (fingerprintsMatch(scannedEntries, candidateEntries)) {
|
||||||
confidence = "fingerprint";
|
confidence = "fingerprint";
|
||||||
} else {
|
} else {
|
||||||
// Fingerprint mismatch: NOT the same content despite name+size. Do not backfill.
|
// Fingerprint mismatch: NOT the same content despite name+size. Do not backfill.
|
||||||
log.info({ candidateId: candidate.id, fileName: args.fileName }, "fingerprint mismatch — not backfilling");
|
log.info({ candidateId: chosen.id, fileName: args.fileName }, "fingerprint mismatch — not backfilling");
|
||||||
return { backfilled: false };
|
return { backfilled: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await backfillProvenance({
|
const ok = await backfillProvenance({
|
||||||
packageId: candidate.id,
|
packageId: chosen.id,
|
||||||
destChannelId: args.destChannelId,
|
destChannelId: args.destChannelId,
|
||||||
sourceChannelId: args.scannedSourceChannelId,
|
sourceChannelId: args.scannedSourceChannelId,
|
||||||
sourceMessageId: args.sourceMessageId,
|
sourceMessageId: args.sourceMessageId,
|
||||||
@@ -122,14 +178,14 @@ export async function tryProvenanceBackfill(
|
|||||||
sourceCaption: args.sourceCaption,
|
sourceCaption: args.sourceCaption,
|
||||||
remoteUniqueId: args.remoteUniqueId,
|
remoteUniqueId: args.remoteUniqueId,
|
||||||
creator: args.creator,
|
creator: args.creator,
|
||||||
entries: candidate.fileCount === 0 && entries ? entries : undefined,
|
entries: chosen.fileCount === 0 && scannedEntries ? scannedEntries : undefined,
|
||||||
previewData: args.previewData ?? undefined,
|
previewData: args.previewData ?? undefined,
|
||||||
previewMsgId: args.previewMsgId ?? undefined,
|
previewMsgId: args.previewMsgId ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ok) return { backfilled: false };
|
if (!ok) return { backfilled: false };
|
||||||
log.info(
|
log.info(
|
||||||
{ candidateId: candidate.id, fileName: args.fileName, confidence, source: args.scannedSourceChannelId },
|
{ candidateId: chosen.id, fileName: args.fileName, confidence, source: args.scannedSourceChannelId },
|
||||||
"provenance backfilled",
|
"provenance backfilled",
|
||||||
);
|
);
|
||||||
return { backfilled: true, confidence };
|
return { backfilled: true, confidence };
|
||||||
|
|||||||
Reference in New Issue
Block a user