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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user