Files
picpeak/backend/src/services/externalPhotoDedupe.js
T
Paul NothaftandPaul Nothaft e9fcf4960e fix(external-media): one row per external file per event (#1162) (#1173)
* fix(external-media): one row per external file per event (#1162)

Stable twin of #1167.

Two overlapping import-external runs against the same event inserted every
file twice. The route checked for an existing external_relpath and then
inserted, with an fs.stat and a sharp().metadata() read sitting in between — a
window wide enough for both runs to see "not there". A reporter's event held
8004 rows for 6012 distinct paths. Nothing at the storage layer stopped it:
migration 041 created only a NON-unique (event_id, source_origin) index.

- migration 176 removes the existing duplicates and adds a partial unique
  index on (event_id, external_relpath), verified against the catalog
  afterwards — a failed CREATE INDEX raises 23505 on Postgres, which
  run-migrations-safe treats as "schema already exists" and would record as
  applied on an install that never got the index.
- dependent rows are removed explicitly rather than by cascade: PicPeak never
  sets `PRAGMA foreign_keys = ON`, so on SQLite the declared CASCADE is inert
  and a bare delete strands feedback and access-log rows. Guest feedback moves
  to the survivor instead of being discarded, keyed on guest identity the way
  feedbackService defines it, and the survivor's denormalized counters are
  recomputed.
- the route treats a unique violation as a skip, so a writer this process
  cannot see converges instead of duplicating, and a second import while one
  is running gets a 409.
- a .picpeak taken before migration 176 carries exactly these duplicates, and
  suspending FK enforcement does not suspend a unique index — so the restore
  drops the index for the load and rebuilds it after running the same dedupe.

Divergences from the main twin, both because the feature is absent here:
faces (no faceProcessor, so no purgePhotoFaces reconciliation — the rows are
still deleted so nothing dangles), admin marks, transfer membership, and
photos.view_count/download_count. The service guards each on hasTable /
hasColumn, so those branches simply do not fire.

Verified on this branch: 36 new tests pass; full suite leaves the same 5
pre-existing failures as origin/stable, unchanged.

* fix(external-media): invalidate the download zip when duplicates are removed (#1162)

External review. Same fix as the main twin.

The pre-built "download everything" archive still contained the duplicate rows
the dedupe had just deleted, so guests kept receiving them. Every ordinary
photo-deletion path calls downloadZipService.invalidate for exactly this
reason.

The columns are cleared rather than the service being called: that service
carries debounce timers and a regeneration queue, which a migration should not
start. getZipInfo already treats a cleared record as a cache miss and rebuilds
on the next request. The stale object is left in storage, as elsewhere.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-26 08:55:17 +02:00

390 lines
16 KiB
JavaScript

/**
* One row per external file per event (#1162).
*
* The import route used to check for an existing external_relpath and then
* insert, with an fs.stat and a Sharp decode in between — wide enough that two
* overlapping imports both walked through it. A reporter's event held 8004 rows
* for 6012 distinct paths.
*
* This lives in a service rather than inside migration 176 because it has two
* callers. The migration is one. The other is a .picpeak restore: the archive
* carries the photos table verbatim, so a backup taken before this fix lands
* duplicate rows into a schema that now has a unique index on them — and
* neither Postgres' `session_replication_role = replica` nor SQLite's
* `defer_foreign_keys` disables a UNIQUE index, so batchInsert would abort the
* whole restore after every table had already been emptied.
*
* DELETING dependent rows explicitly, rather than trusting ON DELETE CASCADE,
* is the load-bearing part. Every FK into photos declares CASCADE, but PicPeak
* does not set `PRAGMA foreign_keys = ON` — the codebase says so in as many
* words where it deletes an event (adminEvents/helpers.js:245-249) — so on
* every SQLite install the cascade is inert and a bare delete would leave
* dangling face embeddings, feedback and marks behind. The same reason
* `hero_photo_id` is repointed by hand: its SET NULL is inert there too, so
* without it a SQLite install keeps a hero pointing at a row that is gone.
*
* Guest and admin state is MOVED to the survivor where it can be, not
* discarded. The duplicates were separate tiles in the grid, so a guest's
* comment or an admin's rating could legitimately be attached to either, and
* silently deleting it inside a fix for silent data loss would be its own bug.
* Where the target already holds an equivalent row — the same guest's like on
* the same photo, the same admin's mark, the same transfer's entry — the loser
* is dropped instead, because those tables mean "one per (photo, actor)" and
* moving would either violate a unique constraint or double-count.
*
* photo_faces is the deliberate exception: both rows were scanned
* independently, so the survivor already has its own embeddings and moving the
* duplicate's would fabricate a second copy of every face and split the
* person clusters built from them.
*/
const { isUniqueViolation } = require('../utils/dbErrors');
const CHUNK = 400; // SQLite caps a statement at 999 bound parameters.
// Joins the parts of an equivalence key. Escaped, not a literal: a raw NUL in
// the source makes git classify this whole file as binary and hide its diffs.
const KEY_SEP = '\u0000';
const INDEX_NAME = 'photos_event_external_relpath_uniq';
const chunked = (arr) => {
const out = [];
for (let i = 0; i < arr.length; i += CHUNK) out.push(arr.slice(i, i + CHUNK));
return out;
};
/** Pure log rows — nothing is lost by dropping them with the duplicate. */
const LOG_TABLES = [
['image_access_logs', 'photo_id'],
['transfer_downloads', 'photo_id'],
];
/**
* Tables holding one row per (photo, actor). `keys` is what makes two rows
* equivalent, so a move that would collide becomes a delete instead.
*/
const MOVE_TABLES = [
{
table: 'photo_feedback',
// Guest identity, the way feedbackService defines it: guest_id when the
// gallery uses per-person guests (migration 078), guest_identifier
// otherwise — the same COALESCE its own duplicate-check and stats
// aggregate use (feedbackService.js:208, :559). Keying on
// guest_identifier alone would treat two DIFFERENT people sharing a
// device as one and delete one of their ratings.
identity: (row) => (row.guest_id != null ? `id:${row.guest_id}` : `anon:${row.guest_identifier}`),
// is_hidden is part of the identity, not noise: feedbackService lets a
// moderator-hidden row coexist with the guest's visible replacement and
// excludes hidden rows from the counts. Without it the visible row is
// dropped as redundant against the hidden one.
keys: ['feedback_type', 'is_hidden'],
// A comment is distinct content, never a per-guest toggle: two comments
// from one guest are two comments, so they always move.
alwaysMove: (row) => row.feedback_type === 'comment',
},
{
table: 'photo_admin_marks',
keys: ['admin_id'],
// rating and color_label are independently writable, so the same admin can
// have rated one tile and colour-labelled the other. Dropping the loser
// outright would lose a half the survivor's row has no value for.
mergeFields: ['rating', 'color_label'],
},
{ table: 'transfer_files', keys: ['transfer_id'] },
];
const equivalenceKey = (spec, row) => [
spec.identity ? spec.identity(row) : '',
...spec.keys.map((k) => row[k]),
].join(KEY_SEP);
async function repointColumn(knex, table, column, doomedToSurvivor) {
if (!(await knex.schema.hasTable(table))) return;
if (!(await knex.schema.hasColumn(table, column))) return;
for (const [doomed, survivor] of doomedToSurvivor) {
await knex(table).where(column, doomed).update({ [column]: survivor });
}
}
async function moveOrDrop(knex, spec, doomedToSurvivor, touched) {
if (!(await knex.schema.hasTable(spec.table))) return;
for (const [doomed, survivor] of doomedToSurvivor) {
const rows = await knex(spec.table).where('photo_id', doomed);
if (!rows.length) continue;
if (touched) touched.add(survivor);
const existing = await knex(spec.table).where('photo_id', survivor);
const taken = new Set(existing.map((r) => equivalenceKey(spec, r)));
for (const row of rows) {
const key = equivalenceKey(spec, row);
const move = (spec.alwaysMove && spec.alwaysMove(row)) || !taken.has(key);
if (!move) {
// Before dropping the loser, hand over any field the winner has no
// value for — otherwise an independently-set half goes with it.
if (spec.mergeFields) {
const winner = existing.find((r) => equivalenceKey(spec, r) === key);
const fill = {};
for (const field of spec.mergeFields) {
if (winner && winner[field] == null && row[field] != null) fill[field] = row[field];
}
if (winner && Object.keys(fill).length) {
await knex(spec.table).where('id', winner.id).update(fill);
Object.assign(winner, fill);
}
}
await knex(spec.table).where('id', row.id).del();
continue;
}
try {
await knex(spec.table).where('id', row.id).update({ photo_id: survivor });
taken.add(key);
} catch (err) {
// A unique constraint we did not model. The row is redundant with one
// the survivor already has, so dropping it is correct — but anything
// else must surface rather than leave a dangling photo_id behind.
if (!isUniqueViolation(err)) throw err;
await knex(spec.table).where('id', row.id).del();
}
}
}
}
/**
* Remove every photo row in `doomedToSurvivor`, moving or dropping the state
* that hangs off it first. Safe on both engines and on schemas that predate
* any of the dependent tables.
*/
async function deleteDuplicatePhotos(knex, doomedToSurvivor) {
const doomed = [...doomedToSurvivor.keys()];
if (!doomed.length) return 0;
// SET NULL is as inert as CASCADE on SQLite, so an event whose hero happened
// to be the duplicate would silently lose its hero image.
await repointColumn(knex, 'events', 'hero_photo_id', doomedToSurvivor);
await repointColumn(knex, 'photo_categories', 'hero_photo_id', doomedToSurvivor);
const feedbackTouched = new Set();
for (const spec of MOVE_TABLES) {
await moveOrDrop(knex, spec, doomedToSurvivor, spec.table === 'photo_feedback' ? feedbackTouched : null);
}
// photos carries denormalized feedback totals (migration 033:
// feedback_count, like_count, average_rating, favorite_count, and later
// reaction/colour counts). Reparenting rows without recomputing leaves a
// survivor that now OWNS feedback still rendering zero.
if (feedbackTouched.size && await knex.schema.hasColumn('photos', 'feedback_count')) {
const feedbackService = require('./feedbackService');
for (const survivor of feedbackTouched) {
await feedbackService.updatePhotoFeedbackStats(survivor, knex);
}
}
for (const [table, column] of LOG_TABLES) {
if (!(await knex.schema.hasTable(table))) continue;
for (const ids of chunked(doomed)) await knex(table).whereIn(column, ids).del();
}
// Both rows were scanned, so the survivor has its own faces; moving the
// duplicate's would double every embedding and split the person clusters.
//
// Through purgePhotoFaces, not a raw delete: deleting the rows is only half
// of it. event_people counts and centroids are derived from the faces being
// removed, and #1132's separation snapshots hold a COPY of each side's
// centroid — so a bare delete leaves ghost or inflated people and vectors
// built from photos that no longer exist. faceProcessor says as much: it is
// "called from every photo-deletion path".
if (await knex.schema.hasTable('photo_faces')) {
// If the ONLY completed scan of this file belonged to the duplicate, the
// purge below takes the sole embeddings with it and nothing re-queues the
// survivor — it just silently stops having a face. Mark those for a
// rescan; the worker picks up 'pending' on its own.
const needsRescan = [];
for (const [doomedId, survivorId] of doomedToSurvivor) {
if (!(await knex('photo_faces').where('photo_id', doomedId).first())) continue;
if (!(await knex('photo_faces').where('photo_id', survivorId).first())) needsRescan.push(survivorId);
}
let purgePhotoFaces = null;
try {
({ purgePhotoFaces } = require('./faceProcessor'));
} catch (err) {
// Face detection is optional; an install without it still needs the rows
// gone so nothing dangles on SQLite.
purgePhotoFaces = null;
}
if (purgePhotoFaces) {
for (const id of doomed) await purgePhotoFaces(id, knex);
} else {
for (const ids of chunked(doomed)) await knex('photo_faces').whereIn('photo_id', ids).del();
}
if (needsRescan.length && await knex.schema.hasColumn('photos', 'face_status')) {
for (const ids of chunked(needsRescan)) {
await knex('photos').whereIn('id', ids).update({ face_status: 'pending' });
}
}
}
// Real interactions, recorded per row. Deleting the duplicate would quietly
// lower the engagement the admin grid shows for a photo people did view and
// download.
if (await knex.schema.hasColumn('photos', 'view_count')) {
for (const [doomedId, survivorId] of doomedToSurvivor) {
const from = await knex('photos').where('id', doomedId)
.select('view_count', 'download_count').first();
if (!from) continue;
const add = {};
if (from.view_count) add.view_count = knex.raw('COALESCE(view_count, 0) + ?', [from.view_count]);
if (from.download_count) add.download_count = knex.raw('COALESCE(download_count, 0) + ?', [from.download_count]);
if (Object.keys(add).length) await knex('photos').where('id', survivorId).update(add);
}
}
for (const ids of chunked(doomed)) await knex('photos').whereIn('id', ids).del();
// The pre-built "download everything" zip still contains the rows just
// removed. Every ordinary photo-deletion path calls
// downloadZipService.invalidate for this reason (adminPhotos.js:450 and
// friends) — but that service carries debounce timers and a regeneration
// queue, which is not something a migration should be starting. Clearing the
// columns is the durable half of what invalidate does: getZipInfo already
// treats a missing or absent record as a cache miss and rebuilds on the next
// request, so guests stop receiving an archive containing deleted duplicates.
//
// The stale object itself is left for the same reason the duplicates'
// thumbnails are — a migration is the wrong place to reach into storage,
// which may be S3.
if (await knex.schema.hasColumn('events', 'download_zip_path')) {
const affected = [...new Set([...doomedToSurvivor.values()])];
const eventIds = affected.length
? (await knex('photos').whereIn('id', affected).distinct('event_id')).map((r) => r.event_id)
: [];
for (const ids of chunked(eventIds)) {
await knex('events').whereIn('id', ids).update({
download_zip_path: null,
download_zip_generated_at: null,
});
}
}
return doomed.length;
}
/**
* Which rows are duplicates, and which one survives.
*
* Survivor: the lowest id that has a thumbnail_path, else the lowest id.
* Thumbnails are generated per row during import, so on a duplicated pair both
* usually have one and the tie-break never fires — but an import killed
* mid-flight leaves rows without, and dropping the one that HAS the thumbnail
* would blank a grid tile for no reason.
*/
async function planDedupe(knex) {
const dupKeys = await knex('photos')
.whereNotNull('external_relpath')
.select('event_id')
.count('* as c')
.groupBy('event_id', 'external_relpath')
.havingRaw('count(*) > 1');
const doomedToSurvivor = new Map();
for (const eventId of new Set(dupKeys.map((r) => r.event_id))) {
const rows = await knex('photos')
.where('event_id', eventId)
.whereNotNull('external_relpath')
.select('id', 'external_relpath', 'thumbnail_path')
.orderBy('id', 'asc');
const byPath = new Map();
for (const row of rows) {
const group = byPath.get(row.external_relpath);
if (group) group.push(row);
else byPath.set(row.external_relpath, [row]);
}
for (const group of byPath.values()) {
if (group.length < 2) continue;
const survivor = group.find((r) => r.thumbnail_path) || group[0];
for (const row of group) {
if (row.id !== survivor.id) doomedToSurvivor.set(row.id, survivor.id);
}
}
}
return doomedToSurvivor;
}
/** @returns {Promise<number>} how many duplicate rows were removed. */
async function dedupeExternalPhotos(knex) {
if (!(await knex.schema.hasTable('photos'))) return 0;
if (!(await knex.schema.hasColumn('photos', 'external_relpath'))) return 0;
return deleteDuplicatePhotos(knex, await planDedupe(knex));
}
/** Is the index actually there? Asked of the catalog, not inferred. */
async function externalRelpathIndexExists(knex) {
const isPg = knex.client && knex.client.config && knex.client.config.client === 'pg';
const row = isPg
? await knex('pg_indexes').where('indexname', INDEX_NAME).first()
: await knex('sqlite_master').where({ type: 'index', name: INDEX_NAME }).first();
return !!row;
}
/**
* The error a failed index MUST raise.
*
* Deliberately carries no `code`. run-migrations-safe.js treats 23505, 42P07,
* 42701 and 42710 as "schema already exists" and marks the migration applied
* (run-migrations-safe.js:138) — and a CREATE UNIQUE INDEX that finds
* duplicate rows raises exactly 23505 on Postgres. Letting the driver's error
* through would therefore record 176 as done on an install that never got the
* index, with nothing to trigger a retry: the precise outcome the throw
* exists to prevent.
*/
function indexFailure(detail) {
return new Error(
`Could not create ${INDEX_NAME}: ${detail}. The photos table still holds `
+ 'duplicate (event_id, external_relpath) rows — most likely inserted by a '
+ 'concurrent import while this migration ran. Stop other writers and re-run.'
);
}
/**
* Partial, so the managed rows — which all carry NULL — are not indexed at
* all. Both engines treat NULLs as distinct in a unique index, so a plain one
* would also be correct, but it would carry every managed photo for no query
* that ever uses it.
*/
async function createExternalRelpathIndex(knex) {
try {
await knex.raw(
`CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} `
+ 'ON photos (event_id, external_relpath) WHERE external_relpath IS NOT NULL'
);
} catch (err) {
throw indexFailure(err.message);
}
// IF NOT EXISTS makes the statement itself a poor witness, and a replica
// inserting a duplicate between the dedupe and this lock is a real rolling-
// deploy shape. Ask the catalog.
if (!(await externalRelpathIndexExists(knex))) {
throw indexFailure('the index is absent afterwards');
}
}
async function dropExternalRelpathIndex(knex) {
await knex.raw(`DROP INDEX IF EXISTS ${INDEX_NAME}`);
}
module.exports = {
dedupeExternalPhotos,
externalRelpathIndexExists,
deleteDuplicatePhotos,
planDedupe,
createExternalRelpathIndex,
dropExternalRelpathIndex,
INDEX_NAME,
};