feat(photos): separate object storage from metadata layer

Move physical file facts out of the photos table into an objects ledger:

- objects / photo_objects / variant_jobs tables (migration 000008)
- drop storage_key/thumb_key/preview_key/size/width/height/mime_type from
  photos (migration 000009); they are derived via joins to the original object
- album-free storage keys: objects/{id}/{role}.{ext}
- explicit GC via pending/ready/deleting states; objects deleted only when no
  photo references them
- background worker drives variant generation (durable variant_jobs) and GC
- copy/move are metadata-only; storage objects are shared between photos
- remove unused storage.DeletePrefix

Non-destructive to the external JSON API: size/dimensions/mime and URLs are
still returned, just sourced from the object layer.

OpenSpec change: media-object-layer
This commit is contained in:
iu
2026-10-10 23:31:41 +03:00
parent 9effc0fc82
commit 96268ee16c
30 changed files with 1835 additions and 136 deletions
+99 -45
View File
@@ -23,9 +23,14 @@ type Store struct {
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, p.storage_key,
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
p.original_filename, p.size, p.width, p.height, p.mime_type,
// photoColumns selects photo rows together with the keys and physical facts of
// their bound objects (original, thumb, preview) and the original's size,
// dimensions and mime type.
const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
COALESCE(o_o.storage_key, ''),
COALESCE(o_t.storage_key, ''),
COALESCE(o_p.storage_key, ''),
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
u.id, u.name, COALESCE(u.avatar_url, ''),
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
@@ -36,6 +41,14 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, p.storage_k
FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text`
const photoJoins = `
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
LEFT JOIN objects o_o ON o_o.id = po_o.object_id
LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb'
LEFT JOIN objects o_t ON o_t.id = po_t.object_id
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
LEFT JOIN objects o_p ON o_p.id = po_p.object_id`
func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo
var exifText, tagsText string
@@ -76,31 +89,68 @@ func setExif(p *models.Photo, raw string) {
}
}
func (s *Store) Create(ctx context.Context, p *models.Photo) error {
// CreateWithOriginal records a new photo together with its pending original
// object, the original binding and a variant job — all in one transaction. The
// caller stores the bytes and then activates the object. The metadata layer is
// the ledger of record; the storage layer follows.
func (s *Store) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error {
exifJSON, err := json.Marshal(p.EXIF)
if err != nil || len(exifJSON) == 0 {
exifJSON = []byte("{}")
}
return s.pool.QueryRow(ctx,
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb)
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx,
`INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
obj.ID, obj.StorageKey, obj.MimeType, obj.Size, obj.Width, obj.Height, obj.Checksum, ObjectPending); err != nil {
return err
}
if err := tx.QueryRow(ctx,
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
RETURNING created_at`,
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
).Scan(&p.CreatedAt)
p.ID, p.AlbumID, p.UploaderID, p.OriginalFilename, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
).Scan(&p.CreatedAt); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3)`,
p.ID, obj.ID, RoleOriginal); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`INSERT INTO variant_jobs (photo_id) VALUES ($1) ON CONFLICT (photo_id) DO NOTHING`, p.ID); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) SetVariants(ctx context.Context, id, thumbKey, previewKey string) error {
_, err := s.pool.Exec(ctx,
`UPDATE photos SET thumb_key = $2, preview_key = $3 WHERE id = $1`, id, thumbKey, previewKey)
return err
// DeleteUpload rolls back a failed upload: the photo (and its bindings and job)
// plus the pending object row are removed.
func (s *Store) DeleteUpload(ctx context.Context, photoID, objectID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photoID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objectID); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
return scanPhoto(s.pool.QueryRow(ctx,
`SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id
FROM photos p JOIN users u ON u.id = p.uploader_id`+photoJoins+`
WHERE p.id = $1`, id, viewerID))
}
@@ -118,7 +168,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
}
rows, err := s.pool.Query(ctx,
`SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id
FROM photos p JOIN users u ON u.id = p.uploader_id`+photoJoins+`
WHERE p.album_id = $1
AND p.deleted_at IS NULL
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
@@ -181,30 +231,36 @@ func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error {
return nil
}
// Clone creates a new photo row that references the same stored objects as src.
// Clone creates a new photo row in albumID that references the same objects as
// src. No storage operation is performed: the clone shares the physical files.
func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
exifJSON, err := json.Marshal(src.EXIF)
if err != nil || len(exifJSON) == 0 {
exifJSON = []byte("{}")
}
_, err = s.pool.Exec(ctx,
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key, preview_key,
original_filename, size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)`,
id, albumID, uploaderID, src.StorageKey, nullIfEmpty(src.ThumbKey), nullIfEmpty(src.PreviewKey),
src.OriginalFilename, src.Size, src.Width, src.Height, src.MimeType,
src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON))
return err
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx,
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
id, albumID, uploaderID, src.OriginalFilename, src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON)); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role)
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
id, src.ID); err != nil {
return err
}
return tx.Commit(ctx)
}
// StorageKeyRefCount counts photo rows (any state) referencing the stored object.
func (s *Store) StorageKeyRefCount(ctx context.Context, storageKey string) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM photos WHERE storage_key = $1`, storageKey).Scan(&n)
return n, err
}
// HardDelete permanently removes the photo row.
// HardDelete permanently removes the photo row. Bound objects are not touched
// here; the caller marks orphaned objects for collection.
func (s *Store) HardDelete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
if err != nil {
@@ -223,7 +279,7 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
`SELECT `+photoColumns+`, COALESCE(a.title, '')
FROM photos p
JOIN users u ON u.id = p.uploader_id
LEFT JOIN albums a ON a.id = p.album_id
LEFT JOIN albums a ON a.id = p.album_id`+photoJoins+`
WHERE p.deleted_at IS NOT NULL
AND (
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
@@ -362,13 +418,6 @@ func (s *Store) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, err
return ok, err
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
_, _ = s.pool.Exec(ctx,
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
@@ -391,11 +440,16 @@ type backfillRow struct {
StorageKey string
}
// BackfillEXIF re-reads the original object of every photo with empty exif and
// fills the exif/geo fields on the photo.
func (s *Store) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, storage_key FROM photos
WHERE exif IS NULL OR exif = '{}'::jsonb
ORDER BY created_at`)
`SELECT p.id, o.storage_key
FROM photos p
JOIN photo_objects po ON po.photo_id = p.id AND po.role = 'original'
JOIN objects o ON o.id = po.object_id
WHERE p.exif IS NULL OR p.exif = '{}'::jsonb
ORDER BY p.created_at`)
if err != nil {
return 0, err
}