931 lines
36 KiB
Go
931 lines
36 KiB
Go
//go:build unit
|
|||
|
|
|
||
|
|
package service
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
|
|
"github.com/stretchr/testify/require"
|
||
|
|
)
|
||
|
|
|
||
|
|
const batchImageTestData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||
|
|
|
||
|
|
func TestParseBatchImageResultLine_SuccessShapes(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
line string
|
||
|
|
wantID string
|
||
|
|
wantMime string
|
||
|
|
wantExt string
|
||
|
|
wantCount int
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
name: "gemini_inlineData",
|
||
|
|
line: `{"key":"cover_001","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"` + batchImageTestData + `"}}]}}]}}`,
|
||
|
|
wantID: "cover_001", wantMime: "image/png", wantExt: "png", wantCount: 1,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "snake_case_inline_data",
|
||
|
|
line: `{"custom_id":"cover_002","response":{"candidates":[{"content":{"parts":[{"inline_data":{"mime_type":"image/jpeg","data":"` + batchImageTestData + `"}}]}}]}}`,
|
||
|
|
wantID: "cover_002", wantMime: "image/jpeg", wantExt: "jpg", wantCount: 1,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "vertex_top_level_response",
|
||
|
|
line: `{"customId":"cover_003","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/webp","data":"` + batchImageTestData + `"}}]}}]}}`,
|
||
|
|
wantID: "cover_003", wantMime: "image/webp", wantExt: "webp", wantCount: 1,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "top_level_candidates",
|
||
|
|
line: `{"request":{"key":"cover_004"},"candidates":[{"content":{"parts":[{"inline_data":{"mime_type":"image/png","data":"` + batchImageTestData + `"}},{"inlineData":{"mimeType":"image/png","data":"` + batchImageTestData + `"}}]}}]}`,
|
||
|
|
wantID: "cover_004", wantMime: "image/png", wantExt: "png", wantCount: 2,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tt := range tests {
|
||
|
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
|
got, err := ParseBatchImageResultLine([]byte(tt.line), 7)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Equal(t, tt.wantID, got.CustomID)
|
||
|
|
require.Equal(t, BatchImageParsedStatusSucceeded, got.Status)
|
||
|
|
require.Equal(t, tt.wantMime, got.MimeType)
|
||
|
|
require.Equal(t, tt.wantExt, got.FileExtension)
|
||
|
|
require.Equal(t, tt.wantCount, got.ImageCount)
|
||
|
|
require.Equal(t, 7, got.SourceLineNumber)
|
||
|
|
require.NotContains(t, fmt.Sprintf("%+v", got), batchImageTestData)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestParseBatchImageResultLine_FailureShapes(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
line string
|
||
|
|
wantCode string
|
||
|
|
}{
|
||
|
|
{name: "status_row", line: `{"key":"cover_001","status":{"code":3,"message":"invalid argument: bad prompt"}}`, wantCode: "INVALID_ARGUMENT"},
|
||
|
|
{name: "error_row", line: `{"key":"cover_002","error":{"code":"SAFETY","message":"blocked by safety policy"}}`, wantCode: "SAFETY_BLOCKED"},
|
||
|
|
{name: "quota_row", line: `{"key":"cover_003","error":{"code":"RESOURCE_EXHAUSTED","message":"quota exceeded"}}`, wantCode: "PROVIDER_RATE_LIMITED"},
|
||
|
|
{name: "empty_image_output", line: `{"key":"cover_004","response":{"candidates":[{"content":{"parts":[{"text":"no image"}]}}]}}`, wantCode: "EMPTY_IMAGE_OUTPUT"},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tt := range tests {
|
||
|
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
|
got, err := ParseBatchImageResultLine([]byte(tt.line), 1)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Equal(t, BatchImageParsedStatusFailed, got.Status)
|
||
|
|
require.Equal(t, tt.wantCode, got.ErrorCode)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestParseBatchImageResultLine_RejectsMissingCustomIDAndDoesNotLeakData(t *testing.T) {
|
||
|
|
_, err := ParseBatchImageResultLine([]byte(`{"response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"`+batchImageTestData+`"}}]}}]}}`), 3)
|
||
|
|
require.ErrorIs(t, err, ErrBatchImageIndexParseFailed)
|
||
|
|
require.NotContains(t, err.Error(), batchImageTestData)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestBatchImageResultIndexer_WritesCountsAndReplacesItems(t *testing.T) {
|
||
|
|
output := strings.Join([]string{
|
||
|
|
`{"key":"ok","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"` + batchImageTestData + `"}}]}}]}}`,
|
||
|
|
`{"key":"bad","error":{"code":"SAFETY","message":"blocked by safety policy"}}`,
|
||
|
|
}, "\n") + "\n"
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
outputRef := "files/output"
|
||
|
|
job := &BatchImageJob{BatchID: "imgbatch_index", ProviderOutputRef: &outputRef}
|
||
|
|
provider := &fakeProcessorProvider{result: output}
|
||
|
|
|
||
|
|
result, err := (&BatchImageResultIndexer{Repo: repo}).Index(context.Background(), job, provider, &Account{})
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.True(t, provider.openResultCalled)
|
||
|
|
require.Equal(t, 1, result.SuccessCount)
|
||
|
|
require.Equal(t, 1, result.FailCount)
|
||
|
|
require.Equal(t, 2, result.TotalCount)
|
||
|
|
require.Equal(t, 1, repo.replaceCalls)
|
||
|
|
require.Len(t, repo.items[job.BatchID], 2)
|
||
|
|
require.Equal(t, BatchImageItemStatusSuccess, repo.items[job.BatchID][0].Status)
|
||
|
|
require.Equal(t, BatchImageItemStatusFailed, repo.items[job.BatchID][1].Status)
|
||
|
|
require.Equal(t, BatchImageCounts{SuccessCount: 1, FailCount: 1}, repo.counts[job.BatchID])
|
||
|
|
require.NotContains(t, fmt.Sprintf("%+v", repo.items[job.BatchID]), batchImageTestData)
|
||
|
|
|
||
|
|
// 重新索引时与现有 custom_id 集对账:未知的 "ok2" 被丢弃,
|
||
|
|
// 输出中缺失的 ok/bad 补为 PROVIDER_RESULT_MISSING 失败记录。
|
||
|
|
provider.result = `{"key":"ok2","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/webp","data":"` + batchImageTestData + `"}}]}}]}}` + "\n"
|
||
|
|
result, err = (&BatchImageResultIndexer{Repo: repo}).Index(context.Background(), job, provider, &Account{})
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Equal(t, 2, result.TotalCount)
|
||
|
|
require.Equal(t, 0, result.SuccessCount)
|
||
|
|
require.Equal(t, 2, result.FailCount)
|
||
|
|
require.Len(t, repo.items[job.BatchID], 2)
|
||
|
|
gotIDs := []string{repo.items[job.BatchID][0].CustomID, repo.items[job.BatchID][1].CustomID}
|
||
|
|
require.ElementsMatch(t, []string{"ok", "bad"}, gotIDs)
|
||
|
|
for _, item := range repo.items[job.BatchID] {
|
||
|
|
require.Equal(t, BatchImageItemStatusFailed, item.Status)
|
||
|
|
require.Equal(t, "PROVIDER_RESULT_MISSING", batchImageDerefString(item.ErrorCode))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestBatchImageResultIndexer_ReconcilesMissingAndUnknownCustomIDs(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
outputRef := "files/output"
|
||
|
|
job := &BatchImageJob{BatchID: "imgbatch_reconcile", ProviderOutputRef: &outputRef, ItemCount: 3}
|
||
|
|
// 预创建提交时的 pending 条目(提交流程的行为)。
|
||
|
|
require.NoError(t, repo.BulkCreateBatchImageItems(context.Background(), []CreateBatchImageItemParams{
|
||
|
|
{JobID: job.BatchID, CustomID: "a", Status: BatchImageItemStatusPending},
|
||
|
|
{JobID: job.BatchID, CustomID: "b", Status: BatchImageItemStatusPending},
|
||
|
|
{JobID: job.BatchID, CustomID: "c", Status: BatchImageItemStatusPending},
|
||
|
|
}))
|
||
|
|
// provider 输出:a 成功,b 失败,c 漏掉,多出未知的 x。
|
||
|
|
output := strings.Join([]string{
|
||
|
|
`{"key":"a","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"` + batchImageTestData + `"}}]}}]}}`,
|
||
|
|
`{"key":"b","error":{"code":"SAFETY","message":"blocked"}}`,
|
||
|
|
`{"key":"x","error":{"code":"UNKNOWN","message":"not ours"}}`,
|
||
|
|
}, "\n") + "\n"
|
||
|
|
provider := &fakeProcessorProvider{result: output}
|
||
|
|
|
||
|
|
result, err := (&BatchImageResultIndexer{Repo: repo}).Index(context.Background(), job, provider, &Account{})
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Equal(t, 3, result.TotalCount)
|
||
|
|
require.Equal(t, 1, result.SuccessCount)
|
||
|
|
require.Equal(t, 2, result.FailCount)
|
||
|
|
require.Len(t, repo.items[job.BatchID], 3)
|
||
|
|
byID := make(map[string]CreateBatchImageItemParams)
|
||
|
|
for _, item := range repo.items[job.BatchID] {
|
||
|
|
byID[item.CustomID] = item
|
||
|
|
}
|
||
|
|
require.NotContains(t, byID, "x")
|
||
|
|
require.Equal(t, BatchImageItemStatusSuccess, byID["a"].Status)
|
||
|
|
require.Equal(t, BatchImageItemStatusFailed, byID["b"].Status)
|
||
|
|
require.Equal(t, BatchImageItemStatusFailed, byID["c"].Status)
|
||
|
|
require.Equal(t, "PROVIDER_RESULT_MISSING", batchImageDerefString(byID["c"].ErrorCode))
|
||
|
|
// 对账后 success+fail == item_count,结算计数校验可通过。
|
||
|
|
require.Equal(t, job.ItemCount, result.SuccessCount+result.FailCount)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestBatchImageResultIndexer_EmptyInvalidAndDuplicateOutput(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
body string
|
||
|
|
want error
|
||
|
|
}{
|
||
|
|
{name: "empty", body: "\n", want: ErrBatchImageIndexNoResultLines},
|
||
|
|
{name: "invalid_json", body: "{bad-json}\n", want: ErrBatchImageIndexParseFailed},
|
||
|
|
{name: "duplicate_custom_id", body: `{"key":"dup","error":{"message":"one"}}` + "\n" + `{"key":"dup","error":{"message":"two"}}` + "\n", want: ErrBatchImageDuplicateCustomID},
|
||
|
|
}
|
||
|
|
for _, tt := range tests {
|
||
|
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
_, err := (&BatchImageResultIndexer{Repo: repo}).Index(context.Background(), &BatchImageJob{BatchID: "imgbatch_bad"}, &fakeProcessorProvider{result: tt.body}, &Account{})
|
||
|
|
require.ErrorIs(t, err, tt.want)
|
||
|
|
require.Empty(t, repo.items["imgbatch_bad"])
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestBatchImageProviderProcessor_ValidationAndTerminalCases(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
accountID := int64(10)
|
||
|
|
providerJob := "providers/job"
|
||
|
|
|
||
|
|
t.Run("terminal job returns without provider call", func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
repo.jobs["imgbatch_done"] = &BatchImageJob{BatchID: "imgbatch_done", Status: BatchImageJobStatusFailed}
|
||
|
|
provider := &fakeProcessorProvider{}
|
||
|
|
got, err := (&BatchImageProviderProcessor{
|
||
|
|
Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(provider), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}},
|
||
|
|
}).Process(ctx, "imgbatch_done")
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.True(t, got.Terminal)
|
||
|
|
require.False(t, provider.getCalled)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("missing provider", func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
repo.jobs["imgbatch_missing_provider"] = &BatchImageJob{BatchID: "imgbatch_missing_provider", Status: BatchImageJobStatusSubmitted, Provider: "missing", AccountID: &accountID, ProviderJobName: &providerJob}
|
||
|
|
_, err := (&BatchImageProviderProcessor{Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}}}).Process(ctx, "imgbatch_missing_provider")
|
||
|
|
require.ErrorIs(t, err, ErrBatchImageUnsupportedProvider)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("missing account id", func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
repo.jobs["imgbatch_missing_account"] = &BatchImageJob{BatchID: "imgbatch_missing_account", Status: BatchImageJobStatusSubmitted, Provider: "fake", ProviderJobName: &providerJob}
|
||
|
|
_, err := (&BatchImageProviderProcessor{Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(&fakeProcessorProvider{}), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}}}).Process(ctx, "imgbatch_missing_account")
|
||
|
|
require.ErrorIs(t, err, ErrBatchImageMissingAccountID)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("missing provider job name", func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
repo.jobs["imgbatch_missing_name"] = &BatchImageJob{BatchID: "imgbatch_missing_name", Status: BatchImageJobStatusSubmitted, Provider: "fake", AccountID: &accountID}
|
||
|
|
_, err := (&BatchImageProviderProcessor{Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(&fakeProcessorProvider{}), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}}}).Process(ctx, "imgbatch_missing_name")
|
||
|
|
require.ErrorIs(t, err, ErrBatchImageMissingProviderJobName)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestBatchImageProviderProcessor_StatusFlow(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
accountID := int64(10)
|
||
|
|
providerJob := "providers/job"
|
||
|
|
newJob := func(status string) *BatchImageJob {
|
||
|
|
return &BatchImageJob{BatchID: "imgbatch_flow", Status: status, Provider: "fake", AccountID: &accountID, ProviderJobName: &providerJob}
|
||
|
|
}
|
||
|
|
|
||
|
|
t.Run("running status updates and requeues", func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusSubmitted)
|
||
|
|
provider := &fakeProcessorProvider{status: &BatchProviderStatus{InternalState: BatchProviderStateRunning, RawState: "RUNNING", SuggestedRequeueAfter: 12 * time.Second}}
|
||
|
|
got, err := newTestBatchImageProcessor(repo, provider).Process(ctx, "imgbatch_flow")
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.False(t, got.Terminal)
|
||
|
|
require.Equal(t, 12*time.Second, got.RequeueAfter)
|
||
|
|
require.Equal(t, BatchImageJobStatusRunning, repo.jobs["imgbatch_flow"].Status)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("queued status requeues", func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusSubmitted)
|
||
|
|
provider := &fakeProcessorProvider{status: &BatchProviderStatus{InternalState: BatchProviderStateQueued}}
|
||
|
|
got, err := newTestBatchImageProcessor(repo, provider).Process(ctx, "imgbatch_flow")
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.False(t, got.Terminal)
|
||
|
|
require.Equal(t, defaultBatchImageProcessorRequeue, got.RequeueAfter)
|
||
|
|
require.Equal(t, BatchImageJobStatusSubmitted, repo.jobs["imgbatch_flow"].Status)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("transient provider get error requeues", func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusSubmitted)
|
||
|
|
provider := &fakeProcessorProvider{getErr: errors.New("temporary upstream failure")}
|
||
|
|
got, err := newTestBatchImageProcessor(repo, provider).Process(ctx, "imgbatch_flow")
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.False(t, got.Terminal)
|
||
|
|
require.Equal(t, time.Minute, got.RequeueAfter)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("succeeded indexes and settles from submitted", func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusSubmitted)
|
||
|
|
provider := &fakeProcessorProvider{
|
||
|
|
status: &BatchProviderStatus{InternalState: BatchProviderStateSucceeded, RawState: "SUCCEEDED", ProviderOutputRef: "files/output"},
|
||
|
|
result: `{"key":"ok","response":{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"` + batchImageTestData + `"}}]}}]}}` + "\n",
|
||
|
|
}
|
||
|
|
got, err := newTestBatchImageProcessor(repo, provider).Process(ctx, "imgbatch_flow")
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.False(t, got.Terminal)
|
||
|
|
require.Equal(t, time.Millisecond, got.RequeueAfter)
|
||
|
|
require.Equal(t, BatchImageJobStatusSettling, repo.jobs["imgbatch_flow"].Status)
|
||
|
|
require.Equal(t, "files/output", batchImageDerefString(repo.jobs["imgbatch_flow"].ProviderOutputRef))
|
||
|
|
require.Equal(t, []string{BatchImageJobStatusIndexing, BatchImageJobStatusSettling}, repo.transitions["imgbatch_flow"])
|
||
|
|
require.Equal(t, BatchImageCounts{SuccessCount: 1}, repo.counts["imgbatch_flow"])
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("failed provider marks job failed", func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusRunning)
|
||
|
|
provider := &fakeProcessorProvider{status: &BatchProviderStatus{InternalState: BatchProviderStateFailed, RawState: "FAILED", ErrorCode: "BAD_PROMPT", ErrorMessage: "bad prompt"}}
|
||
|
|
got, err := newTestBatchImageProcessor(repo, provider).Process(ctx, "imgbatch_flow")
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.True(t, got.Terminal)
|
||
|
|
require.Equal(t, BatchImageJobStatusFailed, repo.jobs["imgbatch_flow"].Status)
|
||
|
|
require.Equal(t, "BAD_PROMPT", batchImageDerefString(repo.jobs["imgbatch_flow"].LastErrorCode))
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("cancelled provider marks job cancelled", func(t *testing.T) {
|
||
|
|
repo := newFakeBatchImageRepository()
|
||
|
|
repo.jobs["imgbatch_flow"] = newJob(BatchImageJobStatusRunning)
|
||
|
|
apiKeyID := int64(22)
|
||
|
|
holdAmount := 0.5
|
||
|
|
repo.jobs["imgbatch_flow"].UserID = 11
|
||
|
|
repo.jobs["imgbatch_flow"].APIKeyID = &apiKeyID
|
||
|
|
repo.jobs["imgbatch_flow"].EstimatedCost = holdAmount
|
||
|
|
repo.jobs["imgbatch_flow"].HoldAmount = &holdAmount
|
||
|
|
provider := &fakeProcessorProvider{status: &BatchProviderStatus{InternalState: BatchProviderStateCancelled, RawState: "CANCELLED"}}
|
||
|
|
processor := newTestBatchImageProcessor(repo, provider)
|
||
|
|
billing := &fakeBatchImageBillingRepo{}
|
||
|
|
processor.BillingRepo = billing
|
||
|
|
got, err := processor.Process(ctx, "imgbatch_flow")
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.True(t, got.Terminal)
|
||
|
|
require.Equal(t, BatchImageJobStatusCancelled, repo.jobs["imgbatch_flow"].Status)
|
||
|
|
require.Len(t, billing.releases, 1)
|
||
|
|
require.Equal(t, BatchImageReleaseRequestID("imgbatch_flow"), billing.releases[0].RequestID)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCanTransitionBatchImageJob_PR5DirectIndexing(t *testing.T) {
|
||
|
|
require.True(t, CanTransitionBatchImageJob(BatchImageJobStatusSubmitted, BatchImageJobStatusIndexing))
|
||
|
|
require.True(t, CanTransitionBatchImageJob(BatchImageJobStatusSubmitted, BatchImageJobStatusFailed))
|
||
|
|
require.True(t, CanTransitionBatchImageJob(BatchImageJobStatusIndexing, BatchImageJobStatusFailed))
|
||
|
|
}
|
||
|
|
|
||
|
|
func newTestBatchImageProcessor(repo *fakeBatchImageRepository, provider *fakeProcessorProvider) *BatchImageProviderProcessor {
|
||
|
|
return &BatchImageProviderProcessor{
|
||
|
|
Repo: repo,
|
||
|
|
ProviderRegistry: NewBatchImageProviderRegistry(provider),
|
||
|
|
AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}},
|
||
|
|
Indexer: &BatchImageResultIndexer{Repo: repo},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type fakeBatchImageAccountResolver struct {
|
||
|
|
account *Account
|
||
|
|
err error
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageAccountResolver) ResolveBatchImageAccount(context.Context, int64) (*Account, error) {
|
||
|
|
if r.err != nil {
|
||
|
|
return nil, r.err
|
||
|
|
}
|
||
|
|
return r.account, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type fakeProcessorProvider struct {
|
||
|
|
status *BatchProviderStatus
|
||
|
|
getErr error
|
||
|
|
result string
|
||
|
|
|
||
|
|
getCalled bool
|
||
|
|
openResultCalled bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *fakeProcessorProvider) Name() string { return "fake" }
|
||
|
|
func (p *fakeProcessorProvider) SupportsAccount(*Account) bool {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
func (p *fakeProcessorProvider) Submit(context.Context, *BatchImageJob, *Account, BatchImageInput) (*BatchProviderJob, error) {
|
||
|
|
panic("Submit must not be called by PR5 processor")
|
||
|
|
}
|
||
|
|
func (p *fakeProcessorProvider) Get(context.Context, *BatchImageJob, *Account) (*BatchProviderStatus, error) {
|
||
|
|
p.getCalled = true
|
||
|
|
if p.getErr != nil {
|
||
|
|
return nil, p.getErr
|
||
|
|
}
|
||
|
|
if p.status == nil {
|
||
|
|
return &BatchProviderStatus{InternalState: BatchProviderStateQueued}, nil
|
||
|
|
}
|
||
|
|
return p.status, nil
|
||
|
|
}
|
||
|
|
func (p *fakeProcessorProvider) Cancel(context.Context, *BatchImageJob, *Account) error { return nil }
|
||
|
|
func (p *fakeProcessorProvider) OpenResult(context.Context, *BatchImageJob, *Account) (io.ReadCloser, string, error) {
|
||
|
|
p.openResultCalled = true
|
||
|
|
return io.NopCloser(strings.NewReader(p.result)), "application/jsonl", nil
|
||
|
|
}
|
||
|
|
func (p *fakeProcessorProvider) Cleanup(context.Context, *BatchImageJob, *Account, CleanupTarget) error {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type fakeBatchImageRepository struct {
|
||
|
|
jobs map[string]*BatchImageJob
|
||
|
|
items map[string][]CreateBatchImageItemParams
|
||
|
|
counts map[string]BatchImageCounts
|
||
|
|
transitions map[string][]string
|
||
|
|
events map[string][]string
|
||
|
|
transitionErr error
|
||
|
|
replaceCalls int
|
||
|
|
}
|
||
|
|
|
||
|
|
func newFakeBatchImageRepository() *fakeBatchImageRepository {
|
||
|
|
return &fakeBatchImageRepository{
|
||
|
|
jobs: make(map[string]*BatchImageJob),
|
||
|
|
items: make(map[string][]CreateBatchImageItemParams),
|
||
|
|
counts: make(map[string]BatchImageCounts),
|
||
|
|
transitions: make(map[string][]string),
|
||
|
|
events: make(map[string][]string),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) CreateBatchImageJob(_ context.Context, params CreateBatchImageJobParams) (*BatchImageJob, error) {
|
||
|
|
job := &BatchImageJob{
|
||
|
|
BatchID: params.BatchID,
|
||
|
|
UserID: params.UserID,
|
||
|
|
APIKeyID: params.APIKeyID,
|
||
|
|
AccountID: params.AccountID,
|
||
|
|
Status: params.Status,
|
||
|
|
Provider: params.Provider,
|
||
|
|
Model: params.Model,
|
||
|
|
TaskName: params.TaskName,
|
||
|
|
ProviderJobName: params.ProviderJobName,
|
||
|
|
ItemCount: params.ItemCount,
|
||
|
|
EstimatedCost: params.EstimatedCost,
|
||
|
|
HoldAmount: params.HoldAmount,
|
||
|
|
HoldID: params.HoldID,
|
||
|
|
BaseUnitPrice: params.BaseUnitPrice,
|
||
|
|
GroupRateMultiplier: params.GroupRateMultiplier,
|
||
|
|
AccountRateMultiplier: params.AccountRateMultiplier,
|
||
|
|
BatchDiscountMultiplier: params.BatchDiscountMultiplier,
|
||
|
|
HoldMultiplier: params.HoldMultiplier,
|
||
|
|
BillableUnitPrice: params.BillableUnitPrice,
|
||
|
|
HoldUnitPrice: params.HoldUnitPrice,
|
||
|
|
PricingSnapshotVersion: params.PricingSnapshotVersion,
|
||
|
|
Currency: params.Currency,
|
||
|
|
IdempotencyKey: params.IdempotencyKey,
|
||
|
|
RequestHash: params.RequestHash,
|
||
|
|
SessionID: params.SessionID,
|
||
|
|
CreatedAt: time.Now(),
|
||
|
|
}
|
||
|
|
r.jobs[job.BatchID] = job
|
||
|
|
return job, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) GetBatchImageJobByBatchID(_ context.Context, batchID string) (*BatchImageJob, error) {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return nil, ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
return job, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) GetBatchImageJobByIdempotencyKey(_ context.Context, userID, apiKeyID int64, key string) (*BatchImageJob, error) {
|
||
|
|
for _, job := range r.jobs {
|
||
|
|
if job.UserID == userID && job.APIKeyID != nil && *job.APIKeyID == apiKeyID && batchImageDerefString(job.IdempotencyKey) == key {
|
||
|
|
return job, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil, ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) GetBatchImageJobByBatchIDForOwner(_ context.Context, userID, apiKeyID int64, batchID string) (*BatchImageJob, error) {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok || job.UserID != userID || job.APIKeyID == nil || *job.APIKeyID != apiKeyID {
|
||
|
|
return nil, ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
return job, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) ListBatchImageJobsForOwner(_ context.Context, userID, apiKeyID int64, filter BatchImageJobFilter) ([]*BatchImageJob, error) {
|
||
|
|
limit := filter.Limit
|
||
|
|
if limit <= 0 || limit > 100 {
|
||
|
|
limit = 20
|
||
|
|
}
|
||
|
|
offset := filter.Offset
|
||
|
|
if offset < 0 {
|
||
|
|
offset = 0
|
||
|
|
}
|
||
|
|
var jobs []*BatchImageJob
|
||
|
|
for _, job := range r.jobs {
|
||
|
|
if job.UserID != userID || job.APIKeyID == nil || *job.APIKeyID != apiKeyID {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if filter.Status != "" && job.Status != filter.Status {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if filter.TaskNameLike != "" && !strings.Contains(strings.ToLower(job.TaskName), strings.ToLower(filter.TaskNameLike)) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if filter.ExcludeDeleted && job.UserDeletedAt != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if filter.Downloaded != nil {
|
||
|
|
downloaded := job.DownloadedAt != nil
|
||
|
|
if downloaded != *filter.Downloaded {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if filter.CreatedAfter != nil && job.CreatedAt.Before(*filter.CreatedAfter) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if filter.CreatedBefore != nil && !job.CreatedAt.Before(*filter.CreatedBefore) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if offset > 0 {
|
||
|
|
offset--
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
jobs = append(jobs, job)
|
||
|
|
if len(jobs) >= limit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return jobs, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) GetBatchImageJobByID(_ context.Context, id int64) (*BatchImageJob, error) {
|
||
|
|
for _, job := range r.jobs {
|
||
|
|
if job.ID == id {
|
||
|
|
return job, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil, ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) TransitionBatchImageJobStatus(_ context.Context, batchID, toStatus string, opts BatchImageTransitionOptions) error {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if !CanTransitionBatchImageJob(job.Status, toStatus) {
|
||
|
|
return ErrBatchImageInvalidTransition
|
||
|
|
}
|
||
|
|
if r.transitionErr != nil {
|
||
|
|
return r.transitionErr
|
||
|
|
}
|
||
|
|
job.Status = toStatus
|
||
|
|
job.LastErrorCode = opts.ErrorCode
|
||
|
|
job.LastErrorMessage = opts.ErrorMessage
|
||
|
|
r.transitions[batchID] = append(r.transitions[batchID], toStatus)
|
||
|
|
if opts.EventType != "" {
|
||
|
|
r.events[batchID] = append(r.events[batchID], opts.EventType)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) TouchBatchImageJobSubmitting(_ context.Context, batchID string) error {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if job.Status == BatchImageJobStatusCreated || job.Status == BatchImageJobStatusUploading {
|
||
|
|
job.UpdatedAt = time.Now()
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) FailStaleUnsubmittedBatchImageJob(_ context.Context, batchID string, cutoff time.Time, code, message string) (bool, error) {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return false, ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if job.Status != BatchImageJobStatusCreated && job.Status != BatchImageJobStatusUploading {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
if batchImageDerefString(job.ProviderJobName) != "" || job.UpdatedAt.After(cutoff) {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
job.Status = BatchImageJobStatusFailed
|
||
|
|
job.LastErrorCode = batchImageStringPtr(code)
|
||
|
|
job.LastErrorMessage = batchImageStringPtr(message)
|
||
|
|
job.UpdatedAt = time.Now()
|
||
|
|
r.transitions[batchID] = append(r.transitions[batchID], BatchImageJobStatusFailed)
|
||
|
|
r.events[batchID] = append(r.events[batchID], "billing_hold_recovery_failed_unsubmitted")
|
||
|
|
return true, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) UpdateBatchImageJobProviderOutputRef(_ context.Context, batchID, providerOutputRef string) error {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
job.ProviderOutputRef = &providerOutputRef
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) UpdateBatchImageJobProviderSubmit(_ context.Context, params UpdateBatchImageJobProviderSubmitParams) error {
|
||
|
|
job, ok := r.jobs[params.BatchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if !CanTransitionBatchImageJob(job.Status, BatchImageJobStatusSubmitted) {
|
||
|
|
return ErrBatchImageInvalidTransition
|
||
|
|
}
|
||
|
|
job.Status = BatchImageJobStatusSubmitted
|
||
|
|
job.ProviderJobName = batchImageOptionalStringPtr(params.ProviderJobName)
|
||
|
|
job.ProviderInputRef = batchImageOptionalStringPtr(params.ProviderInputRef)
|
||
|
|
job.ProviderOutputRef = batchImageOptionalStringPtr(params.ProviderOutputRef)
|
||
|
|
job.GCSInputURI = batchImageOptionalStringPtr(params.GCSInputURI)
|
||
|
|
job.GCSOutputURI = batchImageOptionalStringPtr(params.GCSOutputURI)
|
||
|
|
now := time.Now()
|
||
|
|
job.SubmittedAt = &now
|
||
|
|
r.transitions[params.BatchID] = append(r.transitions[params.BatchID], BatchImageJobStatusSubmitted)
|
||
|
|
r.events[params.BatchID] = append(r.events[params.BatchID], "provider_submitted")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) RecordBatchImageJobSubmitFailure(_ context.Context, batchID, code, message string, markFailed bool) error {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if markFailed {
|
||
|
|
job.Status = BatchImageJobStatusFailed
|
||
|
|
}
|
||
|
|
job.LastErrorCode = batchImageOptionalStringPtr(code)
|
||
|
|
job.LastErrorMessage = batchImageOptionalStringPtr(message)
|
||
|
|
eventType := "submit_failed"
|
||
|
|
if !markFailed {
|
||
|
|
eventType = "queue_failed"
|
||
|
|
}
|
||
|
|
r.events[batchID] = append(r.events[batchID], eventType)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) MarkBatchImageJobSettled(_ context.Context, params MarkBatchImageJobSettledParams) error {
|
||
|
|
job, ok := r.jobs[params.BatchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if job.Status != BatchImageJobStatusSettling {
|
||
|
|
if job.Status == BatchImageJobStatusCompleted {
|
||
|
|
return ErrBatchImageAlreadySettled
|
||
|
|
}
|
||
|
|
return ErrBatchImageSettlementInvalidStatus
|
||
|
|
}
|
||
|
|
if batchImageDerefString(job.ManifestHash) != "" && batchImageDerefString(job.ManifestHash) != params.ManifestHash {
|
||
|
|
return ErrBatchImageSettlementManifestConflict
|
||
|
|
}
|
||
|
|
now := time.Now()
|
||
|
|
job.Status = BatchImageJobStatusCompleted
|
||
|
|
job.ActualCost = ¶ms.ActualCost
|
||
|
|
job.ManifestHash = ¶ms.ManifestHash
|
||
|
|
job.SettledAt = &now
|
||
|
|
if job.OutputExpiresAt == nil && params.OutputExpiresAt != nil {
|
||
|
|
job.OutputExpiresAt = params.OutputExpiresAt
|
||
|
|
}
|
||
|
|
r.transitions[params.BatchID] = append(r.transitions[params.BatchID], BatchImageJobStatusCompleted)
|
||
|
|
r.events[params.BatchID] = append(r.events[params.BatchID], "settlement_completed")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) SetBatchImageJobSettlementFailed(_ context.Context, batchID, code, message string) (int, error) {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return 0, ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
job.LastErrorCode = batchImageStringPtr(code)
|
||
|
|
job.LastErrorMessage = batchImageOptionalStringPtr(message)
|
||
|
|
job.RetryCount++
|
||
|
|
r.events[batchID] = append(r.events[batchID], "settlement_failed")
|
||
|
|
return job.RetryCount, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) CreateBatchImageItem(_ context.Context, params CreateBatchImageItemParams) (*BatchImageItem, error) {
|
||
|
|
r.items[params.JobID] = append(r.items[params.JobID], params)
|
||
|
|
return &BatchImageItem{JobID: params.JobID, CustomID: params.CustomID, Status: params.Status}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) BulkCreateBatchImageItems(ctx context.Context, params []CreateBatchImageItemParams) error {
|
||
|
|
for _, param := range params {
|
||
|
|
if _, err := r.CreateBatchImageItem(ctx, param); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) ReplaceBatchImageItemsForJob(_ context.Context, batchID string, items []CreateBatchImageItemParams, counts BatchImageCounts) error {
|
||
|
|
// 与真实实现一致:仅 indexing 状态允许重建 item 表(未注册的 job 保持宽松,
|
||
|
|
// 供直接构造 job 的单测使用)。
|
||
|
|
if job, ok := r.jobs[batchID]; ok && job.Status != BatchImageJobStatusIndexing {
|
||
|
|
return ErrBatchImageIndexStateConflict
|
||
|
|
}
|
||
|
|
r.replaceCalls++
|
||
|
|
copied := append([]CreateBatchImageItemParams(nil), items...)
|
||
|
|
for idx := range copied {
|
||
|
|
copied[idx].JobID = batchID
|
||
|
|
}
|
||
|
|
r.items[batchID] = copied
|
||
|
|
r.counts[batchID] = counts
|
||
|
|
if job, ok := r.jobs[batchID]; ok {
|
||
|
|
job.SuccessCount = counts.SuccessCount
|
||
|
|
job.FailCount = counts.FailCount
|
||
|
|
job.ItemCount = len(copied)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) ListBatchImageItems(_ context.Context, batchID string, filter BatchImageItemFilter) ([]*BatchImageItem, error) {
|
||
|
|
limit := filter.Limit
|
||
|
|
if limit <= 0 || limit > 500 {
|
||
|
|
limit = 100
|
||
|
|
}
|
||
|
|
offset := filter.Offset
|
||
|
|
if offset < 0 {
|
||
|
|
offset = 0
|
||
|
|
}
|
||
|
|
var result []*BatchImageItem
|
||
|
|
for _, item := range r.items[batchID] {
|
||
|
|
if filter.Status != "" && item.Status != filter.Status {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if offset > 0 {
|
||
|
|
offset--
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
result = append(result, &BatchImageItem{
|
||
|
|
JobID: item.JobID,
|
||
|
|
CustomID: item.CustomID,
|
||
|
|
Status: item.Status,
|
||
|
|
RequestHash: item.RequestHash,
|
||
|
|
PromptPreview: item.PromptPreview,
|
||
|
|
ProviderSourceObject: item.ProviderSourceObject,
|
||
|
|
SourceLineNumber: item.SourceLineNumber,
|
||
|
|
SourceByteOffset: item.SourceByteOffset,
|
||
|
|
SourceByteLength: item.SourceByteLength,
|
||
|
|
MimeType: item.MimeType,
|
||
|
|
FileExtension: item.FileExtension,
|
||
|
|
ImageCount: item.ImageCount,
|
||
|
|
ErrorCode: item.ErrorCode,
|
||
|
|
ErrorMessage: item.ErrorMessage,
|
||
|
|
BilledAmount: item.BilledAmount,
|
||
|
|
IndexedAt: item.IndexedAt,
|
||
|
|
})
|
||
|
|
if len(result) >= limit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) ListBatchImageItemsForOwner(ctx context.Context, userID, apiKeyID int64, batchID string, filter BatchImageItemFilter) ([]*BatchImageItem, error) {
|
||
|
|
if _, err := r.GetBatchImageJobByBatchIDForOwner(ctx, userID, apiKeyID, batchID); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return r.ListBatchImageItems(ctx, batchID, filter)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) GetBatchImageJobForDownload(ctx context.Context, userID, apiKeyID int64, batchID string) (*BatchImageJob, error) {
|
||
|
|
return r.GetBatchImageJobByBatchIDForOwner(ctx, userID, apiKeyID, batchID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) GetBatchImageItemForDownload(_ context.Context, batchID, customID string) (*BatchImageItem, error) {
|
||
|
|
for _, item := range r.items[batchID] {
|
||
|
|
if item.CustomID != customID {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
return &BatchImageItem{
|
||
|
|
JobID: item.JobID,
|
||
|
|
CustomID: item.CustomID,
|
||
|
|
Status: item.Status,
|
||
|
|
RequestHash: item.RequestHash,
|
||
|
|
PromptPreview: item.PromptPreview,
|
||
|
|
ProviderSourceObject: item.ProviderSourceObject,
|
||
|
|
SourceLineNumber: item.SourceLineNumber,
|
||
|
|
SourceByteOffset: item.SourceByteOffset,
|
||
|
|
SourceByteLength: item.SourceByteLength,
|
||
|
|
MimeType: item.MimeType,
|
||
|
|
FileExtension: item.FileExtension,
|
||
|
|
ImageCount: item.ImageCount,
|
||
|
|
ErrorCode: item.ErrorCode,
|
||
|
|
ErrorMessage: item.ErrorMessage,
|
||
|
|
BilledAmount: item.BilledAmount,
|
||
|
|
IndexedAt: item.IndexedAt,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
return nil, ErrBatchImageItemNotFound
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) ListBatchImageItemsForDownload(ctx context.Context, batchID string, status string, limit int) ([]*BatchImageItem, error) {
|
||
|
|
return r.ListBatchImageItems(ctx, batchID, BatchImageItemFilter{Status: status, Limit: limit})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) ListBatchImageJobsDueForInputCleanup(_ context.Context, cutoff time.Time, limit int) ([]*BatchImageJob, error) {
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = 100
|
||
|
|
}
|
||
|
|
var jobs []*BatchImageJob
|
||
|
|
for _, job := range r.jobs {
|
||
|
|
if job.InputDeletedAt != nil || batchImageDerefString(job.ProviderInputRef) == "" || !IsTerminalBatchImageJobStatus(job.Status) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
at := job.FinishedAt
|
||
|
|
if at == nil {
|
||
|
|
at = job.SettledAt
|
||
|
|
}
|
||
|
|
if at == nil {
|
||
|
|
at = &job.UpdatedAt
|
||
|
|
}
|
||
|
|
if at != nil && at.After(cutoff) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
jobs = append(jobs, job)
|
||
|
|
if len(jobs) >= limit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return jobs, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) ListBatchImageJobsDueForOutputCleanup(_ context.Context, now time.Time, limit int) ([]*BatchImageJob, error) {
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = 100
|
||
|
|
}
|
||
|
|
var jobs []*BatchImageJob
|
||
|
|
for _, job := range r.jobs {
|
||
|
|
if job.OutputDeletedAt != nil || batchImageDerefString(job.ProviderOutputRef) == "" || job.Status != BatchImageJobStatusCompleted || job.OutputExpiresAt == nil || job.OutputExpiresAt.After(now) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
jobs = append(jobs, job)
|
||
|
|
if len(jobs) >= limit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return jobs, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) ListStaleUnsubmittedBatchImageJobs(_ context.Context, cutoff time.Time, limit int) ([]*BatchImageJob, error) {
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = 100
|
||
|
|
}
|
||
|
|
jobs := make([]*BatchImageJob, 0, limit)
|
||
|
|
for _, job := range r.jobs {
|
||
|
|
if len(jobs) >= limit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if job.Status != BatchImageJobStatusCreated && job.Status != BatchImageJobStatusUploading {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if batchImageDerefString(job.ProviderJobName) != "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
holdAmount := job.EstimatedCost
|
||
|
|
if job.HoldAmount != nil {
|
||
|
|
holdAmount = *job.HoldAmount
|
||
|
|
}
|
||
|
|
if holdAmount <= 0 || job.UpdatedAt.After(cutoff) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
jobs = append(jobs, job)
|
||
|
|
}
|
||
|
|
return jobs, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) MarkBatchImageInputDeleted(_ context.Context, batchID string, deletedAt time.Time) error {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if job.InputDeletedAt == nil {
|
||
|
|
job.InputDeletedAt = &deletedAt
|
||
|
|
}
|
||
|
|
r.events[batchID] = append(r.events[batchID], "input_cleanup_completed")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) MarkBatchImageOutputDeleted(_ context.Context, batchID string, deletedAt time.Time) error {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if job.OutputDeletedAt == nil {
|
||
|
|
job.OutputDeletedAt = &deletedAt
|
||
|
|
}
|
||
|
|
if job.Status == BatchImageJobStatusCompleted {
|
||
|
|
job.Status = BatchImageJobStatusOutputDeleted
|
||
|
|
}
|
||
|
|
r.events[batchID] = append(r.events[batchID], "output_cleanup_completed")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) MarkBatchImageDownloaded(_ context.Context, batchID string, downloadedAt time.Time) error {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if job.DownloadedAt == nil {
|
||
|
|
job.DownloadedAt = &downloadedAt
|
||
|
|
}
|
||
|
|
r.events[batchID] = append(r.events[batchID], "download_completed")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) MarkBatchImageJobUserDeleted(_ context.Context, userID, apiKeyID int64, batchID string, deletedAt time.Time) error {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok || job.UserID != userID || job.APIKeyID == nil || *job.APIKeyID != apiKeyID {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if !isBatchImageProcessorDoneStatus(job.Status) {
|
||
|
|
return ErrBatchImageRecordDeleteNotReady
|
||
|
|
}
|
||
|
|
if job.UserDeletedAt == nil {
|
||
|
|
job.UserDeletedAt = &deletedAt
|
||
|
|
}
|
||
|
|
r.events[batchID] = append(r.events[batchID], "user_record_deleted")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) SetBatchImageOutputExpiresAt(_ context.Context, batchID string, expiresAt time.Time) error {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
if job.OutputExpiresAt == nil {
|
||
|
|
job.OutputExpiresAt = &expiresAt
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) RecordBatchImageCleanupFailure(_ context.Context, batchID, code, message string) error {
|
||
|
|
job, ok := r.jobs[batchID]
|
||
|
|
if !ok {
|
||
|
|
return ErrBatchImageJobNotFound
|
||
|
|
}
|
||
|
|
job.LastErrorCode = batchImageStringPtr(code)
|
||
|
|
job.LastErrorMessage = batchImageOptionalStringPtr(message)
|
||
|
|
r.events[batchID] = append(r.events[batchID], "output_cleanup_failed")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *fakeBatchImageRepository) AppendBatchImageEvent(_ context.Context, batchID, eventType string, _ any) error {
|
||
|
|
r.events[batchID] = append(r.events[batchID], eventType)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
var _ BatchImageRepository = (*fakeBatchImageRepository)(nil)
|
||
|
|
var _ BatchImageProvider = (*fakeProcessorProvider)(nil)
|
||
|
|
var _ BatchImageAccountResolver = (*fakeBatchImageAccountResolver)(nil)
|
||
|
|
var _ = infraerrors.Reason
|