Repository navigation
Faces and centroids when people are deleted or reassigned - #104
Merged
Merged
Conversation
…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>
This was referenced Sep 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_idison delete set null, so deleting a person does not tidy a row — it detaches every face still pointing at them.refreshCountsdeleted 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:
listPeoplemoves itsface_count > 0filter into SQL (the discarded set is no longer transient — one production owner has 5,758 people), andfindPeopleByNamegains 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 atface_count = 0, asserts the face row still names them, then un-trashes and asserts count and cover come back. Failed before the change withReceived: undefined(the person had been deleted).is deleted only once it has no faces left anywhereandis not offered by a search for their namecover the other two halves; the existing test that asserted the old behaviour is rewritten.#88 —
reassignFacesrecomputed only the destination's centroidEvery 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 withExpected: 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
reassignFacestakes 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
recordFacehits 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.
lockedForPeoplestill takes the owner key shared and then person keys exclusive in sorted order;lockedForOwnerstill 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:
reassignFacesends with an unscopedrefreshCounts, which takes that same key exclusively, so a reassignment has always serialised on it.Because the rebuild now runs under an exclusive owner key,
recomputeCentroidbecamerecomputeCentroids— one statement over the whole set using pgvector'savgandl2_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
face_countis what the library shows and counts only visible photographs; the mean is built from every face. Keeping an out-of-sight person alive madeface_count = 0reachable on a person who still has faces, andupdateCentroidreads 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 exactly1.0000000019426158— the mean was the face just filed.recomputeCentroidswritesface_countthe wayrefreshCountsdoes. 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.peopleby 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
VaultService.moveIndeletes thefacesrows in the same transaction that setsvaulted_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_idstill crosses the two-transaction gap.recomputeCentroidsleaves the cover to the follow-uprefreshCounts, 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. Theface_counthalf of that window is closed here; the cover half is the same shape and pre-existing.refreshFacesizes the mean afterclearFaceshas 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.sweepTrashrecounts an owner whose account tombstoneremoveEmptyTombstonesis about to remove. Provably wasted work, harmless.Closes #87
Closes #88
🤖 Generated with Claude Code