Skip to content

Commit 920b312

Browse files
authored
Ein Anzeigenbild bekommt seine Zeile in der gemeinsamen Bildtabelle (#2056)
Ein Bild an einer Stellenanzeige hatte Tabelle und Uploader für sich allein und war damit für das Einfrieren unsichtbar, das ein Profilbild einzeln offline nimmt. Diese Freigabe schreibt neben jede Zeile in job_posting_images eine images-Zeile und hält beide bei jedem Schreibvorgang synchron; verbunden sind sie über den Token, den beide ohnehin tragen, nicht über einen zweiten Zeiger. Gelesen wird die neue Zeile noch nirgends, also bleiben jede URL, der autorisierende Proxy und das Formular unverändert, und eine Meldung, die so eine Zeile benennt, wird abgelehnt, solange das Einfrieren für diese Art nicht verdrahtet ist. Als kleinste der vier Arten unter #2015 legt sie die Form fest, die die übrigen kopieren; das Abschalten der alten Tabelle samt Doppelschreiben ist die Freigabe danach. Diesen Text hat ein KI-Agent in meinem Namen geschrieben. Ich weiß, dass das problematisch ist. Claude-Session: https://claude.ai/code/session_01UGPe7bHVS11M1W7fNehSku
1 parent a79c41d commit 920b312

16 files changed

Lines changed: 1306 additions & 215 deletions

File tree

docs/ADMINS.md

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -691,26 +691,30 @@ Run on the server, against the release:
691691
cannot re-derive them; this is their equivalent after an upgrade that
692692
changes the cover size. Needs outbound network and `FETCH_BOOK_METADATA=true`,
693693
and paces itself (3s per cover) to stay inside Open Library's rate limit.
694-
- `bin/vutuv eval "Vutuv.Release.backfill_image_rows()"` — brings every profile
695-
picture and cover uploaded before the shared `images` table into it, and
696-
corrects any row that disagrees with the member's own columns. Moves no file
697-
and changes no URL, so it is safe while the app serves traffic; safe to run
698-
again if it is interrupted (a deploy stopping the slot mid-run leaves every
699-
member it reached already correct). The release that moved every avatar and
700-
cover URL onto those rows (issue #2027) does **not** require it — a picture
701-
with no row is still served from the member's own columns — but run it before
702-
the upgrade after that one, which removes that fallback. Until it is green, a
703-
picture with no row cannot be reported on its own; the profile's Report
704-
covers it meanwhile.
705-
- `bin/vutuv eval "Vutuv.Release.check_image_rows()"` — counts every member
706-
picture against its row and against its file on disk, writing nothing. It
707-
prints one line per kind, names the members behind each mismatch, and
708-
**fails the command** when anything is outstanding, so a deploy script can
709-
stand on its exit status. A picture whose file is missing is the one class
710-
the backfill cannot repair. This is the gate on the later upgrade that
711-
removes the member row's own image columns — do not take that upgrade with a
712-
mismatch outstanding. `mix vutuv.images.backfill --check` is the same check
713-
in a source checkout, and a plain `mix vutuv.images.backfill` ends with it.
694+
- `bin/vutuv eval "Vutuv.Release.backfill_image_rows()"` — brings every picture
695+
uploaded before the shared `images` table into it, and corrects any row that
696+
disagrees with the picture's own columns. Moves no file and changes no URL,
697+
so it is safe while the app serves traffic; safe to run again if it is
698+
interrupted (a deploy stopping the slot mid-run leaves every picture it
699+
reached already correct). It covers profile pictures and covers, and the
700+
kinds that still keep a table of their own — a job-posting picture since the
701+
release that added `--only job_posting_image`. The release that moved every
702+
avatar and cover URL onto those rows (issue #2027) does **not** require it —
703+
a picture with no row is still served from the member's own columns — but run
704+
it before the upgrade after that one, which removes that fallback. Until it
705+
is green, a picture with no row cannot be reported on its own; the profile's
706+
Report covers it meanwhile.
707+
- `bin/vutuv eval "Vutuv.Release.check_image_rows()"` — counts every picture
708+
against its row and against its file on disk, writing nothing. It prints one
709+
line per kind, names the pictures behind each mismatch, and **fails the
710+
command** when anything is outstanding, so a deploy script can stand on its
711+
exit status. A picture whose file is missing is the one class the backfill
712+
cannot repair. This is the gate on the later upgrade that removes the member
713+
row's own image columns, and on each upgrade that retires one of the older
714+
per-kind image tables — do not take those with a mismatch outstanding.
715+
`mix vutuv.images.backfill --check` is the same check in a source checkout,
716+
and a plain `mix vutuv.images.backfill` ends with it. Both take
717+
`--only <kind>` / `only: "<kind>"` to look at one kind alone.
714718
- `bin/vutuv eval 'Vutuv.Release.promote_admin("handle-or-email")'` — grants
715719
admin rights.
716720

docs/architecture/images.md

Lines changed: 126 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,25 @@ no half-pair, and the second run created the other 1,438 and corrected none).
325325
`from: "<user id>"` resumes from a progress line instead of re-reading the
326326
table.
327327

328+
Since #2054 the same two commands cover the gallery kinds as well.
329+
`Vutuv.Images.Backfill` has exactly two shapes — `%{cols: …}`, the truth in
330+
columns on a parent row joined by a pointer, and `%{gallery: …}`, the truth in
331+
a row of the picture's own joined by its token — and everything around them
332+
(the keyset walk, the classes, the repair, the sample, the printing, both
333+
operator paths) is shared. A gallery source builds itself from
334+
`Vutuv.Images.mirror_source/1`, so adding a kind touches nothing in the
335+
backfill at all; its repair is the same `mirror/2` upsert the request path
336+
writes, so create and correct are one statement and need no transaction; and
337+
it reports no `missing_pointer`, which the report leaves out rather than
338+
printing as a zero. The one thing to check when the next kind arrives is the
339+
store's `version_path/2` signature: `Vutuv.PostImageStore` and
340+
`Vutuv.JobPostingImageStore` take the row, `Vutuv.OrganizationImageStore` takes
341+
the token, and `Vutuv.Moderation.ImageSubjects.image_path_arg/2` is the adapter
342+
that already knows.
343+
328344
`Backfill.check/1` (`mix vutuv.images.backfill --check`, or
329345
`bin/vutuv eval "Vutuv.Release.check_image_rows()"`) is the gate before the
330-
cut: it counts every member picture against its row *and* against its file on
346+
cut: it counts every picture against its row *and* against its file on
331347
disk — the quarantine tree while the picture is `"pending"`, the served tree
332348
otherwise — prints one line per kind plus the members behind each class of
333349
mismatch, and **fails the command** when anything is outstanding. Both of
@@ -396,10 +412,115 @@ permission, and the only off switch is moving the bytes out of that tree —
396412
the quarantine tree the AI gate already uses
397413
(`Vutuv.Uploads.quarantine_dir/1`), which nginx has no location for. `:proxy`
398414
means every byte goes through a controller that authorizes the reader first,
399-
so the row is the off switch. Avatars and covers are `:static`; the kinds
400-
#2015 brings are mostly `:proxy`. It raises for a kind nobody has declared,
401-
because a picture that inherits a default is one nobody knows how to take
402-
offline.
415+
so the row is the off switch. Avatars and covers are `:static`, a job-posting
416+
picture is `:proxy`; the remaining kinds #2015 brings are mostly `:proxy` too.
417+
It raises for a kind nobody has declared, because a picture that inherits a
418+
default is one nobody knows how to take offline.
419+
420+
### The gallery kinds (issue #2015)
421+
422+
A post photo, an organization image, a job-posting picture and a review cover
423+
each kept a table and an uploader of their own, so the `image` report type and
424+
the freeze knew one kind only. They move in one release per kind, smallest
425+
first — a **job-posting picture** went first (#2054) precisely to settle the
426+
shape.
427+
428+
**Three of the four are the same shape; the review cover is not.** A post
429+
photo, an organization image and a job-posting picture each have a row of their
430+
own with a `token`, so they move as *gallery* pictures, below. A review's cover
431+
is `cover` / `cover_status` / `cover_moderation` **columns on the review row**
432+
with no token and no table of its own (`Vutuv.Posts.PostReview`), which is the
433+
profile picture's shape, not this one — #2055 lands on `member_columns/0`'s side
434+
of the fence, and `Vutuv.Images.Backfill`'s `%{cols: …}` source is what it
435+
extends. What #2052 and #2053 copy from here is everything below.
436+
437+
**The token is the join key, not a pointer.** #2013 added
438+
`users.avatar_image_id` because a member row had no stable handle of its own;
439+
a gallery row has carried a `token` from the start, `images.token` has been
440+
unique across the whole table since #2013 for exactly this, and a gallery token
441+
is minted once and never re-minted (a re-upload is a different row). So the
442+
`images` row simply repeats the token, there is no reverse pointer to keep in
443+
step, and this kind has no `missing_pointer` class in the check. It also means
444+
the contract deploy drops the old table outright rather than a column at a
445+
time.
446+
447+
**One kind's own column, six shared ones.** `images` gained `job_posting_id`
448+
(nullable — a posting image is uploaded before the posting is saved, and no
449+
other kind has a posting) plus `alt`, `position`, `width`, `height`,
450+
`content_type` and `size_bytes`, taken with the types `job_posting_images`
451+
holds them at (`alt` and `content_type` varchar(255), the rest plain
452+
`integer`). `post_images` and `organization_images` carry those same six under
453+
the same names, so #2052 and #2053 add their own parent column plus whatever
454+
they hold beyond the six — a post photo also brings `caption`, `crop`, seven
455+
EXIF columns and its download flags. The `images_profile_kind_has_owner` check
456+
constraint was **extended** rather than the nullable `user_id` widened, which is
457+
what #2013's migration asked for; the parent index is **partial**
458+
(`WHERE job_posting_id IS NOT NULL`), because every other kind's row is NULL
459+
there and the waste would multiply by four — Postgres proves `IS NOT NULL` from
460+
the cascade's strict `= $1` and uses it (measured: 16 kB against 48 kB).
461+
462+
**The double write is one upsert and one delete.**
463+
`Vutuv.Images.mirror/2` takes one gallery row or a list of them and upserts on
464+
the token — one statement however many, so saving ten pictures costs one — and
465+
`Vutuv.Images.forget/2` deletes by token. `Vutuv.Images.write_mirrored/2` pairs
466+
a write with its mirror in one transaction, which is the door a context's own
467+
insert and update go through. `Vutuv.Images.mirror_source/1` is the **one**
468+
per-kind registry — the copied columns, the source schema, the store — so a
469+
kind cannot be mirrored on the request path and invisible to the backfill,
470+
whose check would otherwise print *"Safe to cut"* for a kind it never looked
471+
at. The names are identical on both sides, so the copy is a per-field
472+
`Map.fetch!/2`: a listed name the source lacks raises. The other direction — a
473+
column added to `job_posting_images` and never listed — nothing can see, so a
474+
drift test compares the schema against the list and fails the build.
475+
476+
Every place that writes a job-posting picture goes through one of those: the
477+
upload and the alt edit (`write_mirrored/2`), the attach and detach on save,
478+
the pending sweep, and the AI gate's approve and reject in
479+
`Vutuv.Moderation.ImageSubjects` — which asks `Vutuv.Images.mirrored?/1` first,
480+
so the next kind's release is one entry in `Vutuv.Images` and nothing there. A
481+
deleted posting or member needs no call: `images.job_posting_id` and
482+
`images.user_id` cascade exactly as the gallery table's own columns do. **The
483+
`frozen_at` column is deliberately outside the upsert's replace list**, so an
484+
ordinary write can never lift a takedown.
485+
486+
**What an interruption leaves.** The upload and the alt edit are atomic. The
487+
attach-and-prune on save is not, and never was — it runs after the save — so a
488+
slot dying between an attach and its mirror leaves a `mismatched_row` (the
489+
parent disagrees) and one between a prune and its `forget/2` leaves an
490+
`orphan_row`. Both are classes the backfill names and repairs; neither is
491+
invisible.
492+
493+
**Nothing reads the new row yet, and that is the whole of the expand half.**
494+
Every URL is the one it was (`/job_posting_images/<token>/<version>.avif`),
495+
`VutuvWeb.JobPostingImageController` still authorizes off `job_posting_images`,
496+
and the edit form still renders from `posting.images` — so no render path pays
497+
a query for the mirror. The consequence to know: `Vutuv.Images.freeze/1`,
498+
`unfreeze/1` and `purge/1` **raise** for such a row rather than half-hiding
499+
it, and `Vutuv.Moderation` refuses a report that names one
500+
(`Vutuv.Images.takedown_ready?/1`), because a case opened on a row nothing
501+
consults would go through an uphold that takes nothing offline. A member
502+
reports the posting instead, which is all there was before the row existed.
503+
504+
**Retiring `job_posting_images` is not this release.** It is the deploy after,
505+
and it has to remove the double write in the same step — the mirror, the
506+
`forget/2` calls, the `@gallery_sources` entry in the backfill and
507+
`Vutuv.Images.mirrored?/1`'s answer — because a migration may drop only what
508+
the currently deployed release has stopped using. Before it: run
509+
`mix vutuv.images.backfill --only job_posting_image` and read the check. The
510+
release in between is the one that moves the proxy, the form and the freeze
511+
onto the row, which is what makes the old table unread in the first place; and
512+
that release, not this one, is where the kind decides whether it wants a
513+
**bridge** the way `member_image/2` has one.
514+
515+
**This kind needs no bridge.** #2027 needed one because it moved every reader
516+
onto the row in the same deploy as the row's first appearance, so a picture the
517+
backfill had not reached would have rendered as no picture at all. Here no
518+
reader has moved: a job-posting picture with no mirror row is served exactly as
519+
before, by its own table, so an installation that never runs the backfill sees
520+
nothing change. The bridge question belongs to the release that moves the
521+
readers, and it will have a simpler answer than #2027's — the old row *is* the
522+
picture, so the fallback is a lookup in the table it is about to leave rather
523+
than four columns read as a row.
403524

404525
### The takedown hold (issue #2012)
405526

docs/architecture/jobs.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@ through the authorizing `/job_posting_images/:token/:version` proxy, purged with
3838
the posting), and `job_posting_likes` / `job_posting_bookmarks` (the shared
3939
Engagement building block, both cascading on posting or user deletion).
4040

41+
**`job_posting_images` is on its way out** (issues #2015 / #2054). Since that
42+
release every write to it also writes a row in the shared `images` table with
43+
`kind: "job_posting_image"`, joined on the `token` both carry, so a picture can
44+
eventually be taken offline on its own by the copyright freeze rather than only
45+
with the whole posting. Nothing reads the new row yet: the proxy, the edit form
46+
and the AI gate all still work off this table, and every URL is unchanged. The
47+
double write is `Vutuv.Images.write_mirrored/2` (the upload and the alt edit,
48+
each atomic with its own write), `Vutuv.Images.mirror/2` (the attach on save)
49+
and `Vutuv.Images.forget/2` (`delete_pending_image/1`, the prune on save,
50+
`sweep_pending_images/1`) — plus `Vutuv.Moderation.ImageSubjects` for the
51+
gate's verdict. A deleted posting or member needs no call
52+
(`images.job_posting_id` and `images.user_id` cascade). **If you add a column
53+
here, add it to `Vutuv.Images.mirror_source/1` and to `images` in a migration**,
54+
or the mirror will not carry it and the deploy that retires this table loses
55+
it; a drift test in `test/vutuv/images/job_posting_images_test.exs` compares
56+
the two and fails the build. The reasoning and the order of the remaining
57+
deploys are in [images.md](images.md).
58+
4159
## Lifecycle
4260

4361
```

lib/mix/tasks/vutuv.images.backfill.ex

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
defmodule Mix.Tasks.Vutuv.Images.Backfill do
2-
@shortdoc "Brings every existing profile picture and cover into the images table"
2+
@shortdoc "Brings every existing picture into the shared images table"
33

44
@moduledoc """
5-
Reconciles every member's profile picture and cover with its row in the
6-
shared `images` table — the **contract** half of issue #2013. See
7-
`Vutuv.Images.Backfill`.
5+
Reconciles every picture with its row in the shared `images` table — the
6+
**contract** half of issue #2013 for profile pictures and covers, and of
7+
#2015 for the kinds that still keep a table of their own (a job-posting
8+
picture since #2054). See `Vutuv.Images.Backfill`.
89
9-
mix vutuv.images.backfill # reconcile, then check
10+
mix vutuv.images.backfill # reconcile every kind, then check
1011
mix vutuv.images.backfill --dry-run # report what would change
1112
mix vutuv.images.backfill --check # check only, write nothing
1213
mix vutuv.images.backfill --only cover
14+
mix vutuv.images.backfill --only job_posting_image
1315
mix vutuv.images.backfill --from 019f0000-0000-7000-8000-000000000000
1416
15-
Moves no file and changes no URL: the four member-row columns stay the source
16-
of truth, and this copies what they say into a row. Idempotent — a run that
17-
is interrupted (a deploy stopping the slot) is simply run again, and every
18-
member it already reached reports `unchanged`.
17+
Moves no file and changes no URL: what a picture already lives in stays the
18+
source of truth (four member-row columns for a profile picture, its own row
19+
for a gallery one), and this copies what it says into a row. Idempotent — a
20+
run that is interrupted (a deploy stopping the slot) is simply run again, and
21+
every picture it already reached reports `unchanged`. `--from` is a member id
22+
for a profile kind and a gallery row id for the rest, so pair it with
23+
`--only`.
1924
2025
Every run ends with the check, which prints what it found and **fails the
2126
command only when something is outstanding** — that non-zero exit is the gate

0 commit comments

Comments
 (0)