* fix(external-media): one row per external file per event (#1162) 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 186 removes the duplicates that already exist and adds a partial unique index on (event_id, external_relpath). The survivor is the lowest id that has a thumbnail, so a half-finished import does not cost a grid tile, and hero references are repointed first because the FK is SET NULL. - the route treats a unique violation as a skip and carries on, so a second writer this process cannot see (another replica) converges instead of duplicating or 500ing. - a second import while one is already running now gets a 409 rather than walking the whole tree to have every insert bounce. The duplicates' thumbnail files are left behind as unreferenced bytes — a migration is the wrong place to reach into storage, which may be S3. * fix(external-media): keep dependent rows and legacy restores intact (#1162) External review found two real defects in the dedupe half of this fix. Dangling rows on SQLite. Every FK into photos declares ON DELETE CASCADE, but PicPeak never sets `PRAGMA foreign_keys = ON` — the codebase says so where it deletes an event (adminEvents/helpers.js:245) — so on every SQLite install the cascade is inert and deleting a duplicate photo left its face embeddings, guest feedback and admin marks behind, pointing at an id that no longer exists. Biometric data outliving its photo is exactly the invariant the event delete goes out of its way to hold. Dependents are now handled explicitly, and moved rather than discarded where they can be: the duplicates were separate tiles in the grid, so a guest's comment or an admin's rating could legitimately be on either, and dropping 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, the same admin's mark, the same transfer's entry — the loser is dropped, because those tables mean one row per (photo, actor). photo_faces is the deliberate exception: both rows were scanned, so moving would duplicate every embedding and split the person clusters built from them. Legacy restores. Suspending FK enforcement does not suspend a UNIQUE index on either engine, so a .picpeak backup taken before migration 186 — carrying exactly the duplicates it removes — would hit the new index mid-batchInsert and roll the whole restore back, after every table had already been emptied. The restore now drops the index for the load and rebuilds it after running the same dedupe. Also: a failed CREATE INDEX is no longer swallowed. Recording the migration as applied without it leaves the install permanently racy, with nothing to trigger a retry. The shared work moves to services/externalPhotoDedupe.js, which the migration and the restore both call. * fix(external-media): reconcile derived state around the dedupe (#1162) Second review round, four more real findings. The index throw did not actually stop anything. run-migrations-safe.js treats 23505 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. A replica inserting one between the dedupe and the index lock is a real rolling-deploy shape, and the outcome was the thing the throw was added to prevent. The index is now verified against the catalog afterwards, and failure raises a code-less error the runner cannot mistake for idempotence. Two people sharing a device were treated as one. photo_feedback carries both guest_identifier (per device) and guest_id (per person, migration 078), and feedbackService scopes by guest_id when present. Keying equivalence on the identifier alone deleted one of two different people's ratings. It now uses the same COALESCE rule the service does. Deleting faces raw left ghost people. event_people counts and centroids are derived from the photo_faces rows being removed, and #1132's separation snapshots hold a copy of each side's centroid — which is why faceProcessor exposes purgePhotoFaces and says it is "called from every photo-deletion path". The dedupe now goes through it. Reparenting feedback left the survivor's totals stale. photos carries denormalized feedback_count / like_count / average_rating / favorite_count and the later reaction and colour counts, so a survivor that now owns feedback kept rendering zero. updatePhotoFeedbackStats takes a trx so the dedupe can recompute on its own connection. Also: the equivalence-key delimiter was a literal NUL byte, which made git classify the whole file as binary and hide its diff. Escaped. * fix(external-media): stop the dedupe discarding half-states (#1162) Third review round. Five findings, four applied. - is_hidden joins the feedback equivalence key. feedbackService lets a moderator-hidden row coexist with the guest's visible replacement and counts only the visible one, so ignoring it deleted the visible row as redundant. - admin marks merge instead of dropping. rating and color_label are written independently, so the same admin can have rated one tile and coloured the other; the loser now hands over any field the winner has no value for. - a survivor that loses the only completed scan is requeued. Otherwise the purge takes the sole embeddings and nothing re-queues it — the photo just silently stops having a face. - view_count and download_count are carried over. Those are real interactions recorded per row, and dropping them quietly lowered the engagement the admin grid shows. Not applied: repointing a category hero can in principle land on a survivor in another category. It needs the two duplicate rows to have been re-categorised apart after the racing import, and the result is a cosmetic hero mismatch that the admin category routes already guard on write. Not worth the extra branch in a data migration. * fix(external-media): invalidate the download zip when duplicates are removed (#1162) External review of the stable twin. Applies to both branches. The pre-built "download everything" archive still contained the duplicate rows the dedupe had just deleted, so guests kept receiving them until something else happened to invalidate it. 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 is not something a migration should start. getZipInfo already treats a cleared record as a cache miss and rebuilds on the next request, so this is the durable half of what invalidate does. The stale object is left in storage for the same reason the duplicates' thumbnails are — a migration is the wrong place to reach into a backend that may be S3. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
bb5d496495
commit
06da1b9f7e
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Migration 186: one row per external file per event (#1162).
|
||||
*
|
||||
* The import route checked for an existing external_relpath and then inserted,
|
||||
* with an fs.stat and a sharp().metadata() call sitting in between — a window
|
||||
* wide enough that two overlapping imports of the same folder each see "not
|
||||
* there" and both insert. Nothing at the storage layer stopped them: 041
|
||||
* created only a NON-unique (event_id, source_origin) index. A reporter's
|
||||
* event ended up holding 8004 rows for 6012 distinct paths.
|
||||
*
|
||||
* So this does two things: clear the duplicates that already exist, and add
|
||||
* the constraint that makes the race unwinnable from here on.
|
||||
*
|
||||
* The work — which row survives, what happens to the guest feedback and admin
|
||||
* marks hanging off the loser, and why the dependent rows are deleted by hand
|
||||
* rather than left to ON DELETE CASCADE — lives in
|
||||
* services/externalPhotoDedupe.js, because a .picpeak restore has to run it
|
||||
* too: the archive carries the photos table verbatim, so a pre-#1162 backup
|
||||
* would otherwise hit the unique index mid-restore and roll the whole thing
|
||||
* back.
|
||||
*
|
||||
* Irreversible by design: down() drops the index but cannot resurrect the
|
||||
* deleted rows. They were never distinct data — the same file counted twice.
|
||||
*
|
||||
* What it does NOT do is delete the duplicates' thumbnail files. Those are
|
||||
* `ext<id>_<name>` keys under the thumbnail root, and a migration is the wrong
|
||||
* place to reach into storage — the backend may be pointed at S3, and a failed
|
||||
* object delete must not fail the schema change. They are left behind as
|
||||
* unreferenced bytes; the storage figures on the dashboard count them, which
|
||||
* is the correct answer to "what is on the disk".
|
||||
*/
|
||||
|
||||
const {
|
||||
dedupeExternalPhotos,
|
||||
createExternalRelpathIndex,
|
||||
dropExternalRelpathIndex,
|
||||
} = require('../../src/services/externalPhotoDedupe');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('photos'))) return;
|
||||
if (!(await knex.schema.hasColumn('photos', 'external_relpath'))) return;
|
||||
|
||||
const removed = await dedupeExternalPhotos(knex);
|
||||
if (removed) {
|
||||
console.log(`186_external_relpath_unique: removed ${removed} duplicate external photo row(s)`);
|
||||
}
|
||||
|
||||
// Deliberately unguarded. Recording this migration as applied without the
|
||||
// index would leave the install permanently racy — the in-flight set only
|
||||
// covers one process, and the route's unique-violation path cannot converge
|
||||
// without a constraint to violate — with nothing to trigger a retry. A
|
||||
// failure here means the dedupe above did not achieve uniqueness, which is
|
||||
// worth stopping the upgrade for.
|
||||
await createExternalRelpathIndex(knex);
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('photos'))) return;
|
||||
await dropExternalRelpathIndex(knex);
|
||||
};
|
||||
Reference in New Issue
Block a user