@@ -0,0 +1,373 @@
|
||||
package shortrecommendser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"91porn-server/app/appg"
|
||||
recommendqueue "91porn-server/common/shortrecommend"
|
||||
"91porn-server/models/v/moduleconfmod"
|
||||
"91porn-server/models/v/vidmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxBatches = 5
|
||||
defaultScanMultiplier = 5
|
||||
maxAllowedBatches = 20
|
||||
maxAllowedMultiplier = 20
|
||||
maxRequestIDLength = 128
|
||||
reservationAbortTTL = 2 * time.Second
|
||||
reservationBusyRetries = 3
|
||||
reservationBusyBackoff = 10 * time.Millisecond
|
||||
)
|
||||
|
||||
// FetchResult 是一次环形队列拉取的结果和可观测指标。
|
||||
type FetchResult struct {
|
||||
Videos []*vidmod.VideoModel
|
||||
QueueVersion string
|
||||
QueueLength int
|
||||
Scanned int
|
||||
Filtered int
|
||||
Batches int
|
||||
BudgetExceeded bool
|
||||
}
|
||||
|
||||
type fetchOptions struct {
|
||||
maxBatches int
|
||||
scanMultiplier int
|
||||
}
|
||||
|
||||
type reserveFunc func(
|
||||
context.Context,
|
||||
uint64,
|
||||
int,
|
||||
string,
|
||||
) (recommendqueue.Reservation, error)
|
||||
|
||||
type fetchDependencies struct {
|
||||
reserve reserveFunc
|
||||
commit func(
|
||||
context.Context,
|
||||
uint64,
|
||||
recommendqueue.Reservation,
|
||||
int,
|
||||
) error
|
||||
abort func(
|
||||
context.Context,
|
||||
uint64,
|
||||
recommendqueue.Reservation,
|
||||
) error
|
||||
excludedModuleIDs func(time.Time, bool) ([]string, error)
|
||||
findVideos func(context.Context, []primitive.ObjectID) ([]*vidmod.VideoModel, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Fetch 从用户当前偏移预留短视频,按当前Mongo状态过滤,并限制补位扫描成本。
|
||||
// 只有全部Mongo批次成功后才一次性推进实际扫描前缀;失败会释放预留且不跳内容。
|
||||
func Fetch(
|
||||
ctx context.Context,
|
||||
uid uint64,
|
||||
size int,
|
||||
requestID string,
|
||||
) (FetchResult, error) {
|
||||
return FetchScoped(ctx, uid, size, requestID, "short-recommend")
|
||||
}
|
||||
|
||||
// FetchScoped 将入口和请求大小纳入幂等作用域,防止同一客户端requestID
|
||||
// 被不同短视频接口或不同请求参数误复用。
|
||||
func FetchScoped(
|
||||
ctx context.Context,
|
||||
uid uint64,
|
||||
size int,
|
||||
requestID, scope string,
|
||||
) (FetchResult, error) {
|
||||
if appg.Redis == nil {
|
||||
return FetchResult{}, fmt.Errorf("short recommend Redis is nil")
|
||||
}
|
||||
var err error
|
||||
requestID, err = scopedRequestID(scope, size, requestID)
|
||||
if err != nil {
|
||||
return FetchResult{}, err
|
||||
}
|
||||
opts := configuredFetchOptions()
|
||||
deps := fetchDependencies{
|
||||
reserve: func(
|
||||
ctx context.Context,
|
||||
uid uint64,
|
||||
size int,
|
||||
requestID string,
|
||||
) (recommendqueue.Reservation, error) {
|
||||
return recommendqueue.ReserveCurrentContext(
|
||||
ctx, appg.Redis, uid, size, requestID,
|
||||
)
|
||||
},
|
||||
commit: func(
|
||||
ctx context.Context,
|
||||
uid uint64,
|
||||
reservation recommendqueue.Reservation,
|
||||
consumed int,
|
||||
) error {
|
||||
return recommendqueue.CommitReservationContext(
|
||||
ctx, appg.Redis, uid, reservation, consumed,
|
||||
)
|
||||
},
|
||||
abort: func(
|
||||
ctx context.Context,
|
||||
uid uint64,
|
||||
reservation recommendqueue.Reservation,
|
||||
) error {
|
||||
return recommendqueue.AbortReservationContext(
|
||||
ctx, appg.Redis, uid, reservation,
|
||||
)
|
||||
},
|
||||
excludedModuleIDs: moduleconfmod.ExcludedVideoModuleIDs,
|
||||
findVideos: vidmod.GetRecommendVideosByIDsContext,
|
||||
now: time.Now,
|
||||
}
|
||||
return fetch(ctx, uid, size, requestID, opts, deps)
|
||||
}
|
||||
|
||||
func scopedRequestID(scope string, size int, requestID string) (string, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(requestID) > maxRequestIDLength {
|
||||
return "", fmt.Errorf("short recommend request ID exceeds %d bytes", maxRequestIDLength)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%s", scope, size, requestID)))
|
||||
return fmt.Sprintf("%x", sum[:16]), nil
|
||||
}
|
||||
|
||||
func configuredFetchOptions() fetchOptions {
|
||||
opts := fetchOptions{
|
||||
maxBatches: defaultMaxBatches,
|
||||
scanMultiplier: defaultScanMultiplier,
|
||||
}
|
||||
if appg.Conf == nil {
|
||||
return opts
|
||||
}
|
||||
if configured := appg.Conf.ShortRecommend.MaxBatches; configured > 0 {
|
||||
opts.maxBatches = clamp(configured, 1, maxAllowedBatches)
|
||||
}
|
||||
if configured := appg.Conf.ShortRecommend.ScanMultiplier; configured > 0 {
|
||||
opts.scanMultiplier = clamp(configured, 1, maxAllowedMultiplier)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func fetch(
|
||||
ctx context.Context,
|
||||
uid uint64,
|
||||
size int,
|
||||
requestID string,
|
||||
opts fetchOptions,
|
||||
deps fetchDependencies,
|
||||
) (result FetchResult, err error) {
|
||||
if ctx == nil {
|
||||
return result, fmt.Errorf("short recommend context must not be nil")
|
||||
}
|
||||
if err = ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if uid == 0 {
|
||||
return result, fmt.Errorf("anonymous user has no independent queue offset")
|
||||
}
|
||||
if size <= 0 {
|
||||
return result, nil
|
||||
}
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if len(requestID) > maxRequestIDLength {
|
||||
return result, fmt.Errorf("short recommend request ID exceeds %d bytes", maxRequestIDLength)
|
||||
}
|
||||
opts.maxBatches = clamp(opts.maxBatches, 1, maxAllowedBatches)
|
||||
opts.scanMultiplier = clamp(opts.scanMultiplier, 1, maxAllowedMultiplier)
|
||||
scanBudget := size * opts.scanMultiplier
|
||||
if scanBudget < size {
|
||||
scanBudget = size
|
||||
}
|
||||
if batchBudget := size * opts.maxBatches; scanBudget > batchBudget {
|
||||
// 预留不会超过本次最多能查询的范围,避免把永远不会扫描的ID
|
||||
// 从Redis传到App进程。
|
||||
scanBudget = batchBudget
|
||||
}
|
||||
|
||||
excluded, err := deps.excludedModuleIDs(deps.now(), true)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
excludedSet := make(map[string]struct{}, len(excluded))
|
||||
for _, moduleID := range excluded {
|
||||
excludedSet[moduleID] = struct{}{}
|
||||
}
|
||||
|
||||
reservation, reserveErr := reserveWithBusyRetry(
|
||||
ctx, deps.reserve, uid, scanBudget, requestID,
|
||||
)
|
||||
result.QueueVersion = reservation.Version
|
||||
result.QueueLength = reservation.Length
|
||||
if reserveErr != nil {
|
||||
return result, reserveErr
|
||||
}
|
||||
if reservation.Version == "" || reservation.Length <= 0 ||
|
||||
reservation.Reserved <= 0 || len(reservation.IDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
committed := false
|
||||
defer func() {
|
||||
if committed {
|
||||
return
|
||||
}
|
||||
abortCtx, cancel := context.WithTimeout(context.Background(), reservationAbortTTL)
|
||||
defer cancel()
|
||||
_ = deps.abort(abortCtx, uid, reservation)
|
||||
}()
|
||||
|
||||
result.Videos = make([]*vidmod.VideoModel, 0, size)
|
||||
seen := make(map[primitive.ObjectID]struct{}, size)
|
||||
cursor := 0
|
||||
for cursor < len(reservation.IDs) &&
|
||||
(reservation.AlreadyCommitted ||
|
||||
(result.Batches < opts.maxBatches && len(result.Videos) < size)) {
|
||||
batchSize := size
|
||||
if !reservation.AlreadyCommitted {
|
||||
batchSize = size - len(result.Videos)
|
||||
} else if batchSize > 0 {
|
||||
// 已提交请求的receipt记录的是原实际消费前缀。重试必须扫描并
|
||||
// 确认完整前缀,再以原consumed幂等提交,不能因当前状态变化
|
||||
// 缩短已确认的消费范围。固定按请求大小分批,避免当前只差一条
|
||||
// 有效视频时退化为大量单ID Mongo查询。
|
||||
}
|
||||
if remaining := len(reservation.IDs) - cursor; batchSize > remaining {
|
||||
batchSize = remaining
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
break
|
||||
}
|
||||
end := cursor + batchSize
|
||||
batchIDs := reservation.IDs[cursor:end]
|
||||
objectIDs := parseUniqueObjectIDs(batchIDs)
|
||||
videos, findErr := deps.findVideos(ctx, objectIDs)
|
||||
if findErr != nil {
|
||||
result.Videos = nil
|
||||
return result, findErr
|
||||
}
|
||||
result.Batches++
|
||||
result.Scanned += len(batchIDs)
|
||||
cursor = end
|
||||
byID := make(map[primitive.ObjectID]*vidmod.VideoModel, len(videos))
|
||||
for _, video := range videos {
|
||||
if video == nil {
|
||||
continue
|
||||
}
|
||||
if _, blocked := excludedSet[video.MID]; blocked {
|
||||
continue
|
||||
}
|
||||
byID[video.ID] = video
|
||||
}
|
||||
before := len(result.Videos)
|
||||
for _, rawID := range batchIDs {
|
||||
if len(result.Videos) >= size {
|
||||
break
|
||||
}
|
||||
id, parseErr := primitive.ObjectIDFromHex(rawID)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
video := byID[id]
|
||||
if video == nil {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result.Videos = append(result.Videos, video)
|
||||
if len(result.Videos) == size {
|
||||
break
|
||||
}
|
||||
}
|
||||
result.Filtered += len(batchIDs) - (len(result.Videos) - before)
|
||||
}
|
||||
if result.Scanned <= 0 {
|
||||
result.Videos = nil
|
||||
return result, fmt.Errorf("short recommend reservation was not scanned")
|
||||
}
|
||||
commitErr := deps.commit(ctx, uid, reservation, result.Scanned)
|
||||
if commitErr != nil && ctx.Err() == nil {
|
||||
// 同一receipt重试可确认“脚本已执行但响应丢失”的不确定提交,
|
||||
// 不会再次推进offset。
|
||||
commitErr = deps.commit(ctx, uid, reservation, result.Scanned)
|
||||
}
|
||||
if commitErr != nil {
|
||||
result.Videos = nil
|
||||
return result, commitErr
|
||||
}
|
||||
committed = true
|
||||
result.BudgetExceeded = len(result.Videos) < size &&
|
||||
result.QueueLength > 0 &&
|
||||
result.Scanned < result.QueueLength &&
|
||||
(result.Scanned >= scanBudget || result.Batches >= opts.maxBatches)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func reserveWithBusyRetry(
|
||||
ctx context.Context,
|
||||
reserve reserveFunc,
|
||||
uid uint64,
|
||||
size int,
|
||||
requestID string,
|
||||
) (recommendqueue.Reservation, error) {
|
||||
var reservation recommendqueue.Reservation
|
||||
var err error
|
||||
for attempt := 0; attempt <= reservationBusyRetries; attempt++ {
|
||||
reservation, err = reserve(ctx, uid, size, requestID)
|
||||
if !errors.Is(err, recommendqueue.ErrReservationBusy) ||
|
||||
attempt == reservationBusyRetries {
|
||||
return reservation, err
|
||||
}
|
||||
delay := reservationBusyBackoff << attempt
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return reservation, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return reservation, err
|
||||
}
|
||||
|
||||
func parseUniqueObjectIDs(ids []string) []primitive.ObjectID {
|
||||
out := make([]primitive.ObjectID, 0, len(ids))
|
||||
seen := make(map[primitive.ObjectID]struct{}, len(ids))
|
||||
for _, rawID := range ids {
|
||||
id, err := primitive.ObjectIDFromHex(rawID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clamp(value, low, high int) int {
|
||||
if value < low {
|
||||
return low
|
||||
}
|
||||
if value > high {
|
||||
return high
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
package shortrecommendser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
recommendqueue "91porn-server/common/shortrecommend"
|
||||
"91porn-server/models/v/vidmod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
func TestFetchReservesOncePreservesOrderAndCommitsConsumedPrefix(t *testing.T) {
|
||||
validA := primitive.NewObjectID()
|
||||
downShelf := primitive.NewObjectID()
|
||||
excluded := primitive.NewObjectID()
|
||||
validB := primitive.NewObjectID()
|
||||
ids := []string{validA.Hex(), downShelf.Hex(), excluded.Hex(), validB.Hex()}
|
||||
deps := testFetchDependencies(
|
||||
testReservation(ids, len(ids)),
|
||||
func(_ context.Context, batch []primitive.ObjectID) ([]*vidmod.VideoModel, error) {
|
||||
out := make([]*vidmod.VideoModel, 0, len(batch))
|
||||
for _, id := range batch {
|
||||
switch id {
|
||||
case validA, validB:
|
||||
out = append(out, &vidmod.VideoModel{ID: id})
|
||||
case excluded:
|
||||
out = append(out, &vidmod.VideoModel{ID: id, MID: "blocked"})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
)
|
||||
deps.excludedModuleIDs = func(time.Time, bool) ([]string, error) {
|
||||
return []string{"blocked"}, nil
|
||||
}
|
||||
reserveCalls, committed, aborted := 0, 0, 0
|
||||
originalReserve := deps.reserve
|
||||
deps.reserve = func(
|
||||
ctx context.Context, uid uint64, size int, requestID string,
|
||||
) (recommendqueue.Reservation, error) {
|
||||
reserveCalls++
|
||||
if size != 10 {
|
||||
t.Fatalf("reserve size = %d, want scan budget 10", size)
|
||||
}
|
||||
return originalReserve(ctx, uid, size, requestID)
|
||||
}
|
||||
deps.commit = func(
|
||||
_ context.Context,
|
||||
_ uint64,
|
||||
_ recommendqueue.Reservation,
|
||||
consumed int,
|
||||
) error {
|
||||
committed = consumed
|
||||
return nil
|
||||
}
|
||||
deps.abort = func(
|
||||
context.Context, uint64, recommendqueue.Reservation,
|
||||
) error {
|
||||
aborted++
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := fetch(context.Background(), 7, 2, "request-1", fetchOptions{
|
||||
maxBatches: 5, scanMultiplier: 5,
|
||||
}, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch() error = %v", err)
|
||||
}
|
||||
if len(result.Videos) != 2 ||
|
||||
result.Videos[0].ID != validA ||
|
||||
result.Videos[1].ID != validB {
|
||||
t.Fatalf("videos = %#v, want queue ordered valid videos", result.Videos)
|
||||
}
|
||||
if reserveCalls != 1 || committed != 4 || aborted != 0 {
|
||||
t.Fatalf("reserve=%d committed=%d aborted=%d", reserveCalls, committed, aborted)
|
||||
}
|
||||
if result.Scanned != 4 || result.Filtered != 2 || result.Batches != 3 {
|
||||
t.Fatalf("metrics = %+v", result)
|
||||
}
|
||||
if result.QueueVersion != "20260731-r1" || result.QueueLength != len(ids) {
|
||||
t.Fatalf("queue metadata = %+v", result)
|
||||
}
|
||||
if result.BudgetExceeded {
|
||||
t.Fatal("BudgetExceeded = true, want false after a full queue scan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCommitsOnlyBoundedScannedPrefix(t *testing.T) {
|
||||
ids := make([]string, 30)
|
||||
for i := range ids {
|
||||
ids[i] = primitive.NewObjectID().Hex()
|
||||
}
|
||||
reservation := testReservation(ids, 100)
|
||||
deps := testFetchDependencies(
|
||||
reservation,
|
||||
func(context.Context, []primitive.ObjectID) ([]*vidmod.VideoModel, error) {
|
||||
return nil, nil
|
||||
},
|
||||
)
|
||||
committed := 0
|
||||
deps.commit = func(
|
||||
_ context.Context,
|
||||
_ uint64,
|
||||
_ recommendqueue.Reservation,
|
||||
consumed int,
|
||||
) error {
|
||||
committed = consumed
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := fetch(context.Background(), 8, 3, "request-2", fetchOptions{
|
||||
maxBatches: 2, scanMultiplier: 10,
|
||||
}, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch() error = %v", err)
|
||||
}
|
||||
if result.Batches != 2 || result.Scanned != 6 || committed != 6 {
|
||||
t.Fatalf("committed=%d result=%+v", committed, result)
|
||||
}
|
||||
if !result.BudgetExceeded {
|
||||
t.Fatal("BudgetExceeded = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMongoFailureAbortsWithoutCommitOrPartialResponse(t *testing.T) {
|
||||
valid := primitive.NewObjectID()
|
||||
filtered := primitive.NewObjectID()
|
||||
failing := primitive.NewObjectID()
|
||||
wantErr := errors.New("mongo unavailable")
|
||||
findCalls := 0
|
||||
deps := testFetchDependencies(
|
||||
testReservation(
|
||||
[]string{valid.Hex(), filtered.Hex(), failing.Hex()},
|
||||
10,
|
||||
),
|
||||
func(_ context.Context, ids []primitive.ObjectID) ([]*vidmod.VideoModel, error) {
|
||||
findCalls++
|
||||
if findCalls == 1 {
|
||||
return []*vidmod.VideoModel{{ID: valid}}, nil
|
||||
}
|
||||
return nil, wantErr
|
||||
},
|
||||
)
|
||||
commitCalls, abortCalls := 0, 0
|
||||
deps.commit = func(
|
||||
context.Context, uint64, recommendqueue.Reservation, int,
|
||||
) error {
|
||||
commitCalls++
|
||||
return nil
|
||||
}
|
||||
deps.abort = func(
|
||||
context.Context, uint64, recommendqueue.Reservation,
|
||||
) error {
|
||||
abortCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := fetch(context.Background(), 9, 2, "request-3", fetchOptions{
|
||||
maxBatches: 5, scanMultiplier: 5,
|
||||
}, deps)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if result.Videos != nil || commitCalls != 0 || abortCalls != 1 {
|
||||
t.Fatalf("result=%+v commitCalls=%d abortCalls=%d", result, commitCalls, abortCalls)
|
||||
}
|
||||
if result.Scanned != 2 || result.Batches != 1 {
|
||||
t.Fatalf("successful scan metrics = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRetriesUncertainCommitWithSameReservation(t *testing.T) {
|
||||
id := primitive.NewObjectID()
|
||||
wantErr := errors.New("connection reset after write")
|
||||
deps := testFetchDependencies(
|
||||
testReservation([]string{id.Hex()}, 10),
|
||||
func(context.Context, []primitive.ObjectID) ([]*vidmod.VideoModel, error) {
|
||||
return []*vidmod.VideoModel{{ID: id}}, nil
|
||||
},
|
||||
)
|
||||
commitCalls := 0
|
||||
deps.commit = func(
|
||||
context.Context, uint64, recommendqueue.Reservation, int,
|
||||
) error {
|
||||
commitCalls++
|
||||
if commitCalls == 1 {
|
||||
return wantErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := fetch(context.Background(), 10, 1, "request-4", fetchOptions{
|
||||
maxBatches: 5, scanMultiplier: 5,
|
||||
}, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch() error = %v", err)
|
||||
}
|
||||
if commitCalls != 2 || len(result.Videos) != 1 {
|
||||
t.Fatalf("commitCalls=%d result=%+v", commitCalls, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCommitFailureAbortsAndSuppressesVideos(t *testing.T) {
|
||||
id := primitive.NewObjectID()
|
||||
wantErr := errors.New("redis unavailable")
|
||||
deps := testFetchDependencies(
|
||||
testReservation([]string{id.Hex()}, 10),
|
||||
func(context.Context, []primitive.ObjectID) ([]*vidmod.VideoModel, error) {
|
||||
return []*vidmod.VideoModel{{ID: id}}, nil
|
||||
},
|
||||
)
|
||||
commitCalls, abortCalls := 0, 0
|
||||
deps.commit = func(
|
||||
context.Context, uint64, recommendqueue.Reservation, int,
|
||||
) error {
|
||||
commitCalls++
|
||||
return wantErr
|
||||
}
|
||||
deps.abort = func(
|
||||
context.Context, uint64, recommendqueue.Reservation,
|
||||
) error {
|
||||
abortCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := fetch(context.Background(), 11, 1, "request-5", fetchOptions{
|
||||
maxBatches: 5, scanMultiplier: 5,
|
||||
}, deps)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if commitCalls != 2 || abortCalls != 1 || result.Videos != nil {
|
||||
t.Fatalf("commit=%d abort=%d result=%+v", commitCalls, abortCalls, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReservationErrorCarriesQueueMetadata(t *testing.T) {
|
||||
deps := testFetchDependencies(recommendqueue.Reservation{}, nil)
|
||||
deps.reserve = func(
|
||||
context.Context, uint64, int, string,
|
||||
) (recommendqueue.Reservation, error) {
|
||||
return recommendqueue.Reservation{
|
||||
Version: "20260731-r2",
|
||||
Length: 100,
|
||||
}, recommendqueue.ErrReservationBusy
|
||||
}
|
||||
|
||||
result, err := fetch(context.Background(), 12, 1, "", fetchOptions{}, deps)
|
||||
if !errors.Is(err, recommendqueue.ErrReservationBusy) {
|
||||
t.Fatalf("error = %v, want ErrReservationBusy", err)
|
||||
}
|
||||
if result.QueueVersion != "20260731-r2" || result.QueueLength != 100 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRetriesBusyReservationWithinRequest(t *testing.T) {
|
||||
id := primitive.NewObjectID()
|
||||
reservation := testReservation([]string{id.Hex()}, 10)
|
||||
deps := testFetchDependencies(
|
||||
reservation,
|
||||
func(context.Context, []primitive.ObjectID) ([]*vidmod.VideoModel, error) {
|
||||
return []*vidmod.VideoModel{{ID: id}}, nil
|
||||
},
|
||||
)
|
||||
reserveCalls := 0
|
||||
deps.reserve = func(
|
||||
context.Context, uint64, int, string,
|
||||
) (recommendqueue.Reservation, error) {
|
||||
reserveCalls++
|
||||
if reserveCalls < 3 {
|
||||
return recommendqueue.Reservation{
|
||||
Version: reservation.Version,
|
||||
Length: reservation.Length,
|
||||
}, recommendqueue.ErrReservationBusy
|
||||
}
|
||||
return reservation, nil
|
||||
}
|
||||
|
||||
result, err := fetch(context.Background(), 12, 1, "", fetchOptions{}, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch() error = %v", err)
|
||||
}
|
||||
if reserveCalls != 3 || len(result.Videos) != 1 {
|
||||
t.Fatalf("reserveCalls=%d result=%+v", reserveCalls, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveBusyRetryHonorsCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
_, err := reserveWithBusyRetry(
|
||||
ctx,
|
||||
func(
|
||||
context.Context, uint64, int, string,
|
||||
) (recommendqueue.Reservation, error) {
|
||||
calls++
|
||||
cancel()
|
||||
return recommendqueue.Reservation{}, recommendqueue.ErrReservationBusy
|
||||
},
|
||||
1,
|
||||
20,
|
||||
"",
|
||||
)
|
||||
if !errors.Is(err, context.Canceled) || calls != 1 {
|
||||
t.Fatalf("error=%v calls=%d", err, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsCanceledContextBeforeDependencies(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
called := false
|
||||
deps := testFetchDependencies(recommendqueue.Reservation{}, nil)
|
||||
deps.reserve = func(
|
||||
context.Context, uint64, int, string,
|
||||
) (recommendqueue.Reservation, error) {
|
||||
called = true
|
||||
return recommendqueue.Reservation{}, nil
|
||||
}
|
||||
|
||||
_, err := fetch(ctx, 13, 1, "request-6", fetchOptions{}, deps)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want context.Canceled", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("dependency called after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWithoutClientRequestIDUsesReservationWithoutBusinessReceiptScope(t *testing.T) {
|
||||
id := primitive.NewObjectID()
|
||||
deps := testFetchDependencies(
|
||||
testReservation([]string{id.Hex()}, 1),
|
||||
func(context.Context, []primitive.ObjectID) ([]*vidmod.VideoModel, error) {
|
||||
return []*vidmod.VideoModel{{ID: id}}, nil
|
||||
},
|
||||
)
|
||||
gotRequestID := "not-called"
|
||||
originalReserve := deps.reserve
|
||||
deps.reserve = func(
|
||||
ctx context.Context, uid uint64, size int, requestID string,
|
||||
) (recommendqueue.Reservation, error) {
|
||||
gotRequestID = requestID
|
||||
return originalReserve(ctx, uid, size, requestID)
|
||||
}
|
||||
|
||||
result, err := fetch(context.Background(), 14, 1, "", fetchOptions{
|
||||
maxBatches: 5, scanMultiplier: 5,
|
||||
}, deps)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch() error = %v", err)
|
||||
}
|
||||
if gotRequestID != "" || len(result.Videos) != 1 {
|
||||
t.Fatalf("requestID=%q result=%+v", gotRequestID, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCommittedReceiptReplaysEntireConsumedPrefix(t *testing.T) {
|
||||
ids := []string{
|
||||
primitive.NewObjectID().Hex(),
|
||||
primitive.NewObjectID().Hex(),
|
||||
primitive.NewObjectID().Hex(),
|
||||
}
|
||||
reservation := testReservation(ids, 10)
|
||||
reservation.AlreadyCommitted = true
|
||||
deps := testFetchDependencies(
|
||||
reservation,
|
||||
func(_ context.Context, batch []primitive.ObjectID) ([]*vidmod.VideoModel, error) {
|
||||
out := make([]*vidmod.VideoModel, 0, len(batch))
|
||||
for _, id := range batch {
|
||||
out = append(out, &vidmod.VideoModel{ID: id})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
)
|
||||
committed := 0
|
||||
deps.commit = func(
|
||||
_ context.Context,
|
||||
_ uint64,
|
||||
_ recommendqueue.Reservation,
|
||||
consumed int,
|
||||
) error {
|
||||
committed = consumed
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := fetch(context.Background(), 15, 1, "request-retry", fetchOptions{
|
||||
maxBatches: 1, scanMultiplier: 1,
|
||||
}, deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if committed != len(ids) || result.Scanned != len(ids) ||
|
||||
len(result.Videos) != 1 {
|
||||
t.Fatalf("committed=%d result=%+v", committed, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCommittedReceiptUsesBoundedMongoBatches(t *testing.T) {
|
||||
const (
|
||||
requestSize = 20
|
||||
consumed = 100
|
||||
)
|
||||
ids := make([]string, consumed)
|
||||
for i := range ids {
|
||||
ids[i] = primitive.NewObjectID().Hex()
|
||||
}
|
||||
reservation := testReservation(ids, consumed)
|
||||
reservation.AlreadyCommitted = true
|
||||
batchSizes := make([]int, 0, consumed/requestSize)
|
||||
deps := testFetchDependencies(
|
||||
reservation,
|
||||
func(_ context.Context, batch []primitive.ObjectID) ([]*vidmod.VideoModel, error) {
|
||||
batchSizes = append(batchSizes, len(batch))
|
||||
if len(batchSizes) > 1 {
|
||||
return nil, nil
|
||||
}
|
||||
videos := make([]*vidmod.VideoModel, 0, requestSize-1)
|
||||
for _, id := range batch[:requestSize-1] {
|
||||
videos = append(videos, &vidmod.VideoModel{ID: id})
|
||||
}
|
||||
return videos, nil
|
||||
},
|
||||
)
|
||||
committed := 0
|
||||
deps.commit = func(
|
||||
_ context.Context,
|
||||
_ uint64,
|
||||
_ recommendqueue.Reservation,
|
||||
value int,
|
||||
) error {
|
||||
committed = value
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := fetch(context.Background(), 16, requestSize, "request-retry", fetchOptions{
|
||||
maxBatches: 1, scanMultiplier: 1,
|
||||
}, deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(batchSizes) != consumed/requestSize {
|
||||
t.Fatalf("Mongo calls=%d batchSizes=%v", len(batchSizes), batchSizes)
|
||||
}
|
||||
for _, batchSize := range batchSizes {
|
||||
if batchSize != requestSize {
|
||||
t.Fatalf("batchSizes=%v", batchSizes)
|
||||
}
|
||||
}
|
||||
if committed != consumed || result.Scanned != consumed ||
|
||||
len(result.Videos) != requestSize-1 {
|
||||
t.Fatalf("committed=%d result=%+v", committed, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedRequestIDSeparatesEntryAndRequestSize(t *testing.T) {
|
||||
first, err := scopedRequestID("recommend-list", 20, "request")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same, _ := scopedRequestID("recommend-list", 20, " request ")
|
||||
otherEntry, _ := scopedRequestID("module-short-all", 20, "request")
|
||||
otherSize, _ := scopedRequestID("recommend-list", 10, "request")
|
||||
if first == "" || first != same {
|
||||
t.Fatalf("scoped IDs first=%q same=%q", first, same)
|
||||
}
|
||||
if first == otherEntry || first == otherSize || otherEntry == otherSize {
|
||||
t.Fatalf("scope collision: %q %q %q", first, otherEntry, otherSize)
|
||||
}
|
||||
blank, err := scopedRequestID("recommend-list", 20, " ")
|
||||
if err != nil || blank != "" {
|
||||
t.Fatalf("blank request ID = %q, %v", blank, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testReservation(ids []string, length int) recommendqueue.Reservation {
|
||||
return recommendqueue.Reservation{
|
||||
Version: "20260731-r1",
|
||||
Length: length,
|
||||
Offset: 0,
|
||||
Reserved: len(ids),
|
||||
IDs: append([]string(nil), ids...),
|
||||
LeaseToken: "lease",
|
||||
ReceiptID: "receipt",
|
||||
}
|
||||
}
|
||||
|
||||
func testFetchDependencies(
|
||||
reservation recommendqueue.Reservation,
|
||||
find func(context.Context, []primitive.ObjectID) ([]*vidmod.VideoModel, error),
|
||||
) fetchDependencies {
|
||||
if find == nil {
|
||||
find = func(context.Context, []primitive.ObjectID) ([]*vidmod.VideoModel, error) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return fetchDependencies{
|
||||
reserve: func(
|
||||
context.Context, uint64, int, string,
|
||||
) (recommendqueue.Reservation, error) {
|
||||
return reservation, nil
|
||||
},
|
||||
commit: func(
|
||||
context.Context, uint64, recommendqueue.Reservation, int,
|
||||
) error {
|
||||
return nil
|
||||
},
|
||||
abort: func(
|
||||
context.Context, uint64, recommendqueue.Reservation,
|
||||
) error {
|
||||
return nil
|
||||
},
|
||||
excludedModuleIDs: func(time.Time, bool) ([]string, error) {
|
||||
return nil, nil
|
||||
},
|
||||
findVideos: find,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user