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:
+10
-2
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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** система снимает обложку исходного альбома
|
||||||
@@ -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`
|
||||||
Reference in New Issue
Block a user