From ac85dfd6fda3487e32d0ab098326ca38a0ea19fc Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Thu, 25 Jul 2024 11:01:37 +0100 Subject: [PATCH] clients: Implement storage fallback for recordings (#1303) * config: Create storage-fallback-urls flag * config: Make the StorageFallbackURLs a global * clients: Download manifest from backup OS * clients: Download segments from backup URL * [WIP] clients: Use storage fallback for probing segments too This is incomplete as we still need to fix the probing made on the manifest itself instead of just on each segment. Rabbit hole. * [DEV-revert] box-only: Start a local proxy server to simulate an unreliable storage Just proxies to Minio with a random error chance * Add cucumber test * Refactor * remove comment * fix unit test. (cukes still need fixing) * Fix cukes * add unit tests * Address TODOs and review comments * return variable no longer needed * review comments * Review comments --------- Co-authored-by: Max Holland --- clients/input_copy.go | 24 +++ clients/manifest.go | 171 +++++++++++++++++--- clients/manifest_test.go | 134 ++++++++++++++- config/cli.go | 1 + config/config.go | 2 + config/storage_backup_url.go | 15 ++ config/storage_backup_url_test.go | 31 ++++ go.mod | 5 +- go.sum | 8 + main.go | 3 + pipeline/coordinator.go | 8 +- pipeline/ffmpeg.go | 2 +- test/features/vod.feature | 36 +++-- test/fixtures/rec-fallback-bucket/seg-3.ts | Bin 0 -> 188376 bytes test/fixtures/rec-fallback-bucket/tiny.m3u8 | 13 ++ test/steps/ffmpeg.go | 48 ++++-- test/steps/http.go | 13 +- test/steps/init.go | 80 ++++++++- thumbnails/thumbnails.go | 37 +---- 19 files changed, 526 insertions(+), 105 deletions(-) create mode 100644 config/storage_backup_url.go create mode 100644 config/storage_backup_url_test.go create mode 100644 test/fixtures/rec-fallback-bucket/seg-3.ts create mode 100644 test/fixtures/rec-fallback-bucket/tiny.m3u8 diff --git a/clients/input_copy.go b/clients/input_copy.go index 4c6ba3751..76956c18f 100644 --- a/clients/input_copy.go +++ b/clients/input_copy.go @@ -287,6 +287,30 @@ func GetFile(ctx context.Context, requestID, url string, dStorage *DStorageDownl } } +func GetFileWithBackup(ctx context.Context, requestID, url string, dStorage *DStorageDownload) (io.ReadCloser, string, error) { + rc, err := GetFile(ctx, requestID, url, dStorage) + if err == nil { + return rc, url, nil + } + + backupURL := config.GetStorageBackupURL(url) + if backupURL == "" { + return nil, url, err + } + rc, backupErr := GetFile(ctx, requestID, backupURL, dStorage) + if backupErr == nil { + return rc, backupURL, nil + } + + // prioritize retriable errors in the response so we don't skip retries + if !catErrs.IsUnretriable(err) { + return nil, url, err + } else if !catErrs.IsUnretriable(backupErr) { + return nil, backupURL, backupErr + } + return nil, url, err +} + var retryableHttpClient = newRetryableHttpClient() func newRetryableHttpClient() *http.Client { diff --git a/clients/manifest.go b/clients/manifest.go index 99fa35cab..8337d8044 100644 --- a/clients/manifest.go +++ b/clients/manifest.go @@ -1,6 +1,7 @@ package clients import ( + "bytes" "context" "fmt" "io" @@ -11,19 +12,23 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/cenkalti/backoff/v4" "github.com/grafov/m3u8" + "github.com/livepeer/catalyst-api/config" + "github.com/livepeer/catalyst-api/errors" "github.com/livepeer/catalyst-api/video" ) const ( - MasterManifestFilename = "index.m3u8" - DashManifestFilename = "index.mpd" - ClipManifestFilename = "clip.m3u8" - ManifestUploadTimeout = 5 * time.Minute - Fmp4PostfixDir = "fmp4" + MasterManifestFilename = "index.m3u8" + DashManifestFilename = "index.mpd" + ClipManifestFilename = "clip.m3u8" + ManifestUploadTimeout = 5 * time.Minute + Fmp4PostfixDir = "fmp4" + manifestNotFoundTolerance = 10 * time.Second ) func DownloadRetryBackoffLong() backoff.BackOff { @@ -33,27 +38,73 @@ func DownloadRetryBackoffLong() backoff.BackOff { var DownloadRetryBackoff = DownloadRetryBackoffLong func DownloadRenditionManifest(requestID, sourceManifestOSURL string) (m3u8.MediaPlaylist, error) { - var playlist m3u8.Playlist - var playlistType m3u8.ListType + playlist, playlistType, _, err := downloadManifest(requestID, sourceManifestOSURL) + if err != nil { + return m3u8.MediaPlaylist{}, err + } + return convertToMediaPlaylist(playlist, playlistType) +} + +// RecordingBackupCheck checks whether manifests and segments are available on the primary or +// the backup store and returns a URL to new manifest with absolute segment URLs pointing to either primary or +// backup locations depending on where the segments are available. +func RecordingBackupCheck(requestID string, primaryManifestURL, osTransferURL *url.URL) (*url.URL, error) { + if config.GetStorageBackupURL(primaryManifestURL.String()) == "" { + return primaryManifestURL, nil + } + playlist, playlistType, err := downloadManifestWithBackup(requestID, primaryManifestURL.String()) + if err != nil { + return nil, fmt.Errorf("error downloading manifest: %w", err) + } + mediaPlaylist, err := convertToMediaPlaylist(playlist, playlistType) + if err != nil { + return nil, err + } + + // Check whether segments are available from primary or backup storage dStorage := NewDStorageDownload() - err := backoff.Retry(func() error { - rc, err := GetFile(context.Background(), requestID, sourceManifestOSURL, dStorage) + for _, segment := range mediaPlaylist.GetAllSegments() { + segURL, err := ManifestURLToSegmentURL(primaryManifestURL.String(), segment.URI) if err != nil { - return fmt.Errorf("error downloading manifest: %s", err) + return nil, fmt.Errorf("error getting segment URL: %w", err) } - defer rc.Close() - - playlist, playlistType, err = m3u8.DecodeFrom(rc, true) + var actualSegURL string + err = backoff.Retry(func() error { + var rc io.ReadCloser + rc, actualSegURL, err = GetFileWithBackup(context.Background(), requestID, segURL.String(), dStorage) + if rc != nil { + rc.Close() + } + return err + }, DownloadRetryBackoff()) if err != nil { - return fmt.Errorf("error decoding manifest: %s", err) + return nil, fmt.Errorf("failed to find segment file %s: %w", segURL.Redacted(), err) } - return nil - }, DownloadRetryBackoff()) + segment.URI = actualSegURL + } + + // write the manifest to storage and update the manifestURL variable + outputStorageURL := osTransferURL.JoinPath("input.m3u8") + err = backoff.Retry(func() error { + return UploadToOSURL(outputStorageURL.String(), "", strings.NewReader(mediaPlaylist.String()), ManifestUploadTimeout) + }, UploadRetryBackoff()) if err != nil { - return m3u8.MediaPlaylist{}, err + return nil, fmt.Errorf("failed to upload rendition playlist: %w", err) + } + manifestURL, err := SignURL(outputStorageURL) + if err != nil { + return nil, fmt.Errorf("failed to sign manifest url: %w", err) + } + + newURL, err := url.Parse(manifestURL) + if err != nil { + return nil, fmt.Errorf("failed to parse new manifest URL: %w", err) } + return newURL, nil +} +func convertToMediaPlaylist(playlist m3u8.Playlist, playlistType m3u8.ListType) (m3u8.MediaPlaylist, error) { // We shouldn't ever receive Master playlists from the previous section if playlistType != m3u8.MEDIA { return m3u8.MediaPlaylist{}, fmt.Errorf("received non-Media manifest, but currently only Media playlists are supported") @@ -64,10 +115,86 @@ func DownloadRenditionManifest(requestID, sourceManifestOSURL string) (m3u8.Medi if !ok || mediaPlaylist == nil { return m3u8.MediaPlaylist{}, fmt.Errorf("failed to parse playlist as MediaPlaylist") } - return *mediaPlaylist, nil } +func downloadManifestWithBackup(requestID, sourceManifestOSURL string) (m3u8.Playlist, m3u8.ListType, error) { + var playlist, playlistBackup m3u8.Playlist + var playlistType, playlistTypeBackup m3u8.ListType + var size, sizeBackup int + var errPrimary, errBackup error + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + playlist, playlistType, size, errPrimary = downloadManifest(requestID, sourceManifestOSURL) + }() + + backupManifestURL := config.GetStorageBackupURL(sourceManifestOSURL) + if backupManifestURL != "" { + wg.Add(1) + go func() { + defer wg.Done() + playlistBackup, playlistTypeBackup, sizeBackup, errBackup = downloadManifest(requestID, backupManifestURL) + }() + } + wg.Wait() + + // If the file is not found in either storage, return the not found err from + // the primary. Otherwise, return any error that is not a simple not found + // (only not found errors passthrough below) + primaryNotFound, backupNotFound := errors.IsObjectNotFound(errPrimary), errors.IsObjectNotFound(errBackup) + if primaryNotFound && backupNotFound { + return nil, 0, errPrimary + } + if errPrimary != nil && !primaryNotFound { + return nil, 0, errPrimary + } + if errBackup != nil && !backupNotFound { + return nil, 0, errBackup + } + + // Return the largest manifest as the most recent version + hasBackup := backupManifestURL != "" && errBackup == nil + if hasBackup && (errPrimary != nil || sizeBackup > size) { + return playlistBackup, playlistTypeBackup, nil + } + return playlist, playlistType, errPrimary +} + +func downloadManifest(requestID, sourceManifestOSURL string) (playlist m3u8.Playlist, playlistType m3u8.ListType, size int, err error) { + dStorage := NewDStorageDownload() + start := time.Now() + err = backoff.Retry(func() error { + rc, err := GetFile(context.Background(), requestID, sourceManifestOSURL, dStorage) + if err != nil { + if time.Since(start) > manifestNotFoundTolerance && errors.IsObjectNotFound(err) { + // bail out of the retries earlier for not found errors because it will be quite a common scenario + // where the backup manifest does not exist and we don't want to wait the whole 50s of retries for + // every recording job + return backoff.Permanent(err) + } + return err + } + defer rc.Close() + + data := new(bytes.Buffer) + _, err = data.ReadFrom(rc) + if err != nil { + return fmt.Errorf("error reading manifest: %s", err) + } + + size = data.Len() + playlist, playlistType, err = m3u8.Decode(*data, true) + if err != nil { + return fmt.Errorf("error decoding manifest: %s", err) + } + return nil + }, DownloadRetryBackoff()) + return +} + type SourceSegment struct { URL *url.URL DurationMillis int64 @@ -76,13 +203,7 @@ type SourceSegment struct { // Loop over each segment in a given manifest and convert it from a relative path to a full ObjectStore-compatible URL func GetSourceSegmentURLs(sourceManifestURL string, manifest m3u8.MediaPlaylist) ([]SourceSegment, error) { var urls []SourceSegment - for _, segment := range manifest.Segments { - // The segments list is a ring buffer - see https://github.com/grafov/m3u8/issues/140 - // and so we only know we've hit the end of the list when we find a nil element - if segment == nil { - break - } - + for _, segment := range manifest.GetAllSegments() { u, err := ManifestURLToSegmentURL(sourceManifestURL, segment.URI) if err != nil { return nil, err diff --git a/clients/manifest_test.go b/clients/manifest_test.go index 4808e328a..ddda63ef8 100644 --- a/clients/manifest_test.go +++ b/clients/manifest_test.go @@ -1,6 +1,8 @@ package clients import ( + "fmt" + "net/url" "os" "path/filepath" "strings" @@ -9,6 +11,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/grafov/m3u8" + "github.com/livepeer/catalyst-api/config" "github.com/livepeer/catalyst-api/video" "github.com/stretchr/testify/require" ) @@ -39,7 +42,8 @@ func TestDownloadRenditionManifestFailsWhenItCantFindTheManifest(t *testing.T) { defer func() { DownloadRetryBackoff = DownloadRetryBackoffLong }() _, err := DownloadRenditionManifest("blah", "/tmp/something/x.m3u8") require.Error(t, err) - require.Contains(t, err.Error(), "error downloading manifest") + require.Contains(t, err.Error(), "the specified file does not exist") + require.Contains(t, err.Error(), "ObjectNotFoundError") } func TestDownloadRenditionManifestFailsWhenItCantParseTheManifest(t *testing.T) { @@ -333,3 +337,131 @@ func createDummyMediaSegments() []*m3u8.MediaSegment { }, } } + +func TestDownloadRenditionManifestWithBackup(t *testing.T) { + completeManifest := `#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-TARGETDURATION:10 +#EXTINF:10.000000, +seg-0.ts +#EXTINF:10.000000, +seg-1.ts +#EXTINF:10.000000, +seg-2.ts +#EXTINF:10.000000, +seg-3.ts +#EXT-X-ENDLIST +` + inCompleteManifest := `#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-TARGETDURATION:10 +#EXTINF:10.000000, +seg-0.ts +#EXTINF:10.000000, +seg-1.ts +#EXT-X-ENDLIST +` + + tests := []struct { + name string + primaryManifest string + backupManifest string + primarySegments []string + backupSegments []string + }{ + { + name: "happy. all segments and manifest available on primary", + primaryManifest: completeManifest, + backupManifest: "", + primarySegments: []string{"seg-0.ts", "seg-1.ts", "seg-2.ts", "seg-3.ts"}, + }, + { + name: "all segments and manifest available on backup", + primaryManifest: inCompleteManifest, + backupManifest: completeManifest, + backupSegments: []string{"seg-0.ts", "seg-1.ts", "seg-2.ts", "seg-3.ts"}, + }, + { + name: "all segments on backup and newest manifest on primary", + primaryManifest: completeManifest, + backupManifest: inCompleteManifest, + backupSegments: []string{"seg-0.ts", "seg-1.ts", "seg-2.ts", "seg-3.ts"}, + }, + { + name: "all segments on primary and newest manifest on backup", + primaryManifest: inCompleteManifest, + backupManifest: completeManifest, + primarySegments: []string{"seg-0.ts", "seg-1.ts", "seg-2.ts", "seg-3.ts"}, + }, + { + name: "segments split between primary and backup, newest manifest on primary", + primaryManifest: completeManifest, + backupManifest: inCompleteManifest, + primarySegments: []string{"seg-0.ts", "seg-2.ts"}, + backupSegments: []string{"seg-1.ts", "seg-3.ts"}, + }, + { + name: "segments split between primary and backup, newest manifest on backup", + primaryManifest: inCompleteManifest, + backupManifest: completeManifest, + primarySegments: []string{"seg-0.ts", "seg-2.ts"}, + backupSegments: []string{"seg-1.ts", "seg-3.ts"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir, err := os.MkdirTemp(os.TempDir(), "manifest-test-*") + require.NoError(t, err) + defer os.RemoveAll(dir) + + err = os.Mkdir(filepath.Join(dir, "primary"), 0755) + require.NoError(t, err) + err = os.Mkdir(filepath.Join(dir, "backup"), 0755) + require.NoError(t, err) + config.StorageFallbackURLs = map[string]string{filepath.Join(dir, "primary"): filepath.Join(dir, "backup")} + + err = os.WriteFile(filepath.Join(dir, "primary", "index.m3u8"), []byte(tt.primaryManifest), 0644) + require.NoError(t, err) + if tt.backupManifest != "" { + err = os.WriteFile(filepath.Join(dir, "backup", "index.m3u8"), []byte(tt.backupManifest), 0644) + require.NoError(t, err) + } + + for _, segment := range tt.primarySegments { + err = os.WriteFile(filepath.Join(dir, "primary", segment), []byte{}, 0644) + require.NoError(t, err) + } + for _, segment := range tt.backupSegments { + err = os.WriteFile(filepath.Join(dir, "backup", segment), []byte{}, 0644) + require.NoError(t, err) + } + + renditionUrl, err := RecordingBackupCheck("requestID", toUrl(t, filepath.Join(dir, "primary", "index.m3u8")), toUrl(t, filepath.Join(dir, "transfer"))) + require.NoError(t, err) + + file, err := os.Open(renditionUrl.String()) + require.NoError(t, err) + + // read resulting playlist + playlist, playlistType, err := m3u8.DecodeFrom(file, true) + require.NoError(t, err) + require.Equal(t, m3u8.MEDIA, playlistType) + mediaPlaylist, ok := playlist.(*m3u8.MediaPlaylist) + require.True(t, ok) + + require.Len(t, mediaPlaylist.GetAllSegments(), 4) + for i, segment := range mediaPlaylist.GetAllSegments() { + require.True(t, filepath.IsAbs(segment.URI)) + require.True(t, true, strings.HasSuffix(segment.URI, fmt.Sprintf("seg-%d.ts", i))) + } + }) + } +} + +func toUrl(t *testing.T, in string) *url.URL { + u, err := url.Parse(in) + require.NoError(t, err) + return u +} diff --git a/config/cli.go b/config/cli.go index 4243282e9..f99634a7b 100644 --- a/config/cli.go +++ b/config/cli.go @@ -59,6 +59,7 @@ type Cli struct { EncryptKey string VodDecryptPublicKey string VodDecryptPrivateKey string + StorageFallbackURLs map[string]string GateURL string DataURL string StreamHealthHookURL string diff --git a/config/config.go b/config/config.go index 487a1188b..80b68e79b 100644 --- a/config/config.go +++ b/config/config.go @@ -52,4 +52,6 @@ var ImportIPFSGatewayURLs []*url.URL var ImportArweaveGatewayURLs []*url.URL +var StorageFallbackURLs map[string]string + var HTTPInternalAddress string diff --git a/config/storage_backup_url.go b/config/storage_backup_url.go new file mode 100644 index 000000000..e197cd182 --- /dev/null +++ b/config/storage_backup_url.go @@ -0,0 +1,15 @@ +package config + +import "strings" + +// GetStorageBackupURL returns the backup URL for the given URL or an empty string if it doesn't exist. The backup URL +// is found by checking the `StorageFallbackURLs` global config map. If any of the primary URL prefixes (keys in map) +// are in `urlStr`, it is replaced with the backup URL prefix (associated value of the key in the map). +func GetStorageBackupURL(urlStr string) string { + for primary, backup := range StorageFallbackURLs { + if strings.HasPrefix(urlStr, primary) { + return strings.Replace(urlStr, primary, backup, 1) + } + } + return "" +} diff --git a/config/storage_backup_url_test.go b/config/storage_backup_url_test.go new file mode 100644 index 000000000..2ad4b3697 --- /dev/null +++ b/config/storage_backup_url_test.go @@ -0,0 +1,31 @@ +package config + +import "testing" + +func TestGetStorageBackupURL(t *testing.T) { + StorageFallbackURLs = map[string]string{"https://storj.livepeer.com/catalyst-recordings-com/hls": "https://google.livepeer.com/catalyst-recordings-com/hls"} + defer func() { StorageFallbackURLs = nil }() + tests := []struct { + name string + urlStr string + want string + }{ + { + name: "should replace", + urlStr: "https://storj.livepeer.com/catalyst-recordings-com/hls/foo", + want: "https://google.livepeer.com/catalyst-recordings-com/hls/foo", + }, + { + name: "should not replace", + urlStr: "https://blah.livepeer.com/catalyst-recordings-com/hls/foo", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetStorageBackupURL(tt.urlStr); got != tt.want { + t.Errorf("GetStorageBackupURL() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod index f264033db..e6585061c 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/livepeer/m3u8 v0.11.1 github.com/mileusna/useragent v1.3.4 github.com/minio/madmin-go v1.7.5 + github.com/minio/minio-go/v7 v7.0.45 github.com/mmcloughlin/geohash v0.10.0 github.com/peterbourgon/ff/v3 v3.4.0 github.com/pquerna/cachecontrol v0.2.0 @@ -52,6 +53,7 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect github.com/cucumber/messages/go/v21 v21.0.1 // indirect + github.com/dustin/go-humanize v1.0.0 // indirect github.com/eventials/go-tus v0.0.0-20220610120217-05d0564bb571 // indirect github.com/fatih/color v1.13.0 // indirect github.com/go-logr/logr v1.2.4 // indirect @@ -111,7 +113,7 @@ require ( github.com/mattn/go-isatty v0.0.19 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/miekg/dns v1.1.50 // indirect - github.com/minio/minio-go/v7 v7.0.45 // indirect + github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -138,6 +140,7 @@ require ( github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/secure-io/sio-go v0.3.1 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.0 // indirect diff --git a/go.sum b/go.sum index c4ab2af45..a198dd7ed 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -408,6 +410,7 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= @@ -489,6 +492,8 @@ github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4 github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= github.com/minio/madmin-go v1.7.5 h1:IF8j2HR0jWc7msiOcy0KJ8EyY7Q3z+j+lsmSDksQm+I= github.com/minio/madmin-go v1.7.5/go.mod h1:3SO8SROxHN++tF6QxdTii2SSUaYSrr8lnE9EJWjvz0k= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.45 h1:g4IeM9M9pW/Lo8AGGNOjBZYlvmtlE1N5TQEYWXRWzIs= github.com/minio/minio-go/v7 v7.0.45/go.mod h1:nCrRzjoSUQh8hgKKtu3Y708OLvRLtuASMg2/nvmbarw= github.com/minio/sha256-simd v0.0.0-20190131020904-2d45a736cd16/go.mod h1:2FMWW+8GMoPweT6+pI63m9YE3Lmw4J71hV56Chs1E/U= @@ -651,6 +656,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= @@ -948,6 +955,7 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go index be55d6cfa..7d66a885e 100644 --- a/main.go +++ b/main.go @@ -119,6 +119,7 @@ func main() { fs.StringVar(&cli.EncryptKey, "encrypt", "", "Key for encrypting network traffic within Serf. Must be a base64-encoded 32-byte key.") fs.StringVar(&cli.VodDecryptPublicKey, "catalyst-public-key", "", "Public key of the catalyst node for encryption") fs.StringVar(&cli.VodDecryptPrivateKey, "catalyst-private-key", "", "Private key of the catalyst node for encryption") + config.CommaMapFlag(fs, &cli.StorageFallbackURLs, "storage-fallback-urls", map[string]string{}, `Comma-separated map of primary to backup storage URLs. If a file fails downloading from one of the primary storages (detected by prefix), it will fallback to the corresponding backup URL after having the prefix replaced. E.g. https://storj.livepeer.com/catalyst-recordings-com/hls=https://google.livepeer.com/catalyst-recordings-com/hls`) fs.StringVar(&cli.GateURL, "gate-url", "http://localhost:3004/api/access-control/gate", "Address to contact playback gating API for access control verification") fs.StringVar(&cli.DataURL, "data-url", "http://localhost:3004/api/data", "Address of the Livepeer Data Endpoint") config.InvertedBoolFlag(fs, &cli.MistTriggerSetup, "mist-trigger-setup", true, "Overwrite Mist triggers with the ones built into catalyst-api") @@ -179,6 +180,8 @@ func main() { return } + config.StorageFallbackURLs = cli.StorageFallbackURLs + var ( metricsDB *sql.DB vodEngine *pipeline.Coordinator diff --git a/pipeline/coordinator.go b/pipeline/coordinator.go index 0e8c0d2f8..3f625732d 100644 --- a/pipeline/coordinator.go +++ b/pipeline/coordinator.go @@ -290,6 +290,12 @@ func (c *Coordinator) StartUploadJob(p UploadJobPayload) { // Update osTransferURL if needed if clients.IsHLSInput(sourceURL) { + // Handle falling back to backup bucket for manifest and segments + sourceURL, err = clients.RecordingBackupCheck(p.RequestID, sourceURL, osTransferURL.JoinPath("..")) + if err != nil { + return nil, err + } + // Currently we only clip an HLS source (e.g recordings or transcoded asset) if p.ClipStrategy.Enabled { err := backoff.Retry(func() error { @@ -323,7 +329,7 @@ func (c *Coordinator) StartUploadJob(p UploadJobPayload) { if p.C2PA { si.C2PA = c.C2PA } - si.SourceFile = osTransferURL.String() // OS URL used by mist + si.SourceFile = osTransferURL.String() // OS URL used by ffmpeg pipeline log.AddContext(p.RequestID, "new_source_url", si.SourceFile) si.SignedSourceURL = signedNewSourceURL // http(s) URL used by mediaconvert diff --git a/pipeline/ffmpeg.go b/pipeline/ffmpeg.go index 760951bae..a0b8bf64d 100644 --- a/pipeline/ffmpeg.go +++ b/pipeline/ffmpeg.go @@ -152,7 +152,7 @@ func (f *ffmpeg) HandleStartUploadJob(job *JobInfo) (*HandlerOutput, error) { sourceManifest, err := clients.DownloadRenditionManifest(transcodeRequest.RequestID, transcodeRequest.SourceManifestURL) if err != nil { - return nil, fmt.Errorf("error downloading source manifest: %s", err) + return nil, fmt.Errorf("error downloading source manifest %s: %w", log.RedactURL(transcodeRequest.SourceManifestURL), err) } sourceSegments := sourceManifest.GetAllSegments() diff --git a/test/features/vod.feature b/test/features/vod.feature index ea66f31f6..b744620a3 100644 --- a/test/features/vod.feature +++ b/test/features/vod.feature @@ -7,6 +7,7 @@ Feature: VOD Streaming Given the VOD API is running And the Client app is authenticated And an object store is available +# And a fallback object And Studio API server is running at "localhost:13000" And a Broadcaster is running at "localhost:18935" And Mediaconvert is running at "localhost:11111" @@ -86,29 +87,30 @@ Feature: VOD Streaming Then I get an HTTP response with code "200" And I receive a Request ID in the response body And my "successful" vod request metrics get recorded - And the Broadcaster receives "3" segments for transcoding within "10" seconds - And "3" transcoded segments and manifests have been written to disk for profiles "270p0,low-bitrate" within "30" seconds + And the Broadcaster receives "" segments for transcoding within "10" seconds + And "" transcoded segments and manifests have been written to disk for profiles "270p0,low-bitrate" within "30" seconds And a source copy has not been written to disk And I receive a "success" callback within "30" seconds And thumbnails are written to storage within "10" seconds And a row is written to the "vod_completed" database table containing the following values - | column | value | - | in_fallback_mode | false | - | is_clip | false | - | pipeline | catalyst_ffmpeg | - | profiles_count | 2 | - | source_codec_audio | aac | - | source_codec_video | h264 | - | source_duration | 30000 | - | source_segment_count | 3 | - | state | completed | - | transcoded_segment_count | 3 | + | column | value | + | in_fallback_mode | false | + | is_clip | false | + | pipeline | catalyst_ffmpeg | + | profiles_count | 2 | + | source_codec_audio | aac | + | source_codec_video | h264 | + | source_duration | | + | source_segment_count | | + | state | completed | + | transcoded_segment_count | | Examples: - | payload | - | a valid ffmpeg upload vod request with a source manifest | - | a valid ffmpeg upload vod request with a source manifest and source copying | - | a valid ffmpeg upload vod request with a source manifest and thumbnails | + | payload | segment_count | source_duration | + | a valid ffmpeg upload vod request with a source manifest | 3 | 30000 | + | a valid ffmpeg upload vod request with a source manifest and source copying | 3 | 30000 | + | a valid ffmpeg upload vod request with a source manifest and thumbnails | 3 | 30000 | + | a valid ffmpeg upload vod request with a source manifest from object store | 4 | 40000 | Scenario Outline: Submit an audio-only asset for ingestion When I submit to the internal "/api/vod" endpoint with "" diff --git a/test/fixtures/rec-fallback-bucket/seg-3.ts b/test/fixtures/rec-fallback-bucket/seg-3.ts new file mode 100644 index 0000000000000000000000000000000000000000..476821ce242ead4b7db071479e38c23f8895b772 GIT binary patch literal 188376 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 Y@JdtKV>DbOCGLLroV=Li%3$07H)#AJ@&Et; literal 0 HcmV?d00001 diff --git a/test/fixtures/rec-fallback-bucket/tiny.m3u8 b/test/fixtures/rec-fallback-bucket/tiny.m3u8 new file mode 100644 index 000000000..950dae515 --- /dev/null +++ b/test/fixtures/rec-fallback-bucket/tiny.m3u8 @@ -0,0 +1,13 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-TARGETDURATION:10 +#EXTINF:10.000000, +seg-0.ts +#EXTINF:10.000000, +seg-1.ts +#EXTINF:10.000000, +seg-2.ts +#EXTINF:10.000000, +seg-3.ts +#EXT-X-ENDLIST diff --git a/test/steps/ffmpeg.go b/test/steps/ffmpeg.go index aff0e8368..49ef43963 100644 --- a/test/steps/ffmpeg.go +++ b/test/steps/ffmpeg.go @@ -2,14 +2,17 @@ package steps import ( "bytes" + "context" "fmt" - "os" + "io" + "log" "os/exec" "path/filepath" "strings" "time" "github.com/grafov/m3u8" + "github.com/livepeer/go-tools/drivers" ) // Confirm that we have an ffmpeg binary on the system the tests are running on @@ -21,35 +24,46 @@ func (s *StepContext) CheckFfmpeg() error { } func (s *StepContext) AllOfTheSourceSegmentsAreWrittenToStorageWithinSeconds(numSegments, secs int) error { - // Comes in looking like file:/var/folders/qr/sr8gs8916zd2wjbx50d3c3yc0000gn/T/livepeer/source - // and we want /var/folders/qr/sr8gs8916zd2wjbx50d3c3yc0000gn/T/livepeer/source/aceaegdf/source/*.ts - segmentingDir := filepath.Join(strings.TrimPrefix(s.SourceOutputDir, "file:"), s.latestRequestID, "source/*.ts") + osDriver, err := drivers.ParseOSURL(s.SourceOutputDir, true) + if err != nil { + return fmt.Errorf("could not parse object store url: %w", err) + } + session := osDriver.NewSession(filepath.Join(s.latestRequestID, "source")) var latestNumSegments int - for x := 0; x < secs; x++ { - files, err := filepath.Glob(segmentingDir) + for x := 0; x < secs; x++ { // retry loop + if x > 0 { + time.Sleep(time.Second) + } + page, err := session.ListFiles(context.Background(), "", "") if err != nil { - return err + log.Println("failed to list files: ", err) + continue } - latestNumSegments = len(files) - if latestNumSegments == numSegments { + + latestNumSegments = len(page.Files()) + if latestNumSegments == numSegments+1 { return nil } - time.Sleep(time.Second) } - return fmt.Errorf("did not find the expected number of source segments in %s (wanted %d, got %d)", segmentingDir, numSegments, latestNumSegments) + return fmt.Errorf("did not find the expected number of source segments in %s (wanted %d, got %d)", s.SourceOutputDir, numSegments, latestNumSegments) } func (s *StepContext) TheSourceManifestIsWrittenToStorageWithinSeconds(secs, numSegments int) error { - // Comes in looking like file:/var/folders/qr/sr8gs8916zd2wjbx50d3c3yc0000gn/T/livepeer/source - // and we want /var/folders/qr/sr8gs8916zd2wjbx50d3c3yc0000gn/T/livepeer/source/aceaegdf/source/index.m3u8 - sourceManifest := filepath.Join(strings.TrimPrefix(s.SourceOutputDir, "file:"), s.latestRequestID, "source/index.m3u8") + osDriver, err := drivers.ParseOSURL(s.SourceOutputDir, true) + if err != nil { + return fmt.Errorf("could not parse object store url: %w", err) + } + session := osDriver.NewSession(filepath.Join(s.latestRequestID, "source/index.m3u8")) - var manifestBytes []byte - var err error + var ( + manifestBytes []byte + fileInfoReader *drivers.FileInfoReader + ) for x := 0; x < secs; x++ { - manifestBytes, err = os.ReadFile(sourceManifest) + fileInfoReader, err = session.ReadData(context.Background(), "") if err == nil { + manifestBytes, err = io.ReadAll(fileInfoReader.Body) // Only break if the full manifest has been written if strings.HasSuffix(strings.TrimSpace(string(manifestBytes)), "#EXT-X-ENDLIST") { break diff --git a/test/steps/http.go b/test/steps/http.go index 6be3d9396..e353768ef 100644 --- a/test/steps/http.go +++ b/test/steps/http.go @@ -163,6 +163,10 @@ func (s *StepContext) postRequest(baseURL, endpoint, payload string, headers map } if strings.HasPrefix(payload, "a valid ffmpeg upload vod request with a source manifest") { req.URL = "file://" + filepath.Join(sourceManifestDir, "tiny.m3u8") + if strings.Contains(payload, "from object store") { + req.URL = "http://" + minioAddress + "/rec-bucket/tiny.m3u8" + } + req.PipelineStrategy = "catalyst_ffmpeg" req.OutputLocations = []OutputLocation{ { @@ -211,7 +215,7 @@ func (s *StepContext) postRequest(baseURL, endpoint, payload string, headers map } func (s *StepContext) StartApp() error { - s.SourceOutputDir = fmt.Sprintf("file://%s/%s/", os.TempDir(), "livepeer/source") + s.SourceOutputDir = fmt.Sprintf("s3+http://%s:%s@%s/source", minioKey, minioKey, minioAddress) App = exec.Command( "./app", @@ -220,12 +224,11 @@ func (s *StepContext) StartApp() error { "-cluster-addr=127.0.0.1:19935", "-broadcaster-url=http://127.0.0.1:18935", `-metrics-db-connection-string=`+DB_CONNECTION_STRING, - "-private-bucket", - "fixtures/playback-bucket", + "-private-bucket=fixtures/playback-bucket", "-gate-url=http://localhost:13000/api/access-control/gate", "-external-transcoder=mediaconverthttp://examplekey:examplepass@127.0.0.1:11111?region=us-east-1&role=arn:aws:iam::exampleaccountid:examplerole&s3_aux_bucket=s3://example-bucket", - "-source-output", - s.SourceOutputDir, + fmt.Sprintf("-storage-fallback-urls=http://%s/rec-bucket=http://%s/rec-fallback-bucket", minioAddress, minioAddress), + fmt.Sprintf("-source-output=%s", s.SourceOutputDir), "-no-mist", ) outfile, err := os.Create("logs/app.log") diff --git a/test/steps/init.go b/test/steps/init.go index 298521609..2046e21d2 100644 --- a/test/steps/init.go +++ b/test/steps/init.go @@ -1,12 +1,16 @@ package steps import ( + "context" "encoding/json" "fmt" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" "io" "net/http" "os" "os/exec" + "path" "time" "github.com/cenkalti/backoff/v4" @@ -14,7 +18,10 @@ import ( "github.com/minio/madmin-go" ) -var minioAddress = "127.0.0.1:9000" +const ( + minioAddress = "127.0.0.1:9000" + minioKey = "minioadmin" +) func (s *StepContext) StartStudioAPI(listen string) error { router := httprouter.New() @@ -62,7 +69,7 @@ func WaitForStartup(url string) { } func (s *StepContext) StartObjectStore() error { - app := exec.Command("./minio", "--address "+minioAddress, "server", fmt.Sprint(os.TempDir(), "/minio")) + app := exec.Command("./minio", "server", "--address", minioAddress, path.Join(os.TempDir(), "catalyst-minio")) outfile, err := os.Create("logs/minio.log") if err != nil { return err @@ -74,12 +81,77 @@ func (s *StepContext) StartObjectStore() error { return err } - madmin, err := madmin.New(minioAddress, "minioadmin", "minioadmin", false) + admin, err := madmin.New(minioAddress, minioKey, minioKey, false) if err != nil { return err } + s.MinioAdmin = admin + + minioClient, err := minio.New(minioAddress, &minio.Options{ + Creds: credentials.NewStaticV4(minioKey, minioKey, ""), + Secure: false, + }) + if err != nil { + return err + } + + WaitForStartup("http://" + minioAddress + "/minio/health/live") - s.MinioAdmin = madmin + ctx := context.Background() + + // Create buckets if they do not exist. + buckets := []string{"rec-bucket", "rec-fallback-bucket", "source"} + for _, bucket := range buckets { + exists, err := minioClient.BucketExists(ctx, bucket) + if err != nil { + return fmt.Errorf("failed to check if bucket exists: %w", err) + } + if exists { + continue + } + err = minioClient.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}) + if err != nil { + return err + } + } + + // Set bucket policy to allow anonymous download. + for _, bucket := range buckets { + policy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] + }`, bucket) + + err = minioClient.SetBucketPolicy(ctx, bucket, policy) + if err != nil { + return err + } + } + + // populate recording bucket + files := []string{"fixtures/tiny.m3u8", "fixtures/seg-0.ts", "fixtures/seg-1.ts", "fixtures/seg-2.ts"} + for _, file := range files { + _, err := minioClient.FPutObject(ctx, "rec-bucket", path.Base(file), file, minio.PutObjectOptions{}) + if err != nil { + return err + } + } + + // populate recording fallback bucket + files = []string{"fixtures/rec-fallback-bucket/tiny.m3u8", "fixtures/rec-fallback-bucket/seg-3.ts"} + for _, file := range files { + _, err := minioClient.FPutObject(ctx, "rec-fallback-bucket", path.Base(file), file, minio.PutObjectOptions{}) + if err != nil { + return err + } + } return nil } diff --git a/thumbnails/thumbnails.go b/thumbnails/thumbnails.go index 7d138d5de..a3c6cd44e 100644 --- a/thumbnails/thumbnails.go +++ b/thumbnails/thumbnails.go @@ -31,35 +31,6 @@ func thumbWaitBackoff() backoff.BackOff { return backoff.WithMaxRetries(backoff.NewConstantBackOff(30*time.Second), 10) } -func getMediaManifest(requestID string, input string) (*m3u8.MediaPlaylist, error) { - var ( - rc io.ReadCloser - err error - ) - err = backoff.Retry(func() error { - rc, err = clients.GetFile(context.Background(), requestID, input, nil) - return err - }, clients.DownloadRetryBackoff()) - if err != nil { - return nil, fmt.Errorf("error downloading manifest: %w", err) - } - defer rc.Close() - - manifest, playlistType, err := m3u8.DecodeFrom(rc, true) - if err != nil { - return nil, fmt.Errorf("failed to decode manifest: %w", err) - } - - if playlistType != m3u8.MEDIA { - return nil, fmt.Errorf("received non-Media manifest, but currently only Media playlists are supported") - } - mediaPlaylist, ok := manifest.(*m3u8.MediaPlaylist) - if !ok || mediaPlaylist == nil { - return nil, fmt.Errorf("failed to parse playlist as MediaPlaylist") - } - return mediaPlaylist, nil -} - func getSegmentOffset(mediaPlaylist *m3u8.MediaPlaylist) (int64, error) { segments := mediaPlaylist.GetAllSegments() if len(segments) < 1 { @@ -74,7 +45,7 @@ func getSegmentOffset(mediaPlaylist *m3u8.MediaPlaylist) (int64, error) { func GenerateThumbsVTT(requestID string, input string, output *url.URL) error { // download and parse the manifest - mediaPlaylist, err := getMediaManifest(requestID, input) + mediaPlaylist, err := clients.DownloadRenditionManifest(requestID, input) if err != nil { return err } @@ -86,7 +57,7 @@ func GenerateThumbsVTT(requestID string, input string, output *url.URL) error { if err != nil { return err } - segmentOffset, err := getSegmentOffset(mediaPlaylist) + segmentOffset, err := getSegmentOffset(&mediaPlaylist) if err != nil { return err } @@ -187,7 +158,7 @@ func GenerateThumbsAndVTT(requestID, input string, output *url.URL) error { func GenerateThumbsFromManifest(requestID, input string, output *url.URL) error { // parse manifest and generate one thumbnail per segment - mediaPlaylist, err := getMediaManifest(requestID, input) + mediaPlaylist, err := clients.DownloadRenditionManifest(requestID, input) if err != nil { return err } @@ -195,7 +166,7 @@ func GenerateThumbsFromManifest(requestID, input string, output *url.URL) error if err != nil { return err } - segmentOffset, err := getSegmentOffset(mediaPlaylist) + segmentOffset, err := getSegmentOffset(&mediaPlaylist) if err != nil { return err }