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,92 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCopySharesStorageObjects(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
svc, cl := newTestService(t, pool)
|
||||
user := createUser(t, ctx, pool)
|
||||
albumA := createAlbum(t, ctx, pool, user)
|
||||
albumB := createAlbum(t, ctx, pool, user)
|
||||
|
||||
p, err := svc.Upload(ctx, UploadInput{
|
||||
AlbumID: albumA, UserID: user, Filename: "p.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)
|
||||
|
||||
srcIDs, err := svc.store.PhotoObjectIDs(ctx, p.ID)
|
||||
if err != nil || len(srcIDs) != 3 {
|
||||
t.Fatalf("source objects = %v err=%v", srcIDs, err)
|
||||
}
|
||||
|
||||
clones, err := svc.CopyBatch(ctx, []string{p.ID}, user, albumB)
|
||||
if err != nil || len(clones) != 1 {
|
||||
t.Fatalf("copy: %v len=%d", err, len(clones))
|
||||
}
|
||||
clone := clones[0]
|
||||
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, clone.ID) })
|
||||
|
||||
cloneIDs, err := svc.store.PhotoObjectIDs(ctx, clone.ID)
|
||||
if err != nil || len(cloneIDs) != 3 {
|
||||
t.Fatalf("clone objects = %v err=%v", cloneIDs, err)
|
||||
}
|
||||
if !sameSet(srcIDs, cloneIDs) {
|
||||
t.Fatalf("clone does not share objects: src=%v clone=%v", srcIDs, cloneIDs)
|
||||
}
|
||||
for _, id := range cloneIDs {
|
||||
if n, err := svc.store.ObjectRefCount(ctx, id); err != nil || n != 2 {
|
||||
t.Fatalf("object %s ref count = %d err=%v, want 2", id, n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Move the source into the same album; the storage key and shared objects
|
||||
// must be untouched.
|
||||
beforeKey := p.StorageKey
|
||||
moved, err := svc.MoveBatch(ctx, []string{p.ID}, user, albumB)
|
||||
if err != nil || moved != 1 {
|
||||
t.Fatalf("move: %v moved=%d", err, moved)
|
||||
}
|
||||
after, err := svc.store.GetByID(ctx, p.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("get after move: %v", err)
|
||||
}
|
||||
if after.AlbumID != albumB {
|
||||
t.Fatalf("photo not moved: album=%q", after.AlbumID)
|
||||
}
|
||||
if after.StorageKey != beforeKey {
|
||||
t.Fatalf("move changed storage key: %q -> %q", beforeKey, after.StorageKey)
|
||||
}
|
||||
for _, id := range cloneIDs {
|
||||
if n, err := svc.store.ObjectRefCount(ctx, id); err != nil || n != 2 {
|
||||
t.Fatalf("object %s ref count after move = %d err=%v, want 2", id, n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sameSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
m := make(map[string]int, len(a))
|
||||
for _, v := range a {
|
||||
m[v]++
|
||||
}
|
||||
for _, v := range b {
|
||||
m[v]--
|
||||
if m[v] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user