fix(photos): faster large-album loading and album cover lifecycle

Large albums opened slowly and flickered; the default album cover was
also lost in the media-objects refactor.

Large-album loading:
- quantize signed object URLs within a window and send Cache-Control so
  previews are reused instead of re-fetched
- publish photo.updated (not photo.created) when variants are ready, so
  clients update in place without reordering the grid
- worker concurrency + newest-first variant priority; raise default batch

Album cover:
- first uploaded photo becomes the cover when none is set
- clear the cover when the cover photo is trashed or moved out
- "set as album cover" in the photo context menu and actions panel

OpenSpec changes: fix-large-album-load, fix-album-cover
This commit is contained in:
iu
2026-10-11 14:15:59 +03:00
parent 6361e48b20
commit d7a37328f0
40 changed files with 999 additions and 57 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ func TestCopySharesStorageObjects(t *testing.T) {
}
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
w := NewWorker(svc, time.Second, 10, time.Minute)
w := NewWorker(svc, time.Second, 10, time.Minute, 2)
w.processVariantJobs(ctx)
srcIDs, err := svc.store.PhotoObjectIDs(ctx, p.ID)
+2 -2
View File
@@ -106,7 +106,7 @@ func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend
if endpoint == "" {
t.Skip("S3_ENDPOINT not set")
}
signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour)
signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour, 10*time.Minute)
if err != nil {
t.Skipf("signer init: %v", err)
}
@@ -128,7 +128,7 @@ func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend
// storage backend so the full flow can run without external infrastructure.
func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
t.Helper()
signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour)
signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour, 10*time.Minute)
if err != nil {
t.Fatalf("storage init: %v", err)
}
+8 -4
View File
@@ -296,11 +296,15 @@ func (s *Store) ClaimVariantJobs(ctx context.Context, limit int, lease time.Dura
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx,
`SELECT photo_id FROM variant_jobs
WHERE next_attempt_at <= now()
ORDER BY next_attempt_at
`SELECT vj.photo_id FROM variant_jobs vj
JOIN photos p ON p.id = vj.photo_id
WHERE vj.next_attempt_at <= now()
ORDER BY (vj.attempts > 0) ASC,
CASE WHEN vj.attempts = 0 THEN p.created_at END DESC NULLS LAST,
vj.next_attempt_at ASC,
vj.photo_id DESC
LIMIT $1
FOR UPDATE SKIP LOCKED`, limit)
FOR UPDATE OF vj SKIP LOCKED`, limit)
if err != nil {
return nil, err
}
+1
View File
@@ -28,6 +28,7 @@ type Repository interface {
DetachTag(ctx context.Context, photoID, tagID string) error
TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error)
SetCoverIfEmpty(ctx context.Context, albumID, photoID string)
ClearCoverIfMatches(ctx context.Context, albumID, photoID string)
UpdateFilename(ctx context.Context, id, filename string) error
BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error)
+7
View File
@@ -110,6 +110,9 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
return nil, err
}
// The first photo uploaded to an album becomes its default cover.
s.store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID)
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
@@ -298,6 +301,8 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
if err := s.store.SetDeleted(ctx, photoID, true); err != nil {
return err
}
// A trashed cover leaves the album without one.
s.store.ClearCoverIfMatches(ctx, p.AlbumID, photoID)
s.publishDeleted(p)
return nil
}
@@ -451,6 +456,8 @@ func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlb
if err := s.store.UpdateAlbum(ctx, id, targetAlbumID); err != nil {
continue
}
// Moving a cover photo out of its album leaves the album without one.
s.store.ClearCoverIfMatches(ctx, oldAlbum, id)
p.AlbumID = targetAlbumID
s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{
"id": p.ID, "album_id": oldAlbum, "uploader_id": p.UploaderID,
+7 -3
View File
@@ -240,9 +240,13 @@ func (s *SQLiteStore) ClaimVariantJobs(ctx context.Context, limit int, lease tim
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx,
`SELECT photo_id FROM variant_jobs
WHERE next_attempt_at <= $1
ORDER BY next_attempt_at
`SELECT vj.photo_id FROM variant_jobs vj
JOIN photos p ON p.id = vj.photo_id
WHERE vj.next_attempt_at <= $1
ORDER BY (vj.attempts > 0) ASC,
CASE WHEN vj.attempts = 0 THEN p.created_at END DESC NULLS LAST,
vj.next_attempt_at ASC,
vj.photo_id DESC
LIMIT $2`, now, limit)
if err != nil {
return nil, err
+8
View File
@@ -403,6 +403,14 @@ func (s *SQLiteStore) SetCoverIfEmpty(ctx context.Context, albumID, photoID stri
albumID, photoID)
}
// ClearCoverIfMatches removes the album cover only when it currently points at
// photoID, so a concurrent cover change is not clobbered.
func (s *SQLiteStore) ClearCoverIfMatches(ctx context.Context, albumID, photoID string) {
_, _ = s.db.ExecContext(ctx,
`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`,
albumID, photoID)
}
func (s *SQLiteStore) UpdateFilename(ctx context.Context, id, filename string) error {
res, err := s.db.ExecContext(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
if err != nil {
+8
View File
@@ -424,6 +424,14 @@ func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
albumID, photoID)
}
// ClearCoverIfMatches removes the album cover only when it currently points at
// photoID, so a concurrent cover change is not clobbered.
func (s *Store) ClearCoverIfMatches(ctx context.Context, albumID, photoID string) {
_, _ = s.pool.Exec(ctx,
`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`,
albumID, photoID)
}
func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
if err != nil {
+48 -13
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"log/slog"
"sync"
"time"
"galo/internal/auth"
@@ -16,15 +17,16 @@ const maxVariantAttempts = 5
// and the object garbage collector entirely from the state recorded in the
// metadata layer, so it resumes correctly after a restart.
type Worker struct {
svc *Service
interval time.Duration
batch int
pendingTTL time.Duration
lease time.Duration
svc *Service
interval time.Duration
batch int
pendingTTL time.Duration
lease time.Duration
concurrency int
}
// NewWorker builds a worker. Parameters are clamped to sane defaults.
func NewWorker(svc *Service, interval time.Duration, batch int, pendingTTL time.Duration) *Worker {
func NewWorker(svc *Service, interval time.Duration, batch int, pendingTTL time.Duration, concurrency int) *Worker {
if interval <= 0 {
interval = 5 * time.Second
}
@@ -34,12 +36,16 @@ func NewWorker(svc *Service, interval time.Duration, batch int, pendingTTL time.
if pendingTTL <= 0 {
pendingTTL = 10 * time.Minute
}
if concurrency <= 0 {
concurrency = 4
}
return &Worker{
svc: svc,
interval: interval,
batch: batch,
pendingTTL: pendingTTL,
lease: interval * 4,
svc: svc,
interval: interval,
batch: batch,
pendingTTL: pendingTTL,
lease: interval * 4,
concurrency: concurrency,
}
}
@@ -71,9 +77,36 @@ func (w *Worker) processVariantJobs(ctx context.Context) {
slog.Error("claim variant jobs", "error", err)
return
}
for _, id := range ids {
w.forEachConcurrent(ids, func(id string) {
w.generateVariants(ctx, id)
})
}
// forEachConcurrent runs fn over ids with at most w.concurrency functions in
// flight. Generation is CPU-bound (decode + resize + encode), so parallelism
// scales with cores; a concurrency of 1 stays strictly sequential.
func (w *Worker) forEachConcurrent(ids []string, fn func(string)) {
if len(ids) == 0 {
return
}
if w.concurrency <= 1 {
for _, id := range ids {
fn(id)
}
return
}
sem := make(chan struct{}, w.concurrency)
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
sem <- struct{}{}
go func(id string) {
defer wg.Done()
defer func() { <-sem }()
fn(id)
}(id)
}
wg.Wait()
}
func (w *Worker) generateVariants(ctx context.Context, photoID string) {
@@ -162,7 +195,9 @@ func (w *Worker) notify(ctx context.Context, photoID string) {
if err := w.svc.attachURLs(ctx, p); err != nil {
return
}
w.svc.hub.Publish(p.AlbumID, "photo.created", p)
// Variant readiness updates an existing photo; it is not a new photo in the
// album, so clients update it in place instead of inserting at the top.
w.svc.hub.Publish(p.AlbumID, "photo.updated", p)
}
func (w *Worker) fail(ctx context.Context, photoID, msg string) {
+59 -1
View File
@@ -40,9 +40,21 @@ func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Ba
}
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
w := NewWorker(svc, time.Second, 10, time.Minute)
sub := svc.hub.SubscribeAlbum(album)
defer sub.Close()
w := NewWorker(svc, time.Second, 10, time.Minute, 2)
w.processVariantJobs(ctx)
select {
case ev := <-sub.Events():
if ev.Type != "photo.updated" {
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
}
case <-time.After(time.Second):
t.Fatalf("no variant-ready event published")
}
refreshed, err := svc.store.GetByID(ctx, p.ID, "")
if err != nil {
t.Fatalf("get refreshed: %v", err)
@@ -82,6 +94,52 @@ func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Ba
}
}
func TestWorkerConcurrencyPool(t *testing.T) {
w := &Worker{concurrency: 3}
ids := []string{"a", "b", "c", "d", "e"}
started := make(chan struct{}, len(ids))
release := make(chan struct{})
done := make(chan struct{})
go func() {
w.forEachConcurrent(ids, func(string) {
started <- struct{}{}
<-release
})
close(done)
}()
for i := 0; i < 3; i++ {
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatalf("only %d tasks started concurrently, want 3", i)
}
}
select {
case <-started:
t.Fatalf("more than concurrency tasks started concurrently")
case <-time.After(50 * time.Millisecond):
}
close(release)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("pool did not finish")
}
}
func TestWorkerConcurrencySequentialAtOne(t *testing.T) {
w := &Worker{concurrency: 1}
var order []string
w.forEachConcurrent([]string{"a", "b", "c"}, func(id string) {
order = append(order, id)
})
if len(order) != 3 || order[0] != "a" || order[1] != "b" || order[2] != "c" {
t.Fatalf("sequential order = %v, want [a b c]", order)
}
}
func countObjects(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ids []string) int {
t.Helper()
var n int