perf(cache): 三层缓存框架补强

- 新增 cache-framework skill,规范 RAM→Redis→DB 读路径、失效与 pub/sub
- 上传元数据 Otter+Redis 缓存与多节点失效;Auth Source 缓存与 pub/sub
- ListSystemConfigsByKeys 补 Redis 层;上传统计单事务;登录/Token 缓存预热
- cleanup 任务补 upload meta 失效钩子
This commit is contained in:
ryan
2026-06-20 10:17:50 +08:00
parent 8c872f9b32
commit cdac1f8a45
23 changed files with 1511 additions and 20 deletions
+269
View File
@@ -0,0 +1,269 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
)
const (
authSourceActiveRedisKey = "oauth:auth_sources:active"
authSourceByNameRedisKeyFmt = "oauth:auth_sources:by_name:%s"
authSourceByNameRedisPattern = "oauth:auth_sources:by_name:*"
authSourceActiveRAMKey = "active"
authSourceCacheTTL = time.Hour
authSourceRAMMaximumSize = 64
authSourceInvalidationChannel = "oauth:auth_source_invalidation"
)
// authSourceRedisRecord persists full auth source credentials in Redis.
type authSourceRedisRecord struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
Scopes string `json:"scopes"`
IconURL string `json:"icon_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClientSecretConfigured bool `json:"client_secret_configured"`
}
var (
authSourceActiveRAM = ram.MustNew[string, []model.AuthSource](ram.Options{MaximumSize: authSourceRAMMaximumSize})
authSourceByNameRAM = ram.MustNew[string, model.AuthSource](ram.Options{MaximumSize: authSourceRAMMaximumSize})
authSourceListenerOnce sync.Once
authSourceListenerCtx context.Context
authSourceListenerCancel context.CancelFunc
)
func cloneAuthSources(sources []model.AuthSource) []model.AuthSource {
if len(sources) == 0 {
return nil
}
cloned := make([]model.AuthSource, len(sources))
copy(cloned, sources)
return cloned
}
func cloneAuthSource(source model.AuthSource) model.AuthSource {
return source
}
func normalizeAuthSourceName(name string) string {
return strings.TrimSpace(strings.ToLower(name))
}
func authSourceByNameRedisKey(name string) string {
return fmt.Sprintf(authSourceByNameRedisKeyFmt, normalizeAuthSourceName(name))
}
func authSourceToRedisRecord(source model.AuthSource) authSourceRedisRecord {
return authSourceRedisRecord{
ID: source.ID,
Name: source.Name,
Type: source.Type,
DisplayName: source.DisplayName,
IsActive: source.IsActive,
ClientID: source.ClientID,
ClientSecret: source.ClientSecret,
OpenIDDiscoveryURL: source.OpenIDDiscoveryURL,
Scopes: source.Scopes,
IconURL: source.IconURL,
CreatedAt: source.CreatedAt,
UpdatedAt: source.UpdatedAt,
ClientSecretConfigured: source.ClientSecretConfigured,
}
}
func redisRecordToAuthSource(record authSourceRedisRecord) model.AuthSource {
return model.AuthSource{
ID: record.ID,
Name: record.Name,
Type: record.Type,
DisplayName: record.DisplayName,
IsActive: record.IsActive,
ClientID: record.ClientID,
ClientSecret: record.ClientSecret,
OpenIDDiscoveryURL: record.OpenIDDiscoveryURL,
Scopes: record.Scopes,
IconURL: record.IconURL,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
ClientSecretConfigured: record.ClientSecretConfigured,
}
}
func ensureAuthSourceCacheListener() {
if db.Redis == nil {
return
}
authSourceListenerOnce.Do(startAuthSourceCacheInvalidationListener)
}
func startAuthSourceCacheInvalidationListener() {
authSourceListenerCtx, authSourceListenerCancel = context.WithCancel(context.Background())
go func() {
pubsub := db.Redis.Subscribe(authSourceListenerCtx, authSourceInvalidationChannel)
defer func() {
_ = pubsub.Close()
}()
go func() {
<-authSourceListenerCtx.Done()
_ = pubsub.Close()
}()
for range pubsub.Channel() {
authSourceActiveRAM.InvalidateAll()
authSourceByNameRAM.InvalidateAll()
}
}()
}
func publishAuthSourceRAMInvalidation(ctx context.Context) {
if db.Redis == nil {
return
}
_ = db.Redis.Publish(ctx, authSourceInvalidationChannel, "reset").Err()
}
func populateActiveAuthSourceCache(ctx context.Context, sources []model.AuthSource) {
cloned := cloneAuthSources(sources)
authSourceActiveRAM.Set(authSourceActiveRAMKey, cloned)
if db.Redis != nil {
_ = db.SetJSON(ctx, authSourceActiveRedisKey, cloned, authSourceCacheTTL)
}
}
func populateAuthSourceByNameCache(ctx context.Context, name string, source *model.AuthSource) {
if source == nil {
return
}
cloned := cloneAuthSource(*source)
authSourceByNameRAM.Set(normalizeAuthSourceName(name), cloned)
if db.Redis != nil {
record := authSourceToRedisRecord(cloned)
_ = db.SetJSON(ctx, authSourceByNameRedisKey(name), record, authSourceCacheTTL)
}
}
// GetActiveAuthSourcesCached returns active auth sources from RAM, Redis, or the database.
func GetActiveAuthSourcesCached(ctx context.Context) ([]model.AuthSource, error) {
ensureAuthSourceCacheListener()
if sources, ok := authSourceActiveRAM.GetIfPresent(authSourceActiveRAMKey); ok {
return cloneAuthSources(sources), nil
}
if db.Redis != nil {
var sources []model.AuthSource
if err := db.GetJSON(ctx, authSourceActiveRedisKey, &sources); err == nil {
populateActiveAuthSourceCache(ctx, sources)
return cloneAuthSources(sources), nil
}
}
sources, err := model.GetActiveAuthSources(ctx)
if err != nil {
return nil, err
}
populateActiveAuthSourceCache(ctx, sources)
return cloneAuthSources(sources), nil
}
// GetAuthSourceByNameCached returns an auth source by name from RAM, Redis, or the database.
func GetAuthSourceByNameCached(ctx context.Context, name string) (*model.AuthSource, error) {
ensureAuthSourceCacheListener()
normalized := normalizeAuthSourceName(name)
if normalized == "" {
return model.GetAuthSourceByName(ctx, name)
}
if source, ok := authSourceByNameRAM.GetIfPresent(normalized); ok {
cloned := cloneAuthSource(source)
return &cloned, nil
}
if db.Redis != nil {
var record authSourceRedisRecord
if err := db.GetJSON(ctx, authSourceByNameRedisKey(name), &record); err == nil {
source := redisRecordToAuthSource(record)
populateAuthSourceByNameCache(ctx, name, &source)
cloned := cloneAuthSource(source)
return &cloned, nil
}
}
source, err := model.GetAuthSourceByName(ctx, name)
if err != nil {
return nil, err
}
populateAuthSourceByNameCache(ctx, name, source)
cloned := cloneAuthSource(*source)
return &cloned, nil
}
// InvalidateAuthSourceCache clears active and per-name auth source caches from RAM and Redis.
func InvalidateAuthSourceCache(ctx context.Context) error {
ensureAuthSourceCacheListener()
authSourceActiveRAM.InvalidateAll()
authSourceByNameRAM.InvalidateAll()
if db.Redis == nil {
return nil
}
if err := db.Redis.Del(ctx, db.PrefixedKey(authSourceActiveRedisKey)).Err(); err != nil {
return err
}
pattern := db.PrefixedKey(authSourceByNameRedisPattern)
iter := db.Redis.Scan(ctx, 0, pattern, 0).Iterator()
var keys []string
for iter.Next(ctx) {
keys = append(keys, iter.Val())
}
if err := iter.Err(); err != nil {
return err
}
if len(keys) > 0 {
if err := db.Redis.Del(ctx, keys...).Err(); err != nil {
return err
}
}
publishAuthSourceRAMInvalidation(ctx)
return nil
}
// StopAuthSourceCacheListener stops the Redis Pub/Sub subscription listener and resets the sync.Once guard.
func StopAuthSourceCacheListener() {
if authSourceListenerCancel != nil {
authSourceListenerCancel()
authSourceListenerCancel = nil
}
authSourceListenerOnce = sync.Once{}
}
// ResetAuthSourceRAMCacheForTest clears only the process-local RAM cache.
func ResetAuthSourceRAMCacheForTest() {
authSourceActiveRAM.InvalidateAll()
authSourceByNameRAM.InvalidateAll()
}
@@ -0,0 +1,240 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/glebarez/sqlite"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
"gorm.io/gorm"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
)
func setupAuthSourceCacheTest(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
t.Fatalf("failed to open in-memory SQLite db: %v", err)
}
if err := sqliteDB.AutoMigrate(&model.AuthSource{}); err != nil {
t.Fatalf("failed to migrate auth sources: %v", err)
}
miniRedis, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
db.SetDB(sqliteDB)
db.Redis = redis.NewClient(&redis.Options{
Addr: miniRedis.Addr(),
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
})
ResetAuthSourceRAMCacheForTest()
cleanup := func() {
StopAuthSourceCacheListener()
ResetAuthSourceRAMCacheForTest()
db.Redis.Close()
miniRedis.Close()
db.Redis = nil
}
return sqliteDB, miniRedis, cleanup
}
func TestGetActiveAuthSourcesCached_LoadsFromRedisBeforeDB(t *testing.T) {
dbConn, _, cleanup := setupAuthSourceCacheTest(t)
defer cleanup()
ctx := context.Background()
if err := InvalidateAuthSourceCache(ctx); err != nil {
t.Fatalf("InvalidateAuthSourceCache() error = %v", err)
}
source := model.AuthSource{
Name: "cached-source",
Type: model.AuthSourceTypeOIDC,
DisplayName: "Cached Source",
IsActive: true,
ClientID: "client-id",
ClientSecret: "client-secret",
OpenIDDiscoveryURL: "https://issuer.example.com",
}
if err := model.CreateAuthSource(ctx, &source); err != nil {
t.Fatalf("CreateAuthSource() error = %v", err)
}
warmed, err := GetActiveAuthSourcesCached(ctx)
if err != nil {
t.Fatalf("GetActiveAuthSourcesCached() warm error = %v", err)
}
if len(warmed) == 0 || warmed[0].Name != source.Name {
t.Fatalf("GetActiveAuthSourcesCached() warm = %#v, want source %q", warmed, source.Name)
}
if err := dbConn.Delete(&model.AuthSource{}, "id = ?", source.ID).Error; err != nil {
t.Fatalf("Delete(auth source) error = %v", err)
}
ResetAuthSourceRAMCacheForTest()
cached, err := GetActiveAuthSourcesCached(ctx)
if err != nil {
t.Fatalf("GetActiveAuthSourcesCached() cached error = %v", err)
}
if len(cached) == 0 || cached[0].Name != source.Name {
t.Fatalf("GetActiveAuthSourcesCached() = %#v, want redis-backed source %q", cached, source.Name)
}
}
func TestGetAuthSourceByNameCached_LoadsFromRedisBeforeDB(t *testing.T) {
dbConn, _, cleanup := setupAuthSourceCacheTest(t)
defer cleanup()
ctx := context.Background()
if err := InvalidateAuthSourceCache(ctx); err != nil {
t.Fatalf("InvalidateAuthSourceCache() error = %v", err)
}
source := model.AuthSource{
Name: "by-name-source",
Type: model.AuthSourceTypeOIDC,
DisplayName: "By Name Source",
IsActive: true,
ClientID: "client-id",
ClientSecret: "client-secret",
OpenIDDiscoveryURL: "https://issuer.example.com",
}
if err := model.CreateAuthSource(ctx, &source); err != nil {
t.Fatalf("CreateAuthSource() error = %v", err)
}
warmed, err := GetAuthSourceByNameCached(ctx, source.Name)
if err != nil {
t.Fatalf("GetAuthSourceByNameCached() warm error = %v", err)
}
if warmed.Name != source.Name || warmed.ClientSecret != source.ClientSecret {
t.Fatalf("GetAuthSourceByNameCached() warm = %#v, want %#v", warmed, source)
}
if err := dbConn.Delete(&model.AuthSource{}, "id = ?", source.ID).Error; err != nil {
t.Fatalf("Delete(auth source) error = %v", err)
}
ResetAuthSourceRAMCacheForTest()
cached, err := GetAuthSourceByNameCached(ctx, source.Name)
if err != nil {
t.Fatalf("GetAuthSourceByNameCached() cached error = %v", err)
}
if cached.Name != source.Name || cached.ClientSecret != source.ClientSecret {
t.Fatalf("GetAuthSourceByNameCached() = %#v, want redis-backed source %#v", cached, source)
}
}
func TestInvalidateAuthSourceCache_ClearsRedisKeys(t *testing.T) {
_, _, cleanup := setupAuthSourceCacheTest(t)
defer cleanup()
ctx := context.Background()
if err := InvalidateAuthSourceCache(ctx); err != nil {
t.Fatalf("InvalidateAuthSourceCache() initial error = %v", err)
}
source := model.AuthSource{
Name: "invalidate-source",
Type: model.AuthSourceTypeOIDC,
DisplayName: "Invalidate Source",
IsActive: true,
ClientID: "client-id",
ClientSecret: "client-secret",
OpenIDDiscoveryURL: "https://issuer.example.com",
}
if err := model.CreateAuthSource(ctx, &source); err != nil {
t.Fatalf("CreateAuthSource() error = %v", err)
}
if _, err := GetActiveAuthSourcesCached(ctx); err != nil {
t.Fatalf("GetActiveAuthSourcesCached() warm error = %v", err)
}
if _, err := GetAuthSourceByNameCached(ctx, source.Name); err != nil {
t.Fatalf("GetAuthSourceByNameCached() warm error = %v", err)
}
if err := InvalidateAuthSourceCache(ctx); err != nil {
t.Fatalf("InvalidateAuthSourceCache() error = %v", err)
}
activeExists, err := db.Redis.Exists(ctx, db.PrefixedKey(authSourceActiveRedisKey)).Result()
if err != nil {
t.Fatalf("Exists(active key) error = %v", err)
}
if activeExists != 0 {
t.Fatalf("active redis key still exists after invalidation")
}
byNameExists, err := db.Redis.Exists(ctx, db.PrefixedKey(authSourceByNameRedisKey(source.Name))).Result()
if err != nil {
t.Fatalf("Exists(by-name key) error = %v", err)
}
if byNameExists != 0 {
t.Fatalf("by-name redis key still exists after invalidation")
}
}
func TestAuthSourceInvalidationPubSubClearsPeerRAM(t *testing.T) {
dbConn, _, cleanup := setupAuthSourceCacheTest(t)
defer cleanup()
ctx := context.Background()
source := model.AuthSource{
Name: "pubsub-source",
Type: model.AuthSourceTypeOIDC,
DisplayName: "PubSub Source",
IsActive: true,
ClientID: "client-id",
ClientSecret: "client-secret",
OpenIDDiscoveryURL: "https://issuer.example.com",
}
if err := model.CreateAuthSource(ctx, &source); err != nil {
t.Fatalf("CreateAuthSource() error = %v", err)
}
if _, err := GetActiveAuthSourcesCached(ctx); err != nil {
t.Fatalf("GetActiveAuthSourcesCached() error = %v", err)
}
if err := dbConn.Delete(&model.AuthSource{}, "id = ?", source.ID).Error; err != nil {
t.Fatalf("Delete(auth source) error = %v", err)
}
if _, err := GetActiveAuthSourcesCached(ctx); err != nil {
t.Fatalf("expected RAM cache hit before pub/sub invalidation: %v", err)
}
if err := db.Redis.Publish(ctx, authSourceInvalidationChannel, "reset").Err(); err != nil {
t.Fatalf("publish invalidation: %v", err)
}
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
if _, ok := authSourceActiveRAM.GetIfPresent(authSourceActiveRAMKey); !ok {
break
}
time.Sleep(10 * time.Millisecond)
}
if _, ok := authSourceActiveRAM.GetIfPresent(authSourceActiveRAMKey); ok {
t.Fatal("expected peer RAM cache to be cleared by pub/sub")
}
}
+16
View File
@@ -75,6 +75,22 @@ func ListSystemConfigsByKeys(ctx context.Context, keys []string) (map[string]mod
missing = append(missing, key)
}
if len(missing) > 0 && db.Redis != nil {
stillMissing := make([]string, 0, len(missing))
for _, key := range missing {
var sc model.SystemConfig
if err := db.HGetJSON(ctx, SystemConfigRedisHashKey, key, &sc); err == nil {
systemConfigRAMCache.Set(key, cloneSystemConfig(sc))
result[key] = sc
continue
} else if !errors.Is(err, redis.Nil) {
return nil, err
}
stillMissing = append(stillMissing, key)
}
missing = stillMissing
}
if len(missing) == 0 {
return result, nil
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/alicebob/miniredis/v2"
"github.com/glebarez/sqlite"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
"gorm.io/gorm"
)
func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
t.Fatalf("gorm.Open(sqlite) error = %v", err)
}
if err := sqliteDB.AutoMigrate(&model.SystemConfig{}); err != nil {
t.Fatalf("AutoMigrate(SystemConfig) error = %v", err)
}
siteConfig := model.SystemConfig{
Key: model.ConfigKeySiteName,
Value: "Wavelet",
Type: "system",
Description: "系统平台的展示名称",
}
if err := sqliteDB.Create(&siteConfig).Error; err != nil {
t.Fatalf("Create(site_name) error = %v", err)
}
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("miniredis.Run() error = %v", err)
}
redisClient := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
})
previousRedis := db.Redis
db.SetDB(sqliteDB)
db.Redis = redisClient
cleanup := func() {
StopSystemConfigCacheListener()
ResetSystemConfigRAMCacheForTest()
db.SetDB(nil)
db.Redis = previousRedis
_ = redisClient.Close()
mr.Close()
}
return sqliteDB, cleanup
}
func TestListSystemConfigsByKeys_EmptyKeys(t *testing.T) {
result, err := ListSystemConfigsByKeys(context.Background(), nil)
if err != nil {
t.Fatalf("ListSystemConfigsByKeys(nil) error = %v", err)
}
if len(result) != 0 {
t.Fatalf("ListSystemConfigsByKeys(nil) = %#v, want empty map", result)
}
}
func TestListSystemConfigsByKeys_LoadsFromRedisBeforeDB(t *testing.T) {
dbConn, cleanup := setupSystemConfigTest(t)
defer cleanup()
ctx := context.Background()
ResetSystemConfigRAMCacheForTest()
if err := InvalidateAllSystemConfigCaches(ctx); err != nil {
t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err)
}
warm, err := GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
if err != nil {
t.Fatalf("GetSystemConfigByKey(site_name) warm error = %v", err)
}
if warm.Value != "Wavelet" {
t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", warm.Value, "Wavelet")
}
if err := dbConn.Model(&model.SystemConfig{}).
Where("key = ?", model.ConfigKeySiteName).
Update("value", "db_only_value").Error; err != nil {
t.Fatalf("Update(site_name) error = %v", err)
}
ResetSystemConfigRAMCacheForTest()
configs, err := ListSystemConfigsByKeys(ctx, []string{model.ConfigKeySiteName})
if err != nil {
t.Fatalf("ListSystemConfigsByKeys(site_name) error = %v", err)
}
sc, ok := configs[model.ConfigKeySiteName]
if !ok {
t.Fatal("ListSystemConfigsByKeys(site_name) missing site_name entry")
}
if sc.Value != "Wavelet" {
t.Fatalf("ListSystemConfigsByKeys(site_name).Value = %q, want redis value %q", sc.Value, "Wavelet")
}
}
func TestListSystemConfigsByKeys_PopulatesRAMFromRedis(t *testing.T) {
_, cleanup := setupSystemConfigTest(t)
defer cleanup()
ctx := context.Background()
ResetSystemConfigRAMCacheForTest()
if err := InvalidateAllSystemConfigCaches(ctx); err != nil {
t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err)
}
if _, err := GetSystemConfigByKey(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("GetSystemConfigByKey(site_name) warm error = %v", err)
}
ResetSystemConfigRAMCacheForTest()
if _, err := ListSystemConfigsByKeys(ctx, []string{model.ConfigKeySiteName}); err != nil {
t.Fatalf("ListSystemConfigsByKeys(site_name) error = %v", err)
}
cached, ok := systemConfigRAMCache.GetIfPresent(model.ConfigKeySiteName)
if !ok {
t.Fatal("expected RAM cache to be populated after redis hit")
}
if cached.Value != "Wavelet" {
t.Fatalf("RAM cache value = %q, want %q", cached.Value, "Wavelet")
}
}
+12 -2
View File
@@ -65,7 +65,12 @@ func GetActiveUploadByID(ctx context.Context, id uint64) (model.Upload, error) {
// SoftDeleteUpload marks an upload as deleted.
// External modules must use upload.Remove or upload.RemoveOwned; only internal/apps/upload may call this.
func SoftDeleteUpload(ctx context.Context, upload *model.Upload) error {
return db.DB(ctx).Model(upload).Update("status", model.UploadStatusDeleted).Error
return SoftDeleteUploadTx(db.DB(ctx), upload)
}
// SoftDeleteUploadTx marks an upload as deleted within an existing transaction.
func SoftDeleteUploadTx(tx *gorm.DB, upload *model.Upload) error {
return tx.Model(upload).Update("status", model.UploadStatusDeleted).Error
}
// UpdateUpload applies partial field updates to an upload record.
@@ -100,7 +105,12 @@ func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (mod
// CreateUpload persists a new upload record.
// External modules must use upload.Ingest; only internal/apps/upload may call this.
func CreateUpload(ctx context.Context, upload *model.Upload) error {
return db.DB(ctx).Create(upload).Error
return CreateUploadTx(db.DB(ctx), upload)
}
// CreateUploadTx persists a new upload record within an existing transaction.
func CreateUploadTx(tx *gorm.DB, upload *model.Upload) error {
return tx.Create(upload).Error
}
// ListUploadsByIDs returns active uploads matching the given IDs.