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
+10 -2
View File
@@ -28,6 +28,11 @@ FS_ROOT=./data
URL_SECRET= URL_SECRET=
# Lifetime of signed object URLs # Lifetime of signed object URLs
OBJECT_URL_TTL=1h OBJECT_URL_TTL=1h
# Quantizes URL expiry so the same object keeps an identical URL within a window
# (capped at OBJECT_URL_TTL), letting clients reuse cached previews
OBJECT_URL_SIGN_WINDOW=10m
# Cache-Control max-age for object responses (blobs are immutable)
OBJECT_CACHE_MAX_AGE=1h
# Auth. The token signing secret is generated on first start and stored in the # Auth. The token signing secret is generated on first start and stored in the
# metadata layer (app_settings); it is not configured here. # metadata layer (app_settings); it is not configured here.
@@ -42,9 +47,12 @@ APP_BASE_URL=http://localhost:8080
SSE_HEARTBEAT_INTERVAL=25s SSE_HEARTBEAT_INTERVAL=25s
SSE_CLIENT_BUFFER=64 SSE_CLIENT_BUFFER=64
# Background worker (variant generation + object GC) # Background worker (variant generation + object GC).
# Throughput is roughly WORKER_BATCH per WORKER_INTERVAL; WORKER_CONCURRENCY caps
# how many of those jobs are processed in parallel (CPU-bound, scales with cores).
WORKER_INTERVAL=5s WORKER_INTERVAL=5s
WORKER_BATCH=10 WORKER_BATCH=100
WORKER_CONCURRENCY=4
WORKER_PENDING_TTL=10m WORKER_PENDING_TTL=10m
# Logging. LOG_LEVEL=debug adds per-request access logs and expected 4xx errors. # Logging. LOG_LEVEL=debug adds per-request access logs and expected 4xx errors.
+18 -1
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"strings" "strings"
@@ -59,7 +60,7 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
membersSvc := members.NewService(repos.Members, repos.Albums, hub, notifSvc) membersSvc := members.NewService(repos.Members, repos.Albums, hub, notifSvc)
photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub) photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub)
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL) photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL, cfg.Worker.Concurrency)
socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc) socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc)
@@ -198,20 +199,36 @@ func (s *Server) health(c *gin.Context) {
func (s *Server) serveObject(c *gin.Context) { func (s *Server) serveObject(c *gin.Context) {
key := strings.TrimPrefix(c.Param("key"), "/") key := strings.TrimPrefix(c.Param("key"), "/")
// Objects are immutable (one key maps to one blob), so successful responses
// are safe to cache. The header is dropped on error paths below.
c.Writer.Header().Set("Cache-Control", cacheControlValue(s.cfg.Storage.CacheMaxAge))
switch err := s.storage.ServeObject(c.Writer, c.Request, key); { switch err := s.storage.ServeObject(c.Writer, c.Request, key); {
case err == nil: case err == nil:
return return
case errors.Is(err, storage.ErrSignedURLInvalid): case errors.Is(err, storage.ErrSignedURLInvalid):
c.Writer.Header().Del("Cache-Control")
c.Status(http.StatusForbidden) c.Status(http.StatusForbidden)
case errors.Is(err, storage.ErrObjectNotFound): case errors.Is(err, storage.ErrObjectNotFound):
c.Writer.Header().Del("Cache-Control")
httpx.RespondError(c, httpx.NotFound("object_not_found", "object not found")) httpx.RespondError(c, httpx.NotFound("object_not_found", "object not found"))
case errors.Is(err, storage.ErrRangeNotSatisfiable): case errors.Is(err, storage.ErrRangeNotSatisfiable):
c.Writer.Header().Del("Cache-Control")
c.Status(http.StatusRequestedRangeNotSatisfiable) c.Status(http.StatusRequestedRangeNotSatisfiable)
default: default:
c.Writer.Header().Del("Cache-Control")
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
} }
} }
// cacheControlValue renders the Cache-Control value for object responses.
// Non-positive values fall back to a one-hour freshness window.
func cacheControlValue(maxAge time.Duration) string {
if maxAge <= 0 {
maxAge = time.Hour
}
return fmt.Sprintf("private, max-age=%d", int(maxAge.Seconds()))
}
func (s *Server) albumSSE(c *gin.Context) { func (s *Server) albumSSE(c *gin.Context) {
userID := auth.UserID(c) userID := auth.UserID(c)
albumID := c.Param("id") albumID := c.Param("id")
+1 -1
View File
@@ -16,7 +16,7 @@ import (
func mustSigner(t *testing.T) *storage.Signer { func mustSigner(t *testing.T) *storage.Signer {
t.Helper() t.Helper()
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour) signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
if err != nil { if err != nil {
t.Fatalf("signer: %v", err) t.Fatalf("signer: %v", err)
} }
+7 -1
View File
@@ -22,7 +22,7 @@ import (
func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) { func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
t.Helper() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour) signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
if err != nil { if err != nil {
t.Fatalf("signer: %v", err) t.Fatalf("signer: %v", err)
} }
@@ -70,6 +70,9 @@ func TestServeObjectRoute(t *testing.T) {
if rec.Body.String() != "0123456789" { if rec.Body.String() != "0123456789" {
t.Fatalf("body = %q", rec.Body.String()) t.Fatalf("body = %q", rec.Body.String())
} }
if cc := rec.Header().Get("Cache-Control"); cc == "" || !strings.Contains(cc, "max-age=") {
t.Fatalf("cache-control = %q, want max-age directive", cc)
}
}) })
t.Run("range", func(t *testing.T) { t.Run("range", func(t *testing.T) {
@@ -89,6 +92,9 @@ func TestServeObjectRoute(t *testing.T) {
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code) t.Fatalf("status = %d, want 403", rec.Code)
} }
if cc := rec.Header().Get("Cache-Control"); cc != "" {
t.Fatalf("cache-control = %q, want empty on error", cc)
}
}) })
t.Run("forged", func(t *testing.T) { t.Run("forged", func(t *testing.T) {
+2 -2
View File
@@ -149,7 +149,7 @@ func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing.
} }
func TestShareFlowWithFS(t *testing.T) { func TestShareFlowWithFS(t *testing.T) {
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour) signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
if err != nil { if err != nil {
t.Fatalf("signer: %v", err) t.Fatalf("signer: %v", err)
} }
@@ -178,7 +178,7 @@ func TestShareFlowWithS3(t *testing.T) {
if endpoint == "" { if endpoint == "" {
t.Skip("S3_ENDPOINT not set") t.Skip("S3_ENDPOINT not set")
} }
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour) signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
if err != nil { if err != nil {
t.Skipf("signer init: %v", err) t.Skipf("signer init: %v", err)
} }
+22 -1
View File
@@ -51,6 +51,7 @@ type Worker struct {
Interval time.Duration Interval time.Duration
Batch int Batch int
PendingTTL time.Duration PendingTTL time.Duration
Concurrency int
} }
type Storage struct { type Storage struct {
@@ -58,6 +59,8 @@ type Storage struct {
FSRoot string FSRoot string
URLSecret string URLSecret string
URLTTL time.Duration URLTTL time.Duration
URLSignWindow time.Duration
CacheMaxAge time.Duration
} }
type Database struct { type Database struct {
@@ -103,6 +106,16 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("invalid OBJECT_URL_TTL: %w", err) return nil, fmt.Errorf("invalid OBJECT_URL_TTL: %w", err)
} }
urlSignWindow, err := time.ParseDuration(getEnv("OBJECT_URL_SIGN_WINDOW", "10m"))
if err != nil {
return nil, fmt.Errorf("invalid OBJECT_URL_SIGN_WINDOW: %w", err)
}
cacheMaxAge, err := time.ParseDuration(getEnv("OBJECT_CACHE_MAX_AGE", "1h"))
if err != nil {
return nil, fmt.Errorf("invalid OBJECT_CACHE_MAX_AGE: %w", err)
}
heartbeat, err := time.ParseDuration(getEnv("SSE_HEARTBEAT_INTERVAL", "25s")) heartbeat, err := time.ParseDuration(getEnv("SSE_HEARTBEAT_INTERVAL", "25s"))
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid SSE_HEARTBEAT_INTERVAL: %w", err) return nil, fmt.Errorf("invalid SSE_HEARTBEAT_INTERVAL: %w", err)
@@ -123,7 +136,7 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("invalid WORKER_INTERVAL: %w", err) return nil, fmt.Errorf("invalid WORKER_INTERVAL: %w", err)
} }
workerBatch, err := strconv.Atoi(getEnv("WORKER_BATCH", "10")) workerBatch, err := strconv.Atoi(getEnv("WORKER_BATCH", "100"))
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid WORKER_BATCH: %w", err) return nil, fmt.Errorf("invalid WORKER_BATCH: %w", err)
} }
@@ -133,6 +146,11 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("invalid WORKER_PENDING_TTL: %w", err) return nil, fmt.Errorf("invalid WORKER_PENDING_TTL: %w", err)
} }
workerConcurrency, err := strconv.Atoi(getEnv("WORKER_CONCURRENCY", "4"))
if err != nil {
return nil, fmt.Errorf("invalid WORKER_CONCURRENCY: %w", err)
}
logLevel := getEnv("LOG_LEVEL", "info") logLevel := getEnv("LOG_LEVEL", "info")
if _, err := logging.ParseLevel(logLevel); err != nil { if _, err := logging.ParseLevel(logLevel); err != nil {
return nil, err return nil, err
@@ -164,6 +182,8 @@ func Load() (*Config, error) {
FSRoot: getEnv("FS_ROOT", "./data"), FSRoot: getEnv("FS_ROOT", "./data"),
URLSecret: getEnv("URL_SECRET", ""), URLSecret: getEnv("URL_SECRET", ""),
URLTTL: urlTTL, URLTTL: urlTTL,
URLSignWindow: urlSignWindow,
CacheMaxAge: cacheMaxAge,
}, },
Database: Database{ Database: Database{
Backend: getEnv("DB_BACKEND", "sqlite"), Backend: getEnv("DB_BACKEND", "sqlite"),
@@ -185,6 +205,7 @@ func Load() (*Config, error) {
Interval: workerInterval, Interval: workerInterval,
Batch: workerBatch, Batch: workerBatch,
PendingTTL: workerPendingTTL, PendingTTL: workerPendingTTL,
Concurrency: workerConcurrency,
}, },
Log: Log{ Log: Log{
Level: logLevel, Level: logLevel,
+143 -4
View File
@@ -43,6 +43,7 @@ type stack struct {
photos *photos.Service photos *photos.Service
albums *albums.Service albums *albums.Service
auth *auth.Service auth *auth.Service
hub *sse.Hub
} }
// openStack opens a migrated SQLite database at path and builds the services. // openStack opens a migrated SQLite database at path and builds the services.
@@ -61,7 +62,7 @@ func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
} }
repos := store.NewSQLite(database) repos := store.NewSQLite(database)
hub := sse.NewHub(1) hub := sse.NewHub(1)
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour) signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
if err != nil { if err != nil {
t.Fatalf("signer: %v", err) t.Fatalf("signer: %v", err)
} }
@@ -72,7 +73,145 @@ func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
authSvc := auth.NewService(repos.Auth, auth.NewTokenManager("secret", 15*time.Minute, time.Hour)) authSvc := auth.NewService(repos.Auth, auth.NewTokenManager("secret", 15*time.Minute, time.Hour))
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, blob) albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, blob)
photosSvc := photos.NewService(repos.Photos, albumsSvc, blob, hub) photosSvc := photos.NewService(repos.Photos, albumsSvc, blob, hub)
return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc}, func() { database.Close() } return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc, hub: hub}, func() { database.Close() }
}
func TestUploadSetsDefaultCover(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "cover@test.local", "password123", "C")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Cover"})
if err != nil {
t.Fatalf("create album: %v", err)
}
first, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload first: %v", err)
}
got, _, err := st.albums.Get(ctx, album.ID, user.ID)
if err != nil {
t.Fatalf("get album: %v", err)
}
if got.CoverPhotoID == nil || *got.CoverPhotoID != first.ID {
t.Fatalf("cover = %v, want first photo %s", got.CoverPhotoID, first.ID)
}
second, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "b.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload second: %v", err)
}
got, _, err = st.albums.Get(ctx, album.ID, user.ID)
if err != nil {
t.Fatalf("get album after second: %v", err)
}
if got.CoverPhotoID == nil || *got.CoverPhotoID != first.ID {
t.Fatalf("cover changed to %v, want still first %s (second=%s)", got.CoverPhotoID, first.ID, second.ID)
}
}
func TestCoverClearedWhenCoverPhotoLeaves(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "clear@test.local", "password123", "C")
if err != nil {
t.Fatalf("register: %v", err)
}
albumA, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "A"})
if err != nil {
t.Fatalf("create A: %v", err)
}
albumB, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "B"})
if err != nil {
t.Fatalf("create B: %v", err)
}
cover, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: albumA.ID, UserID: user.ID, Filename: "cover.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload cover: %v", err)
}
// Trashing the cover clears it.
if err := st.photos.Delete(ctx, cover.ID, user.ID); err != nil {
t.Fatalf("delete cover: %v", err)
}
goal, _, _ := st.albums.Get(ctx, albumA.ID, user.ID)
if goal.CoverPhotoID != nil {
t.Fatalf("cover not cleared on delete: %v", goal.CoverPhotoID)
}
// A fresh upload becomes the cover, and moving it out clears it again.
next, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: albumA.ID, UserID: user.ID, Filename: "next.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload next: %v", err)
}
goal, _, _ = st.albums.Get(ctx, albumA.ID, user.ID)
if goal.CoverPhotoID == nil || *goal.CoverPhotoID != next.ID {
t.Fatalf("cover = %v, want %s", goal.CoverPhotoID, next.ID)
}
if _, err := st.photos.MoveBatch(ctx, []string{next.ID}, user.ID, albumB.ID); err != nil {
t.Fatalf("move: %v", err)
}
goal, _, _ = st.albums.Get(ctx, albumA.ID, user.ID)
if goal.CoverPhotoID != nil {
t.Fatalf("cover not cleared on move: %v", goal.CoverPhotoID)
}
}
func TestWorkerPublishesPhotoUpdated(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute, 2)
wctx, cancel := context.WithCancel(ctx)
defer cancel()
go worker.Run(wctx)
user, _, err := st.auth.Register(ctx, "evt@test.local", "password123", "E")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Trip"})
if err != nil {
t.Fatalf("create album: %v", err)
}
sub := st.hub.SubscribeAlbum(album.ID)
defer sub.Close()
if _, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t),
}); err != nil {
t.Fatalf("upload: %v", err)
}
select {
case ev := <-sub.Events():
if ev.Type != "photo.updated" {
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
}
case <-time.After(5 * time.Second):
t.Fatalf("no photo.updated event published for generated variants")
}
} }
func TestSQLiteEndToEndWithRestart(t *testing.T) { func TestSQLiteEndToEndWithRestart(t *testing.T) {
@@ -81,7 +220,7 @@ func TestSQLiteEndToEndWithRestart(t *testing.T) {
blobs := t.TempDir() blobs := t.TempDir()
st, closeDB := openStack(t, path, blobs) st, closeDB := openStack(t, path, blobs)
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute) worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute, 2)
wctx, cancel := context.WithCancel(ctx) wctx, cancel := context.WithCancel(ctx)
go worker.Run(wctx) go worker.Run(wctx)
@@ -127,7 +266,7 @@ func TestSQLiteEndToEndWithRestart(t *testing.T) {
st2, close2 := openStack(t, path, blobs) st2, close2 := openStack(t, path, blobs)
defer close2() defer close2()
worker2 := photos.NewWorker(st2.photos, 20*time.Millisecond, 10, time.Minute) worker2 := photos.NewWorker(st2.photos, 20*time.Millisecond, 10, time.Minute, 2)
wctx2, cancel2 := context.WithCancel(ctx) wctx2, cancel2 := context.WithCancel(ctx)
defer cancel2() defer cancel2()
go worker2.Run(wctx2) go worker2.Run(wctx2)
+1 -1
View File
@@ -22,7 +22,7 @@ func TestCopySharesStorageObjects(t *testing.T) {
} }
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) }) 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) w.processVariantJobs(ctx)
srcIDs, err := svc.store.PhotoObjectIDs(ctx, p.ID) 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 == "" { if endpoint == "" {
t.Skip("S3_ENDPOINT not set") 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 { if err != nil {
t.Skipf("signer init: %v", err) 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. // storage backend so the full flow can run without external infrastructure.
func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) { func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
t.Helper() 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 { if err != nil {
t.Fatalf("storage init: %v", err) 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) }() defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, rows, err := tx.Query(ctx,
`SELECT photo_id FROM variant_jobs `SELECT vj.photo_id FROM variant_jobs vj
WHERE next_attempt_at <= now() JOIN photos p ON p.id = vj.photo_id
ORDER BY next_attempt_at 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 LIMIT $1
FOR UPDATE SKIP LOCKED`, limit) FOR UPDATE OF vj SKIP LOCKED`, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+1
View File
@@ -28,6 +28,7 @@ type Repository interface {
DetachTag(ctx context.Context, photoID, tagID string) error DetachTag(ctx context.Context, photoID, tagID string) error
TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error)
SetCoverIfEmpty(ctx context.Context, albumID, photoID string) SetCoverIfEmpty(ctx context.Context, albumID, photoID string)
ClearCoverIfMatches(ctx context.Context, albumID, photoID string)
UpdateFilename(ctx context.Context, id, filename string) error UpdateFilename(ctx context.Context, id, filename string) error
BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, 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 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 { if err := s.attachURLs(ctx, p); err != nil {
return nil, err 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 { if err := s.store.SetDeleted(ctx, photoID, true); err != nil {
return err return err
} }
// A trashed cover leaves the album without one.
s.store.ClearCoverIfMatches(ctx, p.AlbumID, photoID)
s.publishDeleted(p) s.publishDeleted(p)
return nil 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 { if err := s.store.UpdateAlbum(ctx, id, targetAlbumID); err != nil {
continue continue
} }
// Moving a cover photo out of its album leaves the album without one.
s.store.ClearCoverIfMatches(ctx, oldAlbum, id)
p.AlbumID = targetAlbumID p.AlbumID = targetAlbumID
s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{ s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{
"id": p.ID, "album_id": oldAlbum, "uploader_id": p.UploaderID, "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() }() defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, rows, err := tx.QueryContext(ctx,
`SELECT photo_id FROM variant_jobs `SELECT vj.photo_id FROM variant_jobs vj
WHERE next_attempt_at <= $1 JOIN photos p ON p.id = vj.photo_id
ORDER BY next_attempt_at 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) LIMIT $2`, now, limit)
if err != nil { if err != nil {
return nil, err return nil, err
+8
View File
@@ -403,6 +403,14 @@ func (s *SQLiteStore) SetCoverIfEmpty(ctx context.Context, albumID, photoID stri
albumID, photoID) 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 { 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) res, err := s.db.ExecContext(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
if err != nil { if err != nil {
+8
View File
@@ -424,6 +424,14 @@ func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
albumID, photoID) 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 { 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) tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
if err != nil { if err != nil {
+38 -3
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"sync"
"time" "time"
"galo/internal/auth" "galo/internal/auth"
@@ -21,10 +22,11 @@ type Worker struct {
batch int batch int
pendingTTL time.Duration pendingTTL time.Duration
lease time.Duration lease time.Duration
concurrency int
} }
// NewWorker builds a worker. Parameters are clamped to sane defaults. // 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 { if interval <= 0 {
interval = 5 * time.Second interval = 5 * time.Second
} }
@@ -34,12 +36,16 @@ func NewWorker(svc *Service, interval time.Duration, batch int, pendingTTL time.
if pendingTTL <= 0 { if pendingTTL <= 0 {
pendingTTL = 10 * time.Minute pendingTTL = 10 * time.Minute
} }
if concurrency <= 0 {
concurrency = 4
}
return &Worker{ return &Worker{
svc: svc, svc: svc,
interval: interval, interval: interval,
batch: batch, batch: batch,
pendingTTL: pendingTTL, pendingTTL: pendingTTL,
lease: interval * 4, lease: interval * 4,
concurrency: concurrency,
} }
} }
@@ -71,9 +77,36 @@ func (w *Worker) processVariantJobs(ctx context.Context) {
slog.Error("claim variant jobs", "error", err) slog.Error("claim variant jobs", "error", err)
return return
} }
for _, id := range ids { w.forEachConcurrent(ids, func(id string) {
w.generateVariants(ctx, id) 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) { 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 { if err := w.svc.attachURLs(ctx, p); err != nil {
return 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) { 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) }) 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) 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, "") refreshed, err := svc.store.GetByID(ctx, p.ID, "")
if err != nil { if err != nil {
t.Fatalf("get refreshed: %v", err) 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 { func countObjects(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ids []string) int {
t.Helper() t.Helper()
var n int var n int
@@ -382,6 +382,18 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
t.Fatalf("cover not set: %+v", al.CoverPhotoID) t.Fatalf("cover not set: %+v", al.CoverPhotoID)
} }
// Clearing the cover only affects a matching photo id.
r.Photos.ClearCoverIfMatches(ctx, a.ID, newID())
al, _ = r.Albums.GetByID(ctx, a.ID)
if al.CoverPhotoID == nil || *al.CoverPhotoID != p.ID {
t.Fatalf("cover cleared by non-matching id: %+v", al.CoverPhotoID)
}
r.Photos.ClearCoverIfMatches(ctx, a.ID, p.ID)
al, _ = r.Albums.GetByID(ctx, a.ID)
if al.CoverPhotoID != nil {
t.Fatalf("cover not cleared: %+v", al.CoverPhotoID)
}
if err := r.Photos.SetDeleted(ctx, p.ID, true); err != nil { if err := r.Photos.SetDeleted(ctx, p.ID, true); err != nil {
t.Fatalf("soft delete: %v", err) t.Fatalf("soft delete: %v", err)
} }
@@ -524,6 +536,19 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) { if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
t.Fatalf("abandoned job still present: %+v", jobs) t.Fatalf("abandoned job still present: %+v", jobs)
} }
// Fresh jobs are prioritized by photo freshness: the newest photo's job is
// claimed first. The delay keeps the two photos' timestamps distinct.
pOlder := seedPhoto(t, ctx, r, owner.ID, a.ID)
time.Sleep(5 * time.Millisecond)
pNewer := seedPhoto(t, ctx, r, owner.ID, a.ID)
order, err := r.Photos.ClaimVariantJobs(ctx, 2, time.Minute)
if err != nil {
t.Fatalf("claim ordered jobs: %v", err)
}
if len(order) < 2 || order[0] != pNewer.ID || order[1] != pOlder.ID {
t.Fatalf("claim order = %+v, want newest first [%s %s]", order, pNewer.ID, pOlder.ID)
}
} }
func testSettings(t *testing.T, ctx context.Context, r Repos) { func testSettings(t *testing.T, ctx context.Context, r Repos) {
+1 -1
View File
@@ -16,7 +16,7 @@ import (
func newTestFS(t *testing.T) *FS { func newTestFS(t *testing.T) *FS {
t.Helper() t.Helper()
signer, err := NewSigner("http://app.test", "test-secret", time.Hour) signer, err := NewSigner("http://app.test", "test-secret", time.Hour, 10*time.Minute)
if err != nil { if err != nil {
t.Fatalf("signer: %v", err) t.Fatalf("signer: %v", err)
} }
+1 -1
View File
@@ -22,7 +22,7 @@ func newTestS3(t *testing.T) (*S3, *Signer) {
if endpoint == "" { if endpoint == "" {
t.Skip("S3_ENDPOINT not set") t.Skip("S3_ENDPOINT not set")
} }
signer, err := NewSigner("http://app.test", "secret", time.Hour) signer, err := NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
if err != nil { if err != nil {
t.Fatalf("signer: %v", err) t.Fatalf("signer: %v", err)
} }
+30 -4
View File
@@ -20,33 +20,59 @@ type Signer struct {
secret []byte secret []byte
baseURL string baseURL string
ttl time.Duration ttl time.Duration
window time.Duration
now func() time.Time
} }
// NewSigner builds a signer. secret signs the URLs (required); baseURL is the // NewSigner builds a signer. secret signs the URLs (required); baseURL is the
// public base URL of the app; ttl limits URL lifetime (defaults to one hour). // public base URL of the app; ttl limits URL lifetime (defaults to one hour);
func NewSigner(baseURL, secret string, ttl time.Duration) (*Signer, error) { // window quantizes the expiry so the same object yields an identical URL within
// one window (defaults to ten minutes, and is capped at ttl). A non-positive
// window keeps the URL stable for its whole lifetime.
func NewSigner(baseURL, secret string, ttl, window time.Duration) (*Signer, error) {
if strings.TrimSpace(secret) == "" { if strings.TrimSpace(secret) == "" {
return nil, errors.New("url secret is required") return nil, errors.New("url secret is required")
} }
if ttl <= 0 { if ttl <= 0 {
ttl = time.Hour ttl = time.Hour
} }
if window <= 0 {
window = 10 * time.Minute
}
if window > ttl {
window = ttl
}
return &Signer{ return &Signer{
secret: []byte(secret), secret: []byte(secret),
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
ttl: ttl, ttl: ttl,
window: window,
now: time.Now,
}, nil }, nil
} }
// URL returns a signed app URL for the given opaque key. // URL returns a signed app URL for the given opaque key. The expiry is
// quantized to the signing window so repeated calls within that window return
// the identical URL, letting clients reuse cached objects.
func (s *Signer) URL(key string) string { func (s *Signer) URL(key string) string {
exp := time.Now().Add(s.ttl).Unix() exp := s.expiry()
q := url.Values{} q := url.Values{}
q.Set("e", strconv.FormatInt(exp, 10)) q.Set("e", strconv.FormatInt(exp, 10))
q.Set("s", s.sign(key, exp)) q.Set("s", s.sign(key, exp))
return fmt.Sprintf("%s/api/objects/%s?%s", s.baseURL, escapeKey(key), q.Encode()) return fmt.Sprintf("%s/api/objects/%s?%s", s.baseURL, escapeKey(key), q.Encode())
} }
// expiry is the quantized Unix expiry shared by all URLs issued in the current
// window: the window start plus the TTL. It is never earlier than now.
func (s *Signer) expiry() int64 {
now := s.now()
exp := now.Truncate(s.window).Add(s.ttl)
if exp.Before(now) {
exp = now.Add(s.ttl)
}
return exp.Unix()
}
// VerifyRequest checks the signature and expiry carried by an object request. // VerifyRequest checks the signature and expiry carried by an object request.
// It returns ErrSignedURLInvalid when the request is not authorized. // It returns ErrSignedURLInvalid when the request is not authorized.
func (s *Signer) VerifyRequest(key string, r *http.Request) error { func (s *Signer) VerifyRequest(key string, r *http.Request) error {
+63
View File
@@ -0,0 +1,63 @@
package storage
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
)
func expiryOf(t *testing.T, raw string) int64 {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse url: %v", err)
}
e, err := strconv.ParseInt(u.Query().Get("e"), 10, 64)
if err != nil {
t.Fatalf("parse expiry: %v", err)
}
return e
}
func TestSignerURLStableWithinWindow(t *testing.T) {
s, err := NewSigner("http://app.test", "secret", time.Hour, time.Hour)
if err != nil {
t.Fatalf("new signer: %v", err)
}
first := s.URL("objects/a/thumb.webp")
second := s.URL("objects/a/thumb.webp")
if first != second {
t.Fatalf("URL changed within window:\n %q\n %q", first, second)
}
}
func TestSignerURLChangesAcrossWindow(t *testing.T) {
s, err := NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
if err != nil {
t.Fatalf("new signer: %v", err)
}
base := time.Now()
s.now = func() time.Time { return base }
first := expiryOf(t, s.URL("objects/a/thumb.webp"))
s.now = func() time.Time { return base.Add(s.window) }
second := expiryOf(t, s.URL("objects/a/thumb.webp"))
if first == second {
t.Fatalf("expiry did not advance across window: %d", first)
}
}
func TestSignerURLVerifies(t *testing.T) {
s, err := NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
if err != nil {
t.Fatalf("new signer: %v", err)
}
key := "objects/a/thumb.webp"
raw := s.URL(key)
req := httptest.NewRequest(http.MethodGet, raw, nil)
if err := s.VerifyRequest(key, req); err != nil {
t.Fatalf("verify issued URL: %v", err)
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ func Open(ctx context.Context, cfg *config.Config, jwtSecret string) (Backend, e
if secret == "" { if secret == "" {
return nil, errors.New("object URL secret is not available") return nil, errors.New("object URL secret is not available")
} }
signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL) signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL, cfg.Storage.URLSignWindow)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -75,6 +75,13 @@
.catch((e) => (error = (e as Error).message)); .catch((e) => (error = (e as Error).message));
}); });
$effect(() => {
// Keep the local album in sync with cover changes made elsewhere
// (context menu, photo panel) that go through the albums store.
const updated = albums.list.find((a) => a.id === albumId);
if (updated) album = updated;
});
$effect(() => { $effect(() => {
const id = photoId; const id = photoId;
if (!id) { if (!id) {
@@ -6,6 +6,7 @@
import PanelSection from './PanelSection.svelte'; import PanelSection from './PanelSection.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte'; import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import { auth } from '$lib/stores/auth.svelte'; import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { transfer } from '$lib/stores/transfer.svelte'; import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte'; import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif'; import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
@@ -179,6 +180,14 @@
} }
} }
async function setAsCover() {
try {
await albums.setCover(photo.album_id, photo.id);
} catch {
/* ignore */
}
}
async function confirmDelete() { async function confirmDelete() {
deleting = true; deleting = true;
try { try {
@@ -273,6 +282,12 @@
<Icon name="content_copy" class="size-4" /> <Icon name="content_copy" class="size-4" />
Копировать Копировать
</button> </button>
{#if canEdit}
<button type="button" class={navLink} onclick={setAsCover}>
<Icon name="wallpaper" class="size-4" />
Установить как обложку альбома
</button>
{/if}
{#if canDelete} {#if canDelete}
<button <button
type="button" type="button"
+1
View File
@@ -16,6 +16,7 @@ export class SSEClient {
private attempt = 0; private attempt = 0;
private readonly knownEvents = [ private readonly knownEvents = [
'photo.created', 'photo.created',
'photo.updated',
'photo.deleted', 'photo.deleted',
'comment.created', 'comment.created',
'like.added', 'like.added',
@@ -29,6 +29,7 @@
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>(); let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role)); let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
let canEdit = $derived(role === 'owner' || role === 'admin');
async function loadAlbum() { async function loadAlbum() {
const data = await api<{ album: Album; role: string }>(`/albums/${albumId}`); const data = await api<{ album: Album; role: string }>(`/albums/${albumId}`);
@@ -100,6 +101,8 @@
break; break;
} }
case 'photo.updated': { case 'photo.updated': {
// Variant readiness updates a photo already in the grid; never
// insert an unseen photo here, or the grid would reflow.
const photo = data as unknown as Photo; const photo = data as unknown as Photo;
photos = photos.map((p) => (p.id === photo.id ? photo : p)); photos = photos.map((p) => (p.id === photo.id ? photo : p));
break; break;
@@ -221,6 +224,15 @@
} }
] ]
: []), : []),
...(canEdit
? [
{
label: 'Установить как обложку альбома',
icon: 'wallpaper' as const,
run: () => albums.setCover(albumId, photo.id)
}
]
: []),
{ {
label: 'Переместить', label: 'Переместить',
icon: 'arrow_back', icon: 'arrow_back',
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,98 @@
# Design
## Context
Требование «Обложка по умолчанию» существовало и работало в старом инлайн-пути генерации
вариантов (`processVariants`), который вызывал `SetCoverIfEmpty`. Рефакторинг media-objects
(коммит `96268ee`) перенёс генерацию вариантов в фоновый worker и потерял этот вызов; сейчас
`SetCoverIfEmpty` вызывается только из `CopyBatch`. Дополнительно обложка не снимается при
уходе фото-обложки. См. `proposal.md — Why`.
Опорные точки:
- `internal/photos/service.go`: `Upload`, `Delete`, `MoveBatch`; `SetCoverIfEmpty` — метод
репозитория фото (`store.go`, `repository.go`, `sqlite_store.go`).
- `internal/photos/worker.go`: с недавних пор обрабатывает новые фото первыми
(`created_at DESC`) — отсюда решение не ставить обложку в worker'е.
- `albums.Update` уже поддерживает `cover_photo_id` и `clear_cover`.
- Frontend: `albums.setCover()` уже существует; контекстное меню — `openMenuForPhoto`
в `albums/[id]/+page.svelte`; секция «Действия» — `PhotoPanel.svelte` (props: `photo`, `role`).
## Goals / Non-Goals
**Goals:**
- Обложкой нового альбома становится первое загруженное фото.
- Обложка снимается, когда фото-обложка покидает альбом (удаление/перенос).
- Пункт «Установить как обложку альбома» доступен из сетки и правой панели.
**Non-Goals:**
- Backfill существующих альбомов.
- Автоперевыбор новой обложки после снятия.
- Изменение поведения Viewer и share-страниц.
## Decisions
### 1. Обложка ставится при загрузке, а не в worker'е
**Выбор:** в `Service.Upload`, после успешного сохранения фото, вызвать
`store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID)`.
**Почему:** формулировка «первое загруженное фото». Worker обрабатывает новейшие фото первыми,
поэтому установка «при обработке» дала бы обложку = последнее загруженное. Загрузка идёт
последовательно по файлам запроса, поэтому первое фото детерминированно выигрывает
(`UPDATE ... WHERE cover_photo_id IS NULL`).
**Альтернативы:**
- *В worker'е по готовности вариантов* — thumb уже есть, но порядок обработки ломает «первое
загруженное».
- *В worker'е с выбором MIN(created_at)* — сложнее и не гарантирует готовность thumb
выбранного фото.
**Следствие:** `cover_thumb_url` пуст до генерации thumb; список альбомов подхватит его при
следующем запросе (JOIN по `cover_photo_id`). Реальное время смены обложки в рейле не
обновляется по SSE — вне области.
### 2. Снятие обложки — атомарный метод репозитория
**Выбор:** метод `ClearCoverIfMatches(ctx, albumID, photoID)`:
`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`.
Вызывается в `Delete` (корзина) и в `MoveBatch` для исходного альбома.
**Почему:** условный UPDATE защищает от гонки (обложку могли сменить) и не требует чтения.
`PermanentDelete` уже покрыт FK `ON DELETE SET NULL`.
**Альтернативы:**
- *Снять безусловно* — риск снести чужой выбор обложки.
- *FK при soft-delete* — FK не срабатывает при мягком удалении.
### 3. `clear_cover` → NULL
**Выбор:** явный сброс обложки оставляет `cover_photo_id = NULL` (пересчёта по умолчанию нет).
Соответствует решению «очищать, без backfill».
**Альтернативы:**
- *Пересчитать «по умолчанию» (первое фото)* — тогда сброс бессмыслен (обложка мгновенно
вернётся), плюс скрытый backfill.
### 4. Frontend: та же защита прав, что и у редактирования альбома
**Выбор:** пункт показывается при `role === 'owner' || role === 'admin'`; действие вызывает
`albums.setCover(albumId, photo.id)` (существующий `PATCH /albums/:id`). Добавляется в
`openMenuForPhoto` и в секцию «Действия» `PhotoPanel`; иконка `wallpaper`.
**Почему:** переиспользуется существующий API и гейт прав; `PhotoPanel` уже получает `role`.
`albumId` берётся из `photo.album_id`/контекста страницы.
## Risks / Trade-offs
- [Пункт меню/панели меняет обложку, но рейл не обновляется мгновенно, если thumb ещё не готов]
→ допустимо; при следующем запросе обложка появится.
- [Гонка параллельных загрузок в пустой альбом] → условный UPDATE выбирает одного победителя;
«первое» при строго параллельных запросах незначительно недетерминировано.
- [Снятие обложки при переносе может неожиданно обнулить обложку] → это и есть желаемое
поведение по спеке; пользователь может назначить заново.
- [Существующие альбомы без обложки остаются пустыми] → осознанно (no backfill).
## Migration Plan
- Миграций нет. Порядок: backend (загрузка/снятие обложки) → frontend (меню/панель).
- Откат — возврат предыдущего бинаря/фронта; данные не деградируют.
@@ -0,0 +1,37 @@
# Proposal
## Why
Автобложка «первое фото» была специфицирована, но потерялась при рефакторинге media-objects:
при загрузке обложка больше не назначается, поэтому большинство альбомов (в т.ч. семенные)
остаются без обложки. Дополнительно нужно довести жизненный цикл обложки: снимать её, когда
фото-обложка покидает альбом, и сделать явный сброс предсказуемым.
## What Changes
- **Обложка по умолчанию при загрузке.** Если у альбома нет обложки, обложкой становится
первое загруженное фото; последующие фото её не меняют.
- **Снятие обложки при уходе фото-обложки.** Помещение фото-обложки в корзину или перенос
её в другой альбом очищает обложку (без перевыбора).
- **Явный сброс обложки** (`clear_cover`) обнуляет обложку и не пересчитывает её.
- **Frontend.** Пункт «Установить как обложку альбома» в контекстном меню фото сетки и в
разделе «Действия» правой панели; доступен только владельцу и админу.
- **Без backfill** существующих альбомов без обложки.
## Capabilities
### New Capabilities
<!-- нет -->
### Modified Capabilities
- `photos`: обложка по умолчанию назначается при загрузке (первое фото) и снимается, когда
фото-обложка покидает альбом.
- `albums`: сброс обложки делает её не заданной (без возврата к значению по умолчанию).
## Impact
- Backend: `internal/photos/service.go` (`Upload`, `Delete`, `MoveBatch`),
`internal/photos/{store,repository,sqlite_store}.go` (метод снятия обложки).
- Frontend: контекстное меню фото в сетке (`albums/[id]/+page.svelte`), раздел «Действия»
в `PhotoPanel.svelte`; используется существующий `PATCH /albums/:id` через `albums.setCover`.
- Данные/миграции: не требуются; backfill не выполняется.
@@ -0,0 +1,20 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Выбор обложки
Владелец и админ SHALL иметь возможность назначить обложкой любое фото альбома или сбросить
обложку; после сброса обложка SHALL становиться не заданной (без возврата к значению по
умолчанию).
#### Scenario: Назначение обложки
- **WHEN** владелец выбирает фото альбома как обложку
- **THEN** система сохраняет выбор и отражает его в списке и карточках
#### Scenario: Сброс обложки
- **WHEN** админ сбрасывает обложку
- **THEN** система снимает обложку, и альбом отображается без обложки, пока обложку не назначат снова
#### Scenario: Нет прав на смену обложки
- **WHEN** участник без права редактирования пытается сменить обложку
- **THEN** система отклоняет запрос
@@ -0,0 +1,24 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Обложка по умолчанию
При загрузке фото система SHALL назначать его обложкой альбома, если обложка ещё не задана,
то есть обложкой становится первое загруженное фото. Когда фото, выбранное обложкой, покидает
альбом (помещено в корзину или перенесено в другой альбом), система SHALL снять обложку.
#### Scenario: Первое фото
- **WHEN** фото загружено, и у альбома нет обложки
- **THEN** система назначает это фото обложкой альбома
#### Scenario: Обложка уже задана
- **WHEN** фото загружено, а обложка уже задана
- **THEN** система не меняет обложку
#### Scenario: Фото-обложка в корзине
- **WHEN** фото, назначенное обложкой, помещается в корзину
- **THEN** система снимает обложку, и альбом отображается без обложки
#### Scenario: Фото-обложка перенесено
- **WHEN** фото, назначенное обложкой, переносится в другой альбом
- **THEN** система снимает обложку исходного альбома
+21
View File
@@ -0,0 +1,21 @@
# Tasks
## 1. Автобложка при загрузке
- [x] 1.1 В `backend/internal/photos/service.go` (`Upload`) вызывать `store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID)` после успешного сохранения фото; проверить `cd backend && go test ./internal/e2e/` — тест загружает два фото в новый альбом и проверяет, что обложкой стало первое
- [x] 1.2 Добавить в тот же тест проверку сценария «обложка уже задана»: загрузка второго фото не меняет уже назначенную обложку
## 2. Снятие обложки при уходе фото-обложки
- [x] 2.1 Добавить `ClearCoverIfMatches(ctx, albumID, photoID)` в `backend/internal/photos/repository.go`, `store.go` и `sqlite_store.go` (`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`); проверить `cd backend && go test ./internal/repo/contract/` — снятие срабатывает только при совпадении обложки
- [x] 2.2 Вызывать `ClearCoverIfMatches` в `Delete` (корзина) и `MoveBatch` (исходный альбом) в `service.go`; проверить тестом, что удаление и перенос фото-обложки снимают обложку
## 3. Пункт «Установить как обложку альбома»
- [x] 3.1 Добавить пункт в контекстное меню фото сетки (`frontend/src/routes/(app)/albums/[id]/+page.svelte`, `openMenuForPhoto`) с иконкой `wallpaper` и вызовом `albums.setCover(albumId, photo.id)`, видимый только owner/admin; проверить `cd frontend && npm run check`
- [x] 3.2 Добавить тот же пункт в секцию «Действия» компонента `frontend/src/lib/components/PhotoPanel.svelte`; проверить `npm run check` и наблюдаемо: пункт показывается только владельцу/админу
## 4. Интеграционная проверка
- [x] 4.1 `cd backend && go test ./...` и `cd frontend && npm run check` — оба зелёные
- [x] 4.2 Собрать (`make build`), загрузить фото в новый альбом (обложка = первое фото), затем удалить фото-обложку (обложка пуста) и перенести фото-обложку (обложка пуста); сбросить обложку вручную через панель — остаётся пустой
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,115 @@
# Design
## Context
Список фото отдаётся за миллисекунды; узкое место — не выборка, а доставка вариантов и
реакция клиента. См. `proposal.md — Why` и delta-спеки `photos`, `realtime`,
`media-objects`. Ниже — как устроены три правки.
Текущие точки опоры:
- `internal/storage/signer.go`: `URL()` кладёт `e = now + ttl` и подпись `s` — уникальны
каждую секунду.
- `internal/app/app.go:serveObject`: единая точка выдачи объектов для `fs` и `s3`.
- `internal/photos/worker.go`: один горутин, `tick` → `ClaimVariantJobs(batch, ...)` →
последовательная обработка; `notify` публикует `photo.created`.
- `internal/photos/sqlite_objects.go:ClaimVariantJobs`: `ORDER BY next_attempt_at`.
- `frontend/src/lib/sse.ts`: `knownEvents` не содержит `photo.updated`.
- `frontend/.../[id]/+page.svelte`: на `photo.created` делает prepend незнакомого фото.
## Goals / Non-Goals
**Goals:**
- Устранить перетасовку сетки и повторную перекачку превью при открытии больших альбомов.
- Заполнять видимый экран превью в первую очередь и заметно быстрее, чем сейчас.
**Non-Goals:**
- Изменение схемы метаданных (миграции не требуются).
- Замена кодеков/алгоритмов ресайза, переход на отдельный воркер-сервис.
- Синхронная генерация вариантов на upload (пункт 4 из обсуждения, отложен).
- Инвалидация/очистка CDN-кэша (CDN вне области).
## Decisions
### 1. Разделение семантики событий вместо фильтрации на клиенте
**Выбор:** worker при готовности вариантов публикует `photo.updated`; `photo.created`
остаётся только для реального появления фото (upload/restore/copy). На клиенте `photo.updated`
— replace только для уже присутствующих фото; `photo.created` — по-прежнему prepend.
**Почему:** `photo.created` сейчас перегружен, и клиент не может отличить «новое фото»
от «превью доготовилось». Фильтрация prepend на клиенте без разделения семантики ломает
появление реально новых загрузок. Разделение чинит и первопричину, и заодно закрывает
латентный баг: `photo.updated` не входит в `knownEvents`, поэтому переименование фото по
SSE сейчас не доходит.
**Альтернативы:**
- *Фильтр prepend только для фото старше самого старого загруженного* — эвристика, не
устраняет корень, хрупко при пагинации.
- *Вставлять в правильную позицию по `created_at`* — сохраняет перестановку/перерисовку,
не решает проблему.
### 2. Детерминированные подписанные URL + Cache-Control
**Выбор:** квантовать срок действия: `exp = now.Truncate(step) + ttl.bucket`. URL стабилен
внутри окна `step`. Ответы объектов получают `Cache-Control: private, max-age=<...>`.
Заголовок ставится в `app.serveObject` до вызова `storage.ServeObject` — одна точка для
обоих бэкендов. `http.ServeContent` уже даёт `Last-Modified`/304.
**Почему:** стабильный URL + явный кэш устраняют повторную загрузку при перерисовке сетки и
reconnect. Объекты неизменяемы (один key — одни байты), поэтому кэш безопасен.
**Параметры (решаются здесь, значения конфигурируемы):**
- `step` — окно стабильности подписи (по умолчанию 10 минут).
- `max-age` — срок свежести в браузере (по умолчанию равен окну/ttl; консервативно — окно).
**Альтернативы:**
- *Не менять подпись, только заголовки* — URL всё равно меняется между refetch, браузер
делает новый запрос; мигание остаётся.
- *Долгоживущая подпись без окна* — снижает безопасность сильнее, чем квантование.
- *Кэш в памяти процесса на бэкенде* — не решает перекачку в браузере.
**Безопасность:** ссылка живёт до `ttl + step` вместо `ttl`. Приемлемо; при необходимости
снижается уменьшением `step`.
### 3. Пропускная способность и приоритет очереди
**Выбор:** параметры worker'а (уже частично есть: `WORKER_BATCH`, `WORKER_INTERVAL`) дополняются
уровнем параллелизма `WORKER_CONCURRENCY` (пул горутинов, по умолчанию небольшой, напр. 4).
`ClaimVariantJobs` для свежих заданий сортирует по свежести фото (`created_at DESC`), а
задания с `attempts > 0` — по `next_attempt_at` (сохраняя backoff повторов).
**Почему:** генерация CPU-bound (декод + Lanczos + WebP) и распараллеливается по ядрам;
параллелизм даёт кратный прирост. Приоритет по свежести напрямую сокращает время до
заполнения видимого экрана (клиент показывает новейшие фото первыми). SQLite настроен на
single-writer с `busy_timeout(5000)` и короткими транзакциями — параллельная обработка
безопасна.
**Альтернативы:**
- *Только поднять `WORKER_BATCH`/снизить `WORKER_INTERVAL`* — простой конфиг, но
последовательная обработка упирается в скорость одного ядра и в паузу между тиками.
- *Приоритетная очередь с отдельной колонкой приоритета* — лишняя схема; сортировки по
`created_at`/`next_attempt_at` достаточно.
- *Пул воркеров с `errgroup`/`semaphore`* — выбран вариант реализации пула; конкретный
примитив — деталь, не влияет на поведение.
## Risks / Trade-offs
- [Переупорядочивание событий: `photo.updated` обгонит `photo.created` при гонке] →
обработчик `photo.updated` для отсутствующего фото — no-op (спека это закрепляет).
- [Параллельная запись в SQLite даёт `SQLITE_BUSY`] → короткие транзакции, `busy_timeout`,
ограниченный уровень параллелизма.
- [Увеличение срока жизни подписанного URL] → ограничить окно `step`; сделать его
конфигурируемым.
- [Кэш браузера отдаёт «старое» превью] → объекты неизменяемы, thumb/preview не
перегенерируются на месте; риск отсутствует.
- [Старые сессии/табы ждут `photo.created` для доготовки превью] → после деплоя клиент и
сервер должны обновиться вместе; в рамках проекта клиент и бэкенд релизятся вместе.
## Migration Plan
- Изменений схемы нет → откат — возврат к предыдущему бинарю/фронту.
- Порядок: сначала backend (событие + подпись + worker), затем frontend (набор событий и
обработка). Взаимная совместимость в переходном окне: старый фронт игнорирует
`photo.updated` (не в `knownEvents`), новый фронт с новым бэком работает штатно.
- После деплоя большой альбом наполняется быстрее; уже существующие незавершённые задания
подхватываются автоматически (очередь durable).
@@ -0,0 +1,46 @@
# Proposal
## Why
Большие альбомы (например `seed-1791714157-0 #2`, 1000 фото) открываются медленно и
«мерцают». Замеры на реальной базе: запрос списка фото выполняется за ~1 мс, но при
открытии первые 50 ячеек — это ещё не обработанные фото («обработка…»), генерация
вариантов идёт со скоростью ~2 фото/с (порядка 8 минут на альбом), а сетка перетасовывается
из-за семантики SSE-событий и перекачивается из-за нестабильных подписанных URL.
## What Changes
- **Стабильные ссылки на объекты.** Подписанный URL перестаёт меняться каждую секунду:
срок действия квантуется по окну, ответы бэкенда получают `Cache-Control`, чтобы браузер
переиспользовал уже загруженные превью вместо повторной загрузки.
- **Корректная семантика SSE-событий.** Готовность вариантов worker'а публикуется как
изменение фото (не как новое фото), поэтому клиент обновляет уже показанное фото и не
вставляет чужие фото в начало сетки. Событие изменения фото добавляется в набор
отслеживаемых на клиенте.
- **Ускорение и приоритезация очереди вариантов.** Повышается пропускная способность
генерации вариантов (параллелизм и параметры пакета) и свежие фото обрабатываются первыми,
чтобы видимый экран заполнялся превью быстрее.
## Capabilities
### New Capabilities
<!-- нет -->
### Modified Capabilities
- `photos`: подписанные URL становятся детерминированными в пределах окна и отдаются с
заголовками кэширования.
- `realtime`: готовность вариантов публикуется как изменение фото; набор событий включает
изменение фото.
- `media-objects`: очередь генерации вариантов получает настраиваемую пропускную способность
и приоритет по свежести фото.
## Impact
- Backend: `internal/storage/signer.go`, `internal/app/app.go` (заголовки объектов),
`internal/photos/worker.go`, `internal/photos/sqlite_objects.go` (порядок и пакет claim),
`internal/config/config.go` (параметры worker'а).
- Frontend: `src/lib/sse.ts` (набор событий), `src/routes/(app)/albums/[id]/+page.svelte`
(обработка событий и вставка в сетку).
- Поведение/совместимость: подписанный URL живёт до `ttl + окно` вместо `ttl`; событие
`photo.created` больше не переиспользуется для готовности вариантов.
- Данные/миграции: изменений схемы не требуется.
@@ -0,0 +1,25 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Генерация вариантов
Генерация thumb и preview SHALL выполняться через надёжную очередь заданий с
однократным выполнением и повтором при сбое; готовый результат SHALL
транслироваться подписчикам альбома. Очередь SHALL поддерживать настраиваемую
пропускную способность и SHALL обрабатывать более свежие фото раньше остальных.
#### Scenario: Повтор после рестарта
- **WHEN** процесс перезапускается с незавершёнными заданиями
- **THEN** генерация вариантов продолжается
#### Scenario: Событие после генерации
- **WHEN** варианты фото готовы
- **THEN** подписчикам альбома публикуется событие обновления этого фото
#### Scenario: Приоритет свежих фото
- **WHEN** в очереди есть готовые к обработке задания разного возраста
- **THEN** система обрабатывает более свежие фото раньше
#### Scenario: Настраиваемая пропускная способность
- **WHEN** задан уровень параллелизма генерации
- **THEN** система обрабатывает варианты с этим уровнем параллелизма
@@ -0,0 +1,32 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Получение фото по требованию
Клиент SHALL иметь возможность получить актуальные данные фото с подписанным URL,
обслуживаемым бэкендом, независимо от бэкенда хранения. Подпись SHALL быть
детерминированной в пределах окна действия, чтобы повторные выдачи одного и того же
объекта давали идентичный URL.
#### Scenario: Выдача свежего URL
- **WHEN** клиент запрашивает фото по id
- **THEN** система возвращает подписанный URL бэкенда для просмотра
#### Scenario: Повторная выдача совпадает
- **WHEN** один и тот же объект запрашивается повторно в пределах окна действия подписи
- **THEN** система возвращает идентичный URL
## ADDED Requirements
### Requirement: Кэширование объектов
Доставка объектов SHALL помечать ответы как кэшируемые, чтобы браузер переиспользовал
уже полученные превью, а повторное открытие альбома не вызывало повторную загрузку
неизменившихся объектов.
#### Scenario: Заголовки кэширования
- **WHEN** клиент получает объект (оригинал, превью или thumb) по подписанному URL
- **THEN** ответ содержит заголовок кэширования с ограниченным сроком годности
#### Scenario: Повторный показ без перезагрузки
- **WHEN** сетка превью перерисовывается, а URL объектов не изменились
- **THEN** браузер отдаёт объекты из кэша без повторного запроса к бэкенду
@@ -0,0 +1,31 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: События альбома
Система SHALL публиковать события photo.created, photo.updated, photo.deleted,
comment.created, like.added, like.removed, member.joined, member.left, album.updated.
Событие photo.created SHALL обозначать появление фото в альбоме, а готовность вариантов
существующего фото SHALL публиковаться как photo.updated.
#### Scenario: Новое фото
- **WHEN** в альбоме появляется фото
- **THEN** подписчики получают событие photo.created с метаданными и URL превью
#### Scenario: Готовы варианты
- **WHEN** для существующего фото сгенерированы thumb и preview
- **THEN** подписчики получают событие photo.updated с этим фото
## ADDED Requirements
### Requirement: Различение добавления и обновления фото
Клиент SHALL различать добавление нового фото в альбом и обновление уже показанного:
обновление SHALL изменять фото на месте, не меняя порядок сетки.
#### Scenario: Обновление отображаемого фото
- **WHEN** приходит событие обновления для фото, которое уже показано
- **THEN** клиент обновляет его на месте, порядок сетки не меняется
#### Scenario: Обновление отсутствующего фото
- **WHEN** приходит событие обновления для фото, которого нет в текущем списке
- **THEN** клиент не добавляет его в сетку
@@ -0,0 +1,24 @@
# Tasks
## 1. Стабильные подписанные URL и кэширование объектов
- [x] 1.1 Добавить параметр окна подписи (`OBJECT_URL_SIGN_WINDOW`, по умолчанию 10m) в `internal/config/config.go` и квантовать срок в `Signer.URL` (`exp = now.Truncate(window) + ttl`); проверить `cd backend && go test ./internal/storage/` — новый тест подтверждает, что URL совпадает в пределах окна и меняется за его границей
- [x] 1.2 Выставить `Cache-Control` для ответов объектов в `internal/app/app.go` (`serveObject`), используя параметр срока свежести; проверить `cd backend && go test ./internal/app/` — тест утверждает наличие заголовка в ответе
- [x] 1.3 Задокументировать новые переменные окружения в `.env.example` и проверить, что описанные значения совпадают с дефолтами в `config.go`
## 2. Семантика событий готовности вариантов
- [x] 2.1 Переключить уведомление worker'а на `photo.updated` в `internal/photos/worker.go` (`notify`), оставив `photo.created` только для реального появления фото; проверить тестом с подпиской на hub, что после генерации вариантов публикуется `photo.updated`
- [x] 2.2 Добавить `photo.updated` в `knownEvents` в `frontend/src/lib/sse.ts`; проверить `cd frontend && npm run check`
- [x] 2.3 В `frontend/src/routes/(app)/albums/[id]/+page.svelte` сделать `photo.updated` заменой только уже присутствующих фото, а `photo.created` — по-прежнему добавлением; проверить `npm run check` и наблюдаемым поведением: событие обновления для отсутствующего фото не добавляет его в сетку
## 3. Пропускная способность и приоритет очереди вариантов
- [x] 3.1 Добавить `WORKER_CONCURRENCY` в `internal/config/config.go` и пул параллельной обработки в `internal/photos/worker.go`; проверить `cd backend && go test ./internal/photos/` — тест подтверждает обработку не менее N заданий за проход при concurrency > 1
- [x] 3.2 Изменить порядок в `ClaimVariantJobs` (`internal/photos/sqlite_objects.go`): свежие задания — по свежести фото (`created_at DESC`), задания с ошибками (`attempts > 0`) — по `next_attempt_at`, сохраняя backoff; проверить тестом на порядок выдачи
- [x] 3.3 Задокументировать `WORKER_CONCURRENCY` и пороги очереди в `.env.example`; проверить соответствие дефолтов `config.go`
## 4. Интеграционная проверка
- [x] 4.1 Собрать (`make build`), запустить и открыть существующий большой альбом (`seed-1791714157-0 #2`): первый экран заполняется превью первыми, сетка не перетасовывается при доготовке вариантов; подтвердить повторным открытием без перекачки неизменившихся превью (Network)
- [x] 4.2 Прогнать полный набор: `cd backend && go test ./...` и `cd frontend && npm run check` — оба зелёные; замерить рост пропускной способности по убыванию `variant_jobs`