Commit Graph

2 Commits

Author SHA1 Message Date
Paul Nothaft a7b74bcd87 fix(external-media): store external paths from the media root (#1163) (#1168)
* fix(external-media): store external paths from the media root (#1163)

Importing a second folder into an event silently invalidated every photo
already in it. photos.external_relpath was stored relative to
events.external_path, and every import overwrites that column — so the older
rows were rebased onto the new folder and their originals resolved to paths
that do not exist.

Nothing errored, and the grid still looked intact: thumbnails are written to
local storage during the import while the base path is still correct. Only
what needs the original broke — preview generation, the lightbox, downloads —
which presents as a gallery that looks slow rather than one that is broken.
The reporter had 7547 of 8004 rows pointing into the void and spent a while
chasing it as a CPU problem.

- external_relpath is now relative to EXTERNAL_MEDIA_ROOT, so a row is
  self-describing and nothing an admin does to the event afterwards can move
  an already-imported photo.
- migration 187 folds each event's base path into its rows. Where the current
  resolution is missing on disk it walks up the base path for an ancestor
  under which the file IS there — the already-rebased case — and where it
  finds nothing it leaves the row resolving exactly where it resolves today.
  Skipped entirely when the media root is unmounted, since every file looks
  missing then.
- the fold also runs after a .picpeak restore: knex_migrations is excluded
  from the archive, so a pre-#1163 backup would otherwise land base-relative
  rows on a migrated instance.
- drops the duplicate-leaf-segment guess in photoResolver. It papered over
  this same double-prefixing and actively corrupts a root-relative path whose
  first segment legitimately repeats (base 'Trip', row 'Trip/x.jpg').

* fix(external-media): verify provenance and fold atomically (#1163)

External review found four real defects in the fold.

Repair could adopt the wrong file. Existence alone was accepted as proof that
an ancestor candidate was the row's original — so a row whose file an admin
simply deleted would adopt any same-named file one directory up (base
`Trip/Sub`, relpath `photo.jpg`, an unrelated `Trip/photo.jpg`), and downloads
would then serve a different photo. Worse than a dead link. An ancestor must
now also match photos.size_bytes, which the import recorded from the very file
the row describes; rows carrying no size are never repaired from an ancestor.
The CURRENT base is still accepted on existence alone, because nothing is
being inferred there — that is where the row already resolves.

The fold was not atomic. Every UPDATE committed independently and the marker
came last, so a process killed mid-fold left converted and unconverted rows
with no marker — and the next run folded the converted ones a second time,
putting every original one directory deeper with no undo. Probing is now a
read-only first phase (so a slow cold NAS does not hold a write transaction
open), and every rewrite plus the marker commit together.

Failed rewrites certified a partial conversion. The per-row catch counted any
error as a collision, carried on, and wrote the marker anyway — leaving that
row in the old format for a resolver that now reads it differently. It also
could not tell a genuine duplicate from a SQLite lock or I/O fault. Target
collisions are now resolved in the planning phase, where they can be
identified honestly, and a write that fails rolls the whole fold back.

Restore ordering. The fold ran after the face requeue, with the worker live —
so a worker could claim an external row while it was still base-relative,
resolve it against the wrong path, and burn it to 'failed', a state only an
explicit Re-scan clears. The fold now runs first, for the same reason the
requeue already sat after restoreFiles.

* fix(external-media): close the fold's remaining stranding paths (#1163)

Second review round, three findings.

A collision loser was left stranded. When an event imported one file through
both `Trip` and `Trip/Sub`, two rows folded to the same path and the loser was
skipped — keeping a base-relative value that the root-only resolver then reads
as `<root>/<relpath>`, permanently wrong, with the marker claiming conversion
was complete. It is a duplicate by construction, so it now goes through
migration 186's deleteDuplicatePhotos, which reparents its feedback and marks
and reconciles the face clusters instead of orphaning them. This branch is
rebased onto #1162 for that helper.

The other restore path had the same face-ordering bug. restoreService queued
face scans in step 6, before step 7c runs pending migrations — so a pre-187
full or database restore handed the live worker rows whose paths were still
event-relative, and it burned them to 'failed', a state the later fold does
not clear. The requeue now happens after the migrations, where the files
already are.

A failed conversion was reported as a clean restore. The fold is
transactional, so a failure leaves every external path in the old format under
a resolver that reads from the media root — every original unreachable. It was
logged as a warning and the restore returned success. It now returns
externalPathsConverted/externalPathError, and suppresses the face requeue,
which would otherwise mark those photos failed on top.

* fix(external-media): make the fold safe against its own intermediate states (#1163)

Third review round, four findings.

A one-pass rewrite could collide with itself. Every FINAL path is distinct,
but a final value can equal another row's CURRENT one — `photo.jpg` repairing
to `Trip/photo.jpg` while the row already holding `Trip/photo.jpg` folds
deeper — so the update violated migration 186's unique index halfway through.
On Postgres that surfaces as 23505, which run-migrations-safe.js mistakes for
"schema already exists" and records 187 as applied after the rollback, leaving
every path unconverted with nothing to retry. Rows now park on a per-row
staging value first, and migration 187 re-throws without the driver's code so
the runner cannot misread it.

The bulk update targeted rows the plan never saw. Phase 1 probes outside the
transaction and can run for minutes; an import finishing in that window
inserts an already root-relative row, and `where event_id` prefixed it again
with the stale base. It now updates by the ids phase 1 captured.

The restore UI never showed a conversion failure. The API carried
externalPathsConverted, but PicpeakBackupCard neither declared nor read it and
showed a green success either way — so an admin whose external originals were
all unreachable was told the restore worked.

restoreService requeued faces even when the migrations failed. The step 7c
catch is deliberately non-fatal, so a pre-187 backup whose fold never ran
still handed the live worker event-relative paths to burn to 'failed'.

* fix(external-media): the fold's staging value must be storable on Postgres (#1163)

External review of the stable twin caught this, and it was on both branches.

The two-pass rewrite parks each row on a temporary value, and that value was
written with a leading NUL. SQLite stores NUL in TEXT without complaint;
Postgres rejects it outright — "invalid byte sequence for encoding UTF8: 0x00"
— so migration 187 rolled back on exactly the installs that need the two-pass
repair, and only on the engine most of them run. Restores hit the same wall
and reported the conversion as failed.

The prefix is ordinary text now. It still cannot collide with a real relative
path and is still obviously wrong if a crash leaves one behind.

Adds a gated Postgres test alongside the existing picpeakRestorePg one,
because a SQLite-only suite structurally cannot catch this class: restoring
the NUL makes exactly the two-pass repair case fail with that error, and
nothing else.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:36:45 +02:00
Paul Nothaft 06da1b9f7e fix(external-media): one row per external file per event (#1162) (#1167)
* 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 <paul@MacStudio-von-Paul.local>
2026-08-26 08:28:48 +02:00