feat: image variants cache panel

This commit is contained in:
fsylvain committed 2026-10-08 21:13:32 +02:00
1 parent 951fef18ba
commit d768668014
53 files changed
+5809 -46

No files matched your search

@@ -0,0 +1,170 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"sort"
"time"
)
type VariantCacheEntry struct {
Name string
Enabled bool
Files int
Bytes int64
}
type VariantCacheImage struct {
ID uint32
Filename string
URL string
Files int
Bytes int64
}
type VariantCacheStats struct {
GeneratedFiles int
DiskUsed int64
VariantsDefined int
VariantsEnabled int
VariantsDisabled int
RejectedRequests int
RejectedWindowDays int
Unused image.VariantFileTotals
UnusedDays int
ByVariant []VariantCacheEntry
TopImages []VariantCacheImage
}
type GetVariantCacheStatsUseCase struct {
variants image.VariantRepository
files image.VariantFileRepository
cache image.VariantCacheRepository
images image.Repository
usage image.VariantUsageRepository
tracker image.VariantUsageTracker
now func() time.Time
}
func NewGetVariantCacheStatsUseCase(variants image.VariantRepository, files image.VariantFileRepository, cache image.VariantCacheRepository, images image.Repository, usage image.VariantUsageRepository, tracker image.VariantUsageTracker, now func() time.Time) *GetVariantCacheStatsUseCase {
return &GetVariantCacheStatsUseCase{variants, files, cache, images, usage, tracker, now}
}
func (u *GetVariantCacheStatsUseCase) Get() (VariantCacheStats, error) {
_ = u.tracker.Flush()
variants, err := u.variants.List()
if err != nil {
return VariantCacheStats{}, err
}
totals, err := u.files.Totals()
if err != nil {
return VariantCacheStats{}, err
}
now := u.now()
unused, err := u.cache.UnusedTotals(image.UnusedFilter{VariantNames: enabledNames(variants), Cutoff: image.UnusedCutoff(now)})
if err != nil {
return VariantCacheStats{}, err
}
rejected, err := u.usage.RejectedSince(image.RejectionWindowStart(now))
if err != nil {
return VariantCacheStats{}, err
}
top, err := u.topImages()
if err != nil {
return VariantCacheStats{}, err
}
stats := VariantCacheStats{
VariantsDefined: len(variants),
RejectedRequests: rejected,
RejectedWindowDays: image.RejectionWindowDays,
Unused: unused,
UnusedDays: image.UnusedVariantDays,
ByVariant: cacheEntries(variants, totals),
TopImages: top,
}
for _, entry := range stats.ByVariant {
stats.GeneratedFiles += entry.Files
stats.DiskUsed += entry.Bytes
if entry.Enabled {
stats.VariantsEnabled++
}
}
stats.VariantsDisabled = stats.VariantsDefined - stats.VariantsEnabled
return stats, nil
}
func enabledNames(variants []image.Variant) []string {
names := []string{}
for _, variant := range variants {
if variant.Enabled {
names = append(names, variant.Name)
}
}
return names
}
func cacheEntries(variants []image.Variant, totals map[string]image.VariantFileTotals) []VariantCacheEntry {
entries := make([]VariantCacheEntry, 0, len(variants))
for _, variant := range variants {
own := totals[variant.Name]
entries = append(entries, VariantCacheEntry{Name: variant.Name, Enabled: variant.Enabled, Files: own.Files, Bytes: own.Bytes})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Bytes != entries[j].Bytes {
return entries[i].Bytes > entries[j].Bytes
}
return entries[i].Name < entries[j].Name
})
return entries
}
func (u *GetVariantCacheStatsUseCase) topImages() ([]VariantCacheImage, error) {
limit := image.TopImagesLimit + 1
for {
sources, err := u.cache.TopSources(limit)
if err != nil {
return nil, err
}
ranked, err := u.rank(sources)
if err != nil {
return nil, err
}
if len(sources) < limit || topIsSettled(ranked, sources) {
return ranked[:min(len(ranked), image.TopImagesLimit)], nil
}
limit *= 2
}
}
func topIsSettled(ranked []VariantCacheImage, sources []image.SourceTotals) bool {
return len(ranked) >= image.TopImagesLimit && ranked[image.TopImagesLimit-1].Bytes > sources[len(sources)-1].Bytes
}
func (u *GetVariantCacheStatsUseCase) rank(sources []image.SourceTotals) ([]VariantCacheImage, error) {
names := make([]string, 0, len(sources))
for _, source := range sources {
names = append(names, source.SourceName)
}
found, err := u.images.ListByStoredNames(names)
if err != nil {
return nil, err
}
byName := make(map[string]image.Image, len(found))
for _, img := range found {
byName[image.StoredName(img.Path)] = img
}
ranked := make([]VariantCacheImage, 0, len(sources))
for _, source := range sources {
if img, known := byName[source.SourceName]; known {
ranked = append(ranked, VariantCacheImage{ID: img.ID, Filename: img.Filename, URL: image.PublicPath(source.SourceName), Files: source.Files, Bytes: source.Bytes})
}
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].Bytes != ranked[j].Bytes {
return ranked[i].Bytes > ranked[j].Bytes
}
return ranked[i].ID < ranked[j].ID
})
return ranked, nil
}
@@ -0,0 +1,148 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
"time"
)
type PurgeVariantCacheCommand struct {
Scope image.CachePurgeScope
Variant string
ImageID uint32
}
type VariantCachePurgeResult struct {
Scope image.CachePurgeScope
Files int
Bytes int64
Failed int
}
type PurgeVariantCacheUseCase struct {
variants image.VariantRepository
images image.Repository
cache image.VariantCacheRepository
files variantCache
recorder audit.Recorder
now func() time.Time
}
func NewPurgeVariantCacheUseCase(variants image.VariantRepository, images image.Repository, cache image.VariantCacheRepository, files image.VariantFileRepository, storage image.Storage, now func() time.Time, recorder audit.Recorder) *PurgeVariantCacheUseCase {
return &PurgeVariantCacheUseCase{variants, images, cache, variantCache{files, storage}, recorder, now}
}
type cachePurgePlan struct {
filter image.VariantFileFilter
event audit.Event
}
func (u *PurgeVariantCacheUseCase) Purge(ctx context.Context, command PurgeVariantCacheCommand) (VariantCachePurgeResult, error) {
plan, err := u.plan(command)
if err != nil {
return VariantCachePurgeResult{}, err
}
result, err := u.purgeMatching(plan.filter)
result.Scope = command.Scope
if err != nil {
recordFailure(ctx, u.recorder, plan.event, err)
return result, err
}
u.record(ctx, plan.event, result)
return result, nil
}
func (u *PurgeVariantCacheUseCase) plan(command PurgeVariantCacheCommand) (cachePurgePlan, error) {
switch command.Scope {
case image.CacheScopeAll:
return cachePurgePlan{event: audit.NewEvent(audit.ActionVariantPurgeAll, audit.CategoryMedia)}, nil
case image.CacheScopeUnused:
return u.unusedPlan()
case image.CacheScopeVariant:
return u.variantPlan(command.Variant)
case image.CacheScopeImage:
return u.imagePlan(command.ImageID)
default:
return cachePurgePlan{}, image.ErrCachePurgeScope
}
}
func (u *PurgeVariantCacheUseCase) unusedPlan() (cachePurgePlan, error) {
variants, err := u.variants.List()
if err != nil {
return cachePurgePlan{}, err
}
event := audit.NewEvent(audit.ActionVariantPurgeUnused, audit.CategoryMedia)
filter := image.VariantFileFilter{Unused: &image.UnusedFilter{VariantNames: enabledNames(variants), Cutoff: image.UnusedCutoff(u.now())}}
return cachePurgePlan{filter: filter, event: event}, nil
}
func (u *PurgeVariantCacheUseCase) variantPlan(name string) (cachePurgePlan, error) {
variant, err := u.variants.Get(name)
if err != nil {
return cachePurgePlan{}, err
}
if variant.Name != name {
return cachePurgePlan{}, image.ErrVariantNotFound
}
event := audit.NewEvent(audit.ActionVariantPurge, audit.CategoryMedia)
event.Target = audit.VariantTarget(name)
return cachePurgePlan{filter: image.VariantFileFilter{VariantName: name}, event: event}, nil
}
func (u *PurgeVariantCacheUseCase) imagePlan(id uint32) (cachePurgePlan, error) {
img, err := u.images.Get(id)
if err != nil {
return cachePurgePlan{}, err
}
event := audit.NewEvent(audit.ActionVariantPurgeImage, audit.CategoryMedia)
event.Target = imageTarget(img)
return cachePurgePlan{filter: image.VariantFileFilter{SourceName: image.StoredName(img.Path)}, event: event}, nil
}
func (u *PurgeVariantCacheUseCase) purgeMatching(filter image.VariantFileFilter) (VariantCachePurgeResult, error) {
var result VariantCachePurgeResult
var after uint32
for {
rows, err := u.cache.ListPage(filter, after, image.CachePurgeBatchSize)
if err != nil {
return result, err
}
for _, row := range rows {
after = row.ID
if err := u.files.purgeRow(row); err != nil {
result.Failed++
continue
}
result.Files++
result.Bytes += row.Size
}
if len(rows) < image.CachePurgeBatchSize {
return result, nil
}
}
}
func (u *PurgeVariantCacheUseCase) record(ctx context.Context, event audit.Event, result VariantCachePurgeResult) {
if result.Files == 0 && result.Failed == 0 {
return
}
event.Params = cachePurgeParams(event.Action, result)
if result.Files == 0 {
failFrom(ctx, u.recorder, event, GenericFailureReason)
return
}
u.recorder.Record(ctx, event)
}
func cachePurgeParams(action string, result VariantCachePurgeResult) map[string]any {
params := map[string]any{audit.ParamPurgedFiles: result.Files, audit.ParamPurgedBytes: result.Bytes}
if action == audit.ActionVariantPurgeUnused {
params[audit.ParamOlderThanDays] = image.UnusedVariantDays
}
if result.Failed > 0 {
params[audit.ParamFailedFiles] = result.Failed
}
return params
}
@@ -55,6 +55,18 @@ type ServeVariantUseCase struct {
maxSourcePixels int64
renderSlots chan struct{}
flights singleflight.Group
tracker image.VariantUsageTracker
}
type idleUsageTracker struct{}
func (idleUsageTracker) RecordHit(string, string) {}
func (idleUsageTracker) RecordRejection() {}
func (idleUsageTracker) Flush() error { return nil }
func (u *ServeVariantUseCase) WithUsageTracker(tracker image.VariantUsageTracker) *ServeVariantUseCase {
u.tracker = tracker
return u
}
func NewServeVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, images image.Repository, storage image.Storage, renderer image.VariantRenderer, limits ServeVariantLimits) *ServeVariantUseCase {
@@ -67,10 +79,19 @@ func NewServeVariantUseCase(variants image.VariantRepository, files image.Varian
maxSourceBytes: limits.MaxSourceBytes,
maxSourcePixels: limits.MaxSourcePixels,
renderSlots: make(chan struct{}, max(1, limits.MaxConcurrentRenders)),
tracker: idleUsageTracker{},
}
}
func (u *ServeVariantUseCase) Serve(request ServeVariantRequest) (ServedVariant, error) {
served, err := u.serve(request)
if errors.Is(err, image.ErrUnknownVariant) || errors.Is(err, image.ErrUnsupportedParameter) {
u.tracker.RecordRejection()
}
return served, err
}
func (u *ServeVariantUseCase) serve(request ServeVariantRequest) (ServedVariant, error) {
if parameter := unsupportedParameter(request.Parameters); parameter != "" {
return ServedVariant{}, image.NewUnsupportedParameterError(parameter)
}
@@ -134,18 +155,26 @@ func (u *ServeVariantUseCase) serveSource(variant image.Variant, request ServeVa
settings := variant.Settings()
etag := image.VariantETag(settings, request.StoredName)
if image.ETagMatches(request.IfNoneMatch, etag) {
u.tracker.RecordHit(variant.Name, request.StoredName)
return ServedVariant{NotModified: true, ETag: etag}, nil
}
fingerprint := settings.Fingerprint()
cached, previous, hit, err := u.lookupCached(variant.Name, request.StoredName, fingerprint, etag)
if err != nil || hit {
if err == nil {
u.tracker.RecordHit(variant.Name, request.StoredName)
}
return cached, err
}
file, err := u.generated(requestContext(request), variant, request.StoredName, fingerprint, previous)
if err != nil {
return ServedVariant{}, err
}
return u.serveFile(file, etag)
served, err := u.serveFile(file, etag)
if err == nil {
u.tracker.RecordHit(variant.Name, request.StoredName)
}
return served, err
}
func requestContext(request ServeVariantRequest) context.Context {
@@ -1,6 +1,9 @@
package useCases
import "RenewCMS/internal/domain/image"
import (
"RenewCMS/internal/domain/image"
"errors"
)
type VariantPurgeResult struct {
Files int
@@ -44,7 +47,7 @@ func (c variantCache) purge(rows []image.VariantFile) (VariantPurgeResult, error
}
func (c variantCache) purgeRow(row image.VariantFile) error {
if err := c.storage.Delete(row.CachedName); err != nil {
if err := c.storage.Delete(row.CachedName); err != nil && !errors.Is(err, image.ErrBlobNotFound) {
return err
}
return c.files.Delete(row.ID)