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:
@@ -0,0 +1,79 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestWorkerGeneratesVariantsAndCollects(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
svc, cl := newTestService(t, pool)
|
||||
user := createUser(t, ctx, pool)
|
||||
album := createAlbum(t, ctx, pool, user)
|
||||
|
||||
p, err := svc.Upload(ctx, UploadInput{
|
||||
AlbumID: album,
|
||||
UserID: user,
|
||||
Filename: "photo.png",
|
||||
MimeType: "image/png",
|
||||
Data: makePNG(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
|
||||
|
||||
w := NewWorker(svc, time.Second, 10, time.Minute)
|
||||
w.processVariantJobs(ctx)
|
||||
|
||||
refreshed, err := svc.store.GetByID(ctx, p.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("get refreshed: %v", err)
|
||||
}
|
||||
if refreshed.ThumbKey == "" || refreshed.PreviewKey == "" {
|
||||
t.Fatalf("variants not bound: thumb=%q preview=%q", refreshed.ThumbKey, refreshed.PreviewKey)
|
||||
}
|
||||
if err := svc.attachURLs(ctx, refreshed); err != nil {
|
||||
t.Fatalf("attach urls: %v", err)
|
||||
}
|
||||
if refreshed.ThumbURL == "" || refreshed.PreviewURL == "" {
|
||||
t.Fatalf("variant urls not set")
|
||||
}
|
||||
|
||||
var jobs int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM variant_jobs WHERE photo_id = $1`, p.ID).Scan(&jobs); err != nil {
|
||||
t.Fatalf("count jobs: %v", err)
|
||||
}
|
||||
if jobs != 0 {
|
||||
t.Fatalf("variant job not completed: %d left", jobs)
|
||||
}
|
||||
|
||||
objectIDs, err := svc.store.PhotoObjectIDs(ctx, p.ID)
|
||||
if err != nil || len(objectIDs) != 3 {
|
||||
t.Fatalf("expected 3 bound objects, got %v err=%v", objectIDs, err)
|
||||
}
|
||||
|
||||
if err := svc.PermanentDelete(ctx, p.ID, user); err != nil {
|
||||
t.Fatalf("permanent delete: %v", err)
|
||||
}
|
||||
// Run the collector until the objects are gone (bounded to avoid hangs).
|
||||
for attempt := 0; attempt < 100 && countObjects(t, ctx, pool, objectIDs) > 0; attempt++ {
|
||||
w.collectGarbage(ctx)
|
||||
}
|
||||
if n := countObjects(t, ctx, pool, objectIDs); n != 0 {
|
||||
t.Fatalf("%d objects were not collected", n)
|
||||
}
|
||||
}
|
||||
|
||||
func countObjects(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ids []string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM objects WHERE id = ANY($1)`, ids).Scan(&n); err != nil {
|
||||
t.Fatalf("count objects: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user