🏡 index : ironmagma/quintodrome.git

package stream_test

import (
	"context"
	"errors"
	"sync"

	"github.com/navidrome/navidrome/core/stream"
	"github.com/navidrome/navidrome/log"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("TranscodeLimiter", func() {
	ctx := log.NewContext(context.TODO())

	Describe("Disabled (both caps <= 0)", func() {
		It("never blocks and never returns ErrTooManyTranscodes", func() {
			lim := stream.NewTranscodeLimiter(0, 0)
			for range 100 {
				rel, err := lim.Acquire(ctx, "alice")
				Expect(err).ToNot(HaveOccurred())
				Expect(rel).ToNot(BeNil())
			}
		})
	})

	Describe("Per-user cap only (no global cap)", func() {
		It("still enforces the per-user limit when MaxConcurrent is disabled", func() {
			lim := stream.NewTranscodeLimiter(0, 2)

			rel1, err := lim.Acquire(ctx, "alice")
			Expect(err).ToNot(HaveOccurred())
			rel2, err := lim.Acquire(ctx, "alice")
			Expect(err).ToNot(HaveOccurred())

			_, err = lim.Acquire(ctx, "alice")
			Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())

			// Other users have their own buckets.
			rel3, err := lim.Acquire(ctx, "bob")
			Expect(err).ToNot(HaveOccurred())

			rel1()
			_, err = lim.Acquire(ctx, "alice")
			Expect(err).ToNot(HaveOccurred())

			rel2()
			rel3()
		})
	})

	Describe("Global cap", func() {
		It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
			lim := stream.NewTranscodeLimiter(2, 0)

			rel1, err := lim.Acquire(ctx, "alice")
			Expect(err).ToNot(HaveOccurred())
			rel2, err := lim.Acquire(ctx, "bob")
			Expect(err).ToNot(HaveOccurred())

			_, err = lim.Acquire(ctx, "carol")
			Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())

			rel1()
			_, err = lim.Acquire(ctx, "carol")
			Expect(err).ToNot(HaveOccurred())

			rel2()
		})

		It("releases a slot only once even if release is called multiple times", func() {
			lim := stream.NewTranscodeLimiter(1, 0)

			rel, err := lim.Acquire(ctx, "alice")
			Expect(err).ToNot(HaveOccurred())

			rel()
			rel()
			rel()

			// After releases, exactly one slot should be available.
			_, err = lim.Acquire(ctx, "alice")
			Expect(err).ToNot(HaveOccurred())
			_, err = lim.Acquire(ctx, "alice")
			Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
		})
	})

	Describe("Per-user cap", func() {
		It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() {
			lim := stream.NewTranscodeLimiter(10, 2)

			rel1, err := lim.Acquire(ctx, "alice")
			Expect(err).ToNot(HaveOccurred())
			rel2, err := lim.Acquire(ctx, "alice")
			Expect(err).ToNot(HaveOccurred())

			_, err = lim.Acquire(ctx, "alice")
			Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())

			// A different user is unaffected.
			rel3, err := lim.Acquire(ctx, "bob")
			Expect(err).ToNot(HaveOccurred())

			rel1()
			_, err = lim.Acquire(ctx, "alice")
			Expect(err).ToNot(HaveOccurred())

			rel2()
			rel3()
		})

		It("skips the per-user cap for anonymous users (empty key)", func() {
			// Anonymous requests (e.g. public share viewers) deliberately
			// bypass the per-user cap so unrelated anonymous clients are not
			// collapsed into a single shared bucket. The global cap remains
			// the only ceiling on anonymous traffic.
			lim := stream.NewTranscodeLimiter(10, 1)

			rels := make([]func(), 0, 5)
			for range 5 {
				rel, err := lim.Acquire(ctx, "")
				Expect(err).ToNot(HaveOccurred())
				rels = append(rels, rel)
			}
			for _, rel := range rels {
				rel()
			}
		})

		It("still applies the global cap to anonymous users", func() {
			lim := stream.NewTranscodeLimiter(2, 1)

			rel1, err := lim.Acquire(ctx, "")
			Expect(err).ToNot(HaveOccurred())
			rel2, err := lim.Acquire(ctx, "")
			Expect(err).ToNot(HaveOccurred())

			_, err = lim.Acquire(ctx, "")
			Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())

			rel1()
			rel2()
		})
	})

	Describe("Concurrent safety", func() {
		It("survives parallel Acquire/release with consistent counts", func() {
			lim := stream.NewTranscodeLimiter(5, 0)

			var wg sync.WaitGroup
			var acquired int64
			var rejected int64
			var mu sync.Mutex

			for i := range 50 {
				wg.Add(1)
				go func(i int) {
					defer wg.Done()
					rel, err := lim.Acquire(ctx, "alice")
					mu.Lock()
					if err == nil {
						acquired++
						mu.Unlock()
						rel()
					} else {
						rejected++
						mu.Unlock()
					}
					_ = i
				}(i)
			}
			wg.Wait()

			Expect(acquired + rejected).To(Equal(int64(50)))
			// After all releases, all 5 slots should be free again.
			for range 5 {
				_, err := lim.Acquire(ctx, "alice")
				Expect(err).ToNot(HaveOccurred())
			}
			_, err := lim.Acquire(ctx, "alice")
			Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
		})
	})
})