Skip to content

Faces and centroids when people are deleted or reassigned - #104

Merged
ergofobe merged 4 commits into
mainfrom
issue-87-88-faces
Sep 15, 2026
Merged

ergofobe merged 4 commits into
mainfrom
issue-87-88-faces

Conversation

@ergofobe

Copy link
Copy Markdown
Owner

Two bugs in the same subsystem: what happens to faces and to centroids when a person is deleted or a grouping is corrected.

#87 — an unscoped recount deleted a person and unassigned faces on hidden assets

faces.person_id is on delete set null, so deleting a person does not tidy a row — it detaches every face still pointing at them. refreshCounts deleted whoever had no face on a visible photograph, so a person whose remaining faces all sat in the trash was removed and their faces silently unassigned; restoring the photograph brought the face back belonging to nobody.

Counting and deleting are now different questions. The count still sees only visible photographs; deletion asks whether any face is left at all. A person with none visible is zeroed instead — count and cover both, since a cover left pointing into a trashed photograph would put that crop on the People page.

That defers their end rather than removing it, so two things follow:

  • the retention sweep recounts each owner it destroyed a photograph for. Once the photograph itself is gone there is nothing left to restore, and nothing else would notice. Swallowed and logged per owner: every deletion has committed by then, and letting the owner lock time out would strand the owners behind it and report a sweep that worked as a failure.
  • listPeople moves its face_count > 0 filter into SQL (the discarded set is no longer transient — one production owner has 5,758 people), and findPeopleByName gains the same predicate, so a kept-but-empty person is not offered by search.

Test: a person whose photographs are all out of sight › survives the recount, keeps its faces, and comes back on restore — recounts, then asserts the person survives at face_count = 0, asserts the face row still names them, then un-trashes and asserts count and cover come back. Failed before the change with Received: undefined (the person had been deleted). is deleted only once it has no faces left anywhere and is not offered by a search for their name cover the other two halves; the existing test that asserted the old behaviour is rewritten.

#88 — reassignFaces recomputed only the destination's centroid

Every source person kept the moved face's embedding in its mean and went on attracting faces it no longer contained. Sources are now read before the move and recomputed alongside the destination.

Test: reassigning faces away from a person › recomputes the source centroid, not only the destination — two people built from orthogonal unit embeddings, so the mean is a value the test can spell out. After moving the face on axis 1 away from Anna, her centroid must read [1, 0, …]. Failed before the change with Expected: 0, Received: 0.70710677 — the axis of the face she no longer had. Two more cover unassigning entirely and sources spread across several people.

Locking

reassignFaces takes the owner's key exclusively (lockedForOwner), where naming a destination previously took that person's key alone.

The people losing a face are not known until the faces are read, and a lock key has to be chosen before the transaction that reads them. That is the same bind recordFace hits when the person it needs does not exist yet, and it is answered the same way: only the owner's key covers a set discovered that late, and taking a person key after the transaction has started would break the fixed order the lattice depends on.

So the lattice from #72 is untouched. lockedForPeople still takes the owner key shared and then person keys exclusive in sorted order; lockedForOwner still takes the owner key exclusive and no person key at all. No path acquires the owner key while holding a person key, so no cycle is constructible — this change adds no new key and no new order. The two forms are mutually exclusive on the owner key, which is the serialisation wanted.

It also costs nothing: reassignFaces ends with an unscoped refreshCounts, which takes that same key exclusively, so a reassignment has always serialised on it.

Because the rebuild now runs under an exclusive owner key, recomputeCentroid became recomputeCentroids — one statement over the whole set using pgvector's avg and l2_normalize, rather than reading 512 floats per face back to average them in JavaScript. The size of that hold is the cost.

Also fixed, because this change made them reachable

  • The running mean was sized by the wrong number. face_count is what the library shows and counts only visible photographs; the mean is built from every face. Keeping an out-of-sight person alive made face_count = 0 reachable on a person who still has faces, and updateCentroid reads a count of nothing as a brand new person — so a fresh photograph of someone whose earlier ones were all trashed replaced a fifty-face identity with the single face being filed, permanently. The arithmetic now counts the faces it is actually averaging over. Test: a person out of sight keeps their mean when a new face joins them, which failed at exactly 1.0000000019426158 — the mean was the face just filed.
  • recomputeCentroids writes face_count the way refreshCounts does. The recount runs in a second transaction; since the count now gates the People list and search, whatever the first transaction leaves has to stand on its own. Test: leaves a merge survivor visible when the recount afterwards fails.
  • It is driven from people by a left join, so a person a reassignment emptied is named too and their mean goes null — which is what stops them attracting a face they no longer contain.

Found, not fixed

  • Vaulting a photograph destroys its faces outright. VaultService.moveIn deletes the faces rows in the same transaction that sets vaulted_at, so taking a photograph out of the vault does not restore the grouping — the symmetry An unscoped recount deletes a person and unassigns faces on vaulted or trashed assets #87 establishes for the trash does not hold for the vault. Out of scope here; the comments in this diff were corrected to stop implying otherwise.
  • cover_face_id still crosses the two-transaction gap. recomputeCentroids leaves the cover to the follow-up refreshCounts, so between them a reassigned person's tile can show a crop that now belongs to somebody else, and a merge survivor who was previously empty lists with no thumbnail. The face_count half of that window is closed here; the cover half is the same shape and pre-existing.
  • refreshFace sizes the mean after clearFaces has run. The stored centroid still embodies the unconfirmed faces that delete just removed, so the divisor is a little small. A numerical wobble in an approximation that is documented as one — the previous divisor was wrong in a different direction — not a new class of error.
  • sweepTrash recounts an owner whose account tombstone removeEmptyTombstones is about to remove. Provably wasted work, harmless.

Closes #87
Closes #88

🤖 Generated with Claude Code

ergofobe and others added 4 commits September 15, 2026 17:34
…sides of a reassignment

Two bugs in what happens to faces and centroids when people are deleted or
reassigned.

`faces.person_id` is `on delete set null`, so deleting a person does not tidy a
row — it unassigns every face still pointing at them. `refreshCounts` deleted
whoever had no face on a *visible* photograph, so a person whose remaining faces
all sat in the vault or the trash was removed and their faces silently detached;
restoring the photograph brought the face back belonging to nobody. Counting and
deleting are now different questions: the count still sees only visible
photographs, while deletion asks whether any face is left at all. A person with
none visible is zeroed instead — count and cover both, since a cover left
pointing into the vault would put that crop back on the People page.

That defers their end rather than removing it, so the retention sweep recounts
each owner it destroyed a photograph for: once the photograph itself is gone
there is nothing left to restore, and the person really does stop existing.
`findPeopleByName` gains the `face_count > 0` filter `listPeople` has always
had, so a kept-but-empty person is not offered by search.

`reassignFaces` recomputed only the destination's centroid, so every person a
face was moved off kept that embedding in its mean and went on attracting faces
it no longer contained — the correction moved the face and left the mistake
behind. Sources are now read before the move and recomputed alongside the
destination.

That widens the lock. The people losing a face are not known until the faces are
read, and a lock key has to be chosen before the transaction that reads them, so
the move takes the owner's key exclusively — the same bind `recordFace` hits
when the person it needs does not exist yet, answered the same way. No person
key is taken, so the `lockedForPeople` ordering is untouched and no cycle is
constructible. It costs nothing: the recount on the way out already took that
key, so a reassignment has always serialised on it.

Closes #87
Closes #88

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n one statement

Review of the previous commit. Keeping an out-of-sight person alive made a state
that was previously impossible — `face_count = 0` on a person who still has
faces and a centroid — and the running mean read that column as the size of the
mean it was moving. `updateCentroid` treats a count of nothing as a brand new
person, so a fresh photograph of someone whose earlier ones were all trashed
replaced an identity built from fifty faces with the single face being filed,
permanently: nothing recomputes a centroid on restore. `replaceInCentroid` had
the same short circuit.

The two quantities were never the same. `face_count` is what the library shows
and counts only visible photographs; the mean is built from every face. So the
arithmetic now counts the faces it is actually averaging over, which also
removes the drift that was there whenever any of a person's photographs were in
the trash.

`recomputeCentroid` becomes `recomputeCentroids`, one statement over the whole
set using pgvector's `avg` and `l2_normalize`. Reassignment recomputes every
person it touched while holding the owner's key exclusively, and reading 512
floats per face back to average them in JavaScript makes that hold proportional
to how many photographs those people appear in.

The retention sweep swallows a failed recount per owner: every deletion has
committed by then, and letting the owner lock time out would strand the owners
behind it, skip `removeEmptyTombstones`, and report a sweep that worked as a
failure. The bookkeeping is self-healing — the next sweep, or any trash or
restore, recounts the same owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st in SQL

Second review pass.

`recomputeCentroids` no longer writes `face_count`. That column is the figure
the library shows, counts only visible photographs, and now gates both the
People list and search — so setting it from a count that includes trashed faces
and leaving the correction to a `refreshCounts` in a later transaction put a
person with nothing in view back into both for as long as that recount failed to
run. It gains the owner predicate every other write in this file carries.

`listPeople` filters on the count in SQL rather than afterwards. A person with
nothing in view is kept now rather than deleted, so the fetched-and-discarded
set is no longer transient — against an owner with 5,758 people it is the whole
table per page load.

`centroidSize` moves below the orphan early-return, which returns through
`refreshFace` and does its own count. The sweep's swallowed recount is logged.

Comments corrected: `VaultService.moveIn` deletes a photograph's faces in the
same transaction that hides it, so a vaulted face never exists and only the
trash produces a person with faces but nothing in view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third review pass, on a hole the second one opened.

`mergePeople` and `reassignFaces` commit their work and then recount in a second
transaction, which takes the owner's exclusive lock — the one this codebase has
watched time out under an import. Taking `face_count` out of the centroid
rebuild meant a merge survivor whose own photographs were in the trash was left
at zero until that recount ran, and since the same PR makes the count the gate
for the People list and for search, a recount that failed in between made both
the survivor and the person merged into them vanish.

So `recomputeCentroids` writes the count again, but writes it the way
`refreshCounts` does: only photographs the owner can see. That is the reading
both problems agree on — a survivor with three visible photographs reads as
three, and someone with nothing in view reads as zero.

It is also now driven from `people` by a left join rather than grouped over
`faces`, so a person a reassignment emptied is named too. Their mean goes null,
which is what stops them attracting a face they no longer contain; grouped over
`faces` they were skipped, keeping the very mean the reassignment was
correcting whenever the recount behind it failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ergofobe
ergofobe merged commit 2aef3e0 into main Sep 15, 2026
4 checks passed
@ergofobe
ergofobe deleted the issue-87-88-faces branch September 15, 2026 21:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant