added s3 support

This commit is contained in:
onysd 2026-08-05 02:34:24 +03:00
parent 03f10b66ee
commit 45f79148c2
12 changed files with 341 additions and 40 deletions

View file

@ -0,0 +1,173 @@
package files
import (
"context"
"os"
"testing"
"telesrv/internal/domain"
)
// fakeNamedBackend is a minimal BlobBackend double with a settable Name(),
// used to prove Service.backendFor's routing logic without needing two real
// backend implementations (LocalFS always reports "localfs").
type fakeNamedBackend struct {
BlobBackend
name string
}
func (f fakeNamedBackend) Name() string { return f.name }
// TestBackendForRoutesToTheBackendThatActuallyWroteTheBlob proves the core
// safety property behind switching TELESRV_BLOB_BACKEND: a row whose
// recorded backend differs from the currently active one must still
// resolve to the backend that actually holds its bytes (via
// WithAdditionalBlobBackend), not silently fall through to the active one
// (which would 404 -- the row/bytes still exist, but the code would be
// looking in the wrong place).
func TestBackendForRoutesToTheBackendThatActuallyWroteTheBlob(t *testing.T) {
active := fakeNamedBackend{name: "s3"}
old := fakeNamedBackend{name: "localfs"}
s := NewService(nil, active, 2, WithAdditionalBlobBackend(old))
got, err := s.backendFor(domain.MediaBackendS3)
if err != nil || got.Name() != "s3" {
t.Fatalf("resolve active backend: got %v err=%v, want s3", got, err)
}
got, err = s.backendFor(domain.MediaBackendLocalFS)
if err != nil || got.Name() != "localfs" {
t.Fatalf("resolve registered old backend: got %v err=%v, want localfs", got, err)
}
// A row predating the owner/backend column (empty string) must not be
// treated as unreachable -- it falls back to the active backend, which
// is where every pre-migration row's bytes actually are.
got, err = s.backendFor("")
if err != nil || got.Name() != "s3" {
t.Fatalf("resolve empty/legacy backend: got %v err=%v, want active s3", got, err)
}
// A backend that was never configured (e.g. its config was wiped after
// switching away from it) must fail loudly, not silently read from the
// wrong place.
if _, err := s.backendFor("gcs"); err == nil {
t.Fatal("expected an error resolving an unconfigured backend, got nil")
}
}
// TestServiceGetFileReadsFromRegisteredOldBackendAfterSwitch is the
// end-to-end version: a Service whose active backend is A must still serve
// a file whose file_blobs row says it lives on B, as long as B was
// registered via WithAdditionalBlobBackend -- exactly the "I switched
// TELESRV_BLOB_BACKEND, did my old files disappear?" scenario.
func TestServiceGetFileReadsFromRegisteredOldBackendAfterSwitch(t *testing.T) {
ctx := context.Background()
oldBackend, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("new old backend: %v", err)
}
newBackend, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("new active backend: %v", err)
}
// Give the "old" backend a distinct name so backendFor can tell them
// apart, the way a real second BlobBackend implementation would.
oldNamed := fakeNamedBackend{BlobBackend: oldBackend, name: "old-backend"}
data := []byte("file written before the backend switch")
objectKey, err := oldBackend.Put(ctx, data)
if err != nil {
t.Fatalf("write to old backend: %v", err)
}
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:1",
Backend: "old-backend",
ObjectKey: objectKey,
Size: int64(len(data)),
}); err != nil {
t.Fatalf("put file blob metadata: %v", err)
}
// Active backend is the NEW one; the old one is only reachable via the
// registry -- mirrors production after a TELESRV_BLOB_BACKEND switch.
s := NewService(media, newBackend, 2, WithAdditionalBlobBackend(oldNamed))
chunk, found, err := s.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:1", Limit: 1 << 20})
if err != nil {
t.Fatalf("get file after backend switch: %v", err)
}
if !found {
t.Fatal("file written before the switch was not found -- it would appear to have vanished")
}
if string(chunk.Bytes) != string(data) {
t.Fatalf("chunk = %q, want %q", chunk.Bytes, data)
}
}
// TestBackendSwitchRoundTripsThroughRealMinIO exercises the actual
// scenario the user asked about: files written while s3 (MinIO) was active
// stay readable after switching to localfs, and files written while
// localfs was active stay readable after switching to s3 -- both directions,
// against a real MinIO instance (deploy/docker-compose.yml's "minio"
// service). Skips if MinIO isn't reachable at the default dev endpoint.
func TestBackendSwitchRoundTripsThroughRealMinIO(t *testing.T) {
endpoint := os.Getenv("TELESRV_TEST_S3_ENDPOINT")
if endpoint == "" {
endpoint = "localhost:9000"
}
ctx := context.Background()
s3, err := NewS3FS(ctx, endpoint, "owpengram", "owpengram123", "telesrv-test-backend-switch", "us-east-1", false, true)
if err != nil {
t.Skipf("minio not reachable at %s (start deploy/docker-compose.yml's minio service to run this test): %v", endpoint, err)
}
local, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("new local backend: %v", err)
}
t.Run("s3_active_localfs_switch", func(t *testing.T) {
s3Data := []byte("written while s3 was the active backend")
s3Key, err := s3.Put(ctx, s3Data)
if err != nil {
t.Fatalf("write to s3: %v", err)
}
t.Cleanup(func() { _ = s3.Delete(ctx, s3Key) })
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:1", Backend: domain.MediaBackendS3, ObjectKey: s3Key, Size: int64(len(s3Data)),
}); err != nil {
t.Fatalf("put file blob: %v", err)
}
// Switched: localfs is now active, s3 kept reachable as the old backend.
s := NewService(media, local, 2, WithAdditionalBlobBackend(s3))
chunk, found, err := s.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:1", Limit: 1 << 20})
if err != nil || !found || string(chunk.Bytes) != string(s3Data) {
t.Fatalf("read s3-written file after switching to localfs: found=%v err=%v bytes=%q, want %q", found, err, chunk.Bytes, s3Data)
}
})
t.Run("localfs_active_s3_switch", func(t *testing.T) {
localData := []byte("written while localfs was the active backend")
localKey, err := local.Put(ctx, localData)
if err != nil {
t.Fatalf("write to localfs: %v", err)
}
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:2", Backend: domain.MediaBackendLocalFS, ObjectKey: localKey, Size: int64(len(localData)),
}); err != nil {
t.Fatalf("put file blob: %v", err)
}
// Switched: s3 is now active, localfs kept reachable as the old backend.
s := NewService(media, s3, 2, WithAdditionalBlobBackend(local))
chunk, found, err := s.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:2", Limit: 1 << 20})
if err != nil || !found || string(chunk.Bytes) != string(localData) {
t.Fatalf("read localfs-written file after switching to s3: found=%v err=%v bytes=%q, want %q", found, err, chunk.Bytes, localData)
}
})
}

View file

@ -34,7 +34,11 @@ func (s *Service) DocumentAnimationJSON(ctx context.Context, documentID int64) (
if !found || blob.Size <= 0 || blob.Size > maxEmojiAnimationBytes {
return nil, false, nil
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
backend, err := s.backendFor(blob.Backend)
if err != nil {
return nil, false, err
}
data, total, err := backend.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil {
return nil, false, err
}

View file

@ -1096,7 +1096,12 @@ func (s *Service) readSmallBlob(ctx context.Context, locationKey string, expecte
if !found || blob.Size <= 0 || blob.Size > avatarMarkupMaxSourceBytes {
return nil, false
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
backend, err := s.backendFor(blob.Backend)
if err != nil {
s.log.Warn("resolve avatar markup blob backend failed", zap.String("location_key", locationKey), zap.Error(err))
return nil, false
}
data, total, err := backend.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil {
s.log.Warn("read avatar markup blob failed",
zap.String("location_key", locationKey),

View file

@ -67,11 +67,13 @@ func (s *Service) DeleteOrphanedOlderThan(ctx context.Context, cutoff time.Time,
// deleteOrphanedBlobs removes each blob from its backend once confirming
// (via CountFileBlobRefs) no other file_blobs row still references
// (backend, object_key). Only blobs on the currently active backend are
// physically removed -- a blob left over from a previously active backend
// (deployment switched TELESRV_BLOB_BACKEND at some point; switching back
// isn't supported) is logged and skipped rather than silently dropped,
// since there's no configured client to reach it right now anyway.
// (backend, object_key). Resolves the correct backend per blob via
// backendFor (not just the currently active one) -- a blob written before a
// TELESRV_BLOB_BACKEND switch still needs deleting from wherever it
// actually lives. If that backend is no longer configured (its credentials
// were removed after switching away from it), the blob is logged and
// skipped rather than silently dropped, since there's nothing reachable to
// delete it from.
func (s *Service) deleteOrphanedBlobs(ctx context.Context, store mediaRetentionStore, blobs []domain.FileBlob) {
for _, b := range blobs {
refs, err := store.CountFileBlobRefs(ctx, string(b.Backend), b.ObjectKey)
@ -82,12 +84,13 @@ func (s *Service) deleteOrphanedBlobs(ctx context.Context, store mediaRetentionS
if refs > 0 {
continue
}
if string(b.Backend) != s.blobs.Name() {
s.log.Warn("orphaned blob is on an inactive backend, skipping physical delete",
zap.String("backend", string(b.Backend)), zap.String("object_key", b.ObjectKey))
backend, err := s.backendFor(b.Backend)
if err != nil {
s.log.Warn("orphaned blob's backend is not configured, skipping physical delete",
zap.String("backend", string(b.Backend)), zap.String("object_key", b.ObjectKey), zap.Error(err))
continue
}
if err := s.blobs.Delete(ctx, b.ObjectKey); err != nil {
if err := backend.Delete(ctx, b.ObjectKey); err != nil {
s.log.Warn("delete orphaned blob failed", zap.String("object_key", b.ObjectKey), zap.Error(err))
}
}

View file

@ -48,7 +48,14 @@ const (
type Service struct {
media store.MediaStore
blobs BlobBackend
uploadParts UploadPartBackend
// otherBackends holds additional, non-active BlobBackend instances keyed
// by Name() (e.g. "localfs" while s3 is active, or vice versa). Only
// used for reading/deleting rows written before the deployment switched
// TELESRV_BLOB_BACKEND -- new writes always go through blobs. Without
// this, a backend switch would make every pre-switch file unreadable
// (the row/bytes still exist, but nothing would know how to reach them).
otherBackends map[string]BlobBackend
uploadParts UploadPartBackend
dc int
log *zap.Logger
thumbs VideoThumbnailer
@ -127,6 +134,23 @@ func WithSpaceGuard(guard SpaceGuard) Option {
}
}
// WithAdditionalBlobBackend registers a non-active blob backend purely for
// reading/deleting rows written while it used to be active. Register the
// previous backend here whenever TELESRV_BLOB_BACKEND changes and the old
// backend's storage/credentials are still reachable -- otherwise every file
// written before the switch becomes unreadable (see Service.otherBackends).
func WithAdditionalBlobBackend(backend BlobBackend) Option {
return func(s *Service) {
if backend == nil {
return
}
if s.otherBackends == nil {
s.otherBackends = make(map[string]BlobBackend, 1)
}
s.otherBackends[backend.Name()] = backend
}
}
// WithUploadPartBackend overrides where transient upload-part chunks are
// staged before assembly, independent of the permanent blob backend passed
// to NewService. Needed when the permanent backend is s3 (S3FS doesn't
@ -190,6 +214,22 @@ func NewService(media store.MediaStore, blobs BlobBackend, dc int, opts ...Optio
return s
}
// backendFor resolves the BlobBackend that actually holds a given blob row,
// which is not necessarily the currently active s.blobs -- a row written
// before a TELESRV_BLOB_BACKEND switch stays on whichever backend wrote it.
// Falls back to the active backend for an empty/unset name (older rows
// predating this field) rather than failing a previously-working read.
func (s *Service) backendFor(backend domain.MediaBackend) (BlobBackend, error) {
name := string(backend)
if name == "" || name == s.blobs.Name() {
return s.blobs, nil
}
if b, ok := s.otherBackends[name]; ok {
return b, nil
}
return nil, fmt.Errorf("blob backend %q is not configured (was TELESRV_BLOB_BACKEND changed without keeping the old backend reachable?)", name)
}
// SaveFilePart 累积一个 small file 分片。
func (s *Service) SaveFilePart(ctx context.Context, ownerUserID, fileID int64, part int, bytes []byte) (bool, error) {
if err := validatePart(part, len(bytes)); err != nil {
@ -390,6 +430,10 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
}
blob = res.blob
}
backend, err := s.backendFor(blob.Backend)
if err != nil {
return domain.FileChunk{}, false, err
}
if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes {
cacheLog.byteCacheEligible = true
if data, ok := s.byteCache.get(blob.ObjectKey); ok {
@ -406,7 +450,7 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
if cached, ok := s.byteCache.get(blob.ObjectKey); ok {
return blobBytesResult{data: cached, total: int64(len(cached)), cacheable: true, cacheHit: true}, nil
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
data, total, err := backend.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
if err != nil {
return blobBytesResult{}, err
}
@ -443,7 +487,7 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
if cacheLog.source == "unknown" {
cacheLog.source = "backend_range"
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit))
data, total, err := backend.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit))
if err != nil {
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
}

View file

@ -404,7 +404,11 @@ func (s *Service) readStickerMaterialBlob(ctx context.Context, doc domain.Docume
if err != nil || !found || blob.Size <= 0 || blob.Size > domain.MaxStickerMaterialDocumentSize {
return nil, false
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
backend, err := s.backendFor(blob.Backend)
if err != nil {
return nil, false
}
data, total, err := backend.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil || int64(len(data)) != total || total != blob.Size {
return nil, false
}

View file

@ -121,7 +121,14 @@ func (s *Service) warmBlobBytes(ctx context.Context, blob domain.FileBlob) (bool
if blob.Size <= 0 || blob.Size > blobBytesCacheMaxEntryBytes || s.byteCache.has(blob.ObjectKey) {
return false, nil
}
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
backend, err := s.backendFor(blob.Backend)
if err != nil {
// Warmup is a pure optimization; a blob left on a backend that's no
// longer configured just stays uncached here -- GetFile's own
// backendFor call surfaces the real error if it's ever requested.
return false, nil
}
data, total, err := backend.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
if err != nil {
return false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
}