| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988 |
- package repository
- import (
- "context"
- "database/sql"
- "encoding/json"
- "strconv"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/service"
- )
- type batchImageSQLExecutor interface {
- ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
- QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
- QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
- }
- type batchImageRepository struct {
- db *sql.DB
- sql batchImageSQLExecutor
- }
- func NewBatchImageRepository(db *sql.DB) service.BatchImageRepository {
- return &batchImageRepository{db: db, sql: db}
- }
- func (r *batchImageRepository) CreateBatchImageJob(ctx context.Context, params service.CreateBatchImageJobParams) (*service.BatchImageJob, error) {
- if !service.IsSupportedBatchImageProvider(params.Provider) {
- return nil, service.ErrBatchImageInvalidProvider
- }
- if params.BatchID == "" {
- batchID, err := service.NewBatchImageID()
- if err != nil {
- return nil, err
- }
- params.BatchID = batchID
- }
- if params.Status == "" {
- params.Status = service.BatchImageJobStatusCreated
- }
- if params.Currency == "" {
- params.Currency = "USD"
- }
- job, err := createBatchImageJobWithSQL(ctx, r.sql, params)
- if err != nil {
- return nil, translatePersistenceError(err, nil, service.ErrBatchImageJobExists)
- }
- return job, nil
- }
- func (r *batchImageRepository) GetBatchImageJobByBatchID(ctx context.Context, batchID string) (*service.BatchImageJob, error) {
- job, err := scanBatchImageJob(r.sql.QueryRowContext(ctx, batchImageJobSelectSQL+" WHERE batch_id = $1", batchID))
- if err != nil {
- return nil, translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
- }
- return job, nil
- }
- func (r *batchImageRepository) GetBatchImageJobByIdempotencyKey(ctx context.Context, userID, apiKeyID int64, key string) (*service.BatchImageJob, error) {
- job, err := scanBatchImageJob(r.sql.QueryRowContext(ctx, batchImageJobSelectSQL+`
- WHERE user_id = $1 AND api_key_id = $2 AND idempotency_key = $3
- ORDER BY id DESC LIMIT 1`, userID, apiKeyID, key))
- if err != nil {
- return nil, translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
- }
- return job, nil
- }
- func (r *batchImageRepository) GetBatchImageJobByBatchIDForOwner(ctx context.Context, userID, apiKeyID int64, batchID string) (*service.BatchImageJob, error) {
- job, err := scanBatchImageJob(r.sql.QueryRowContext(ctx, batchImageJobSelectSQL+`
- WHERE batch_id = $1 AND user_id = $2 AND api_key_id = $3 AND user_deleted_at IS NULL`, batchID, userID, apiKeyID))
- if err != nil {
- return nil, translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
- }
- return job, nil
- }
- func (r *batchImageRepository) ListBatchImageJobsForOwner(ctx context.Context, userID, apiKeyID int64, filter service.BatchImageJobFilter) ([]*service.BatchImageJob, error) {
- limit := filter.Limit
- if limit <= 0 || limit > 100 {
- limit = 20
- }
- if filter.Offset < 0 {
- filter.Offset = 0
- }
- query := batchImageJobSelectSQL + " WHERE user_id = $1 AND api_key_id = $2"
- args := []any{userID, apiKeyID}
- if filter.ExcludeDeleted {
- query += " AND user_deleted_at IS NULL"
- }
- if filter.Status != "" {
- query += " AND status = $" + strconv.Itoa(len(args)+1)
- args = append(args, filter.Status)
- }
- if filter.TaskNameLike != "" {
- query += " AND task_name ILIKE $" + strconv.Itoa(len(args)+1)
- args = append(args, "%"+filter.TaskNameLike+"%")
- }
- if filter.Downloaded != nil {
- if *filter.Downloaded {
- query += " AND downloaded_at IS NOT NULL"
- } else {
- query += " AND downloaded_at IS NULL"
- }
- }
- if filter.CreatedAfter != nil {
- query += " AND created_at >= $" + strconv.Itoa(len(args)+1)
- args = append(args, *filter.CreatedAfter)
- }
- if filter.CreatedBefore != nil {
- query += " AND created_at < $" + strconv.Itoa(len(args)+1)
- args = append(args, *filter.CreatedBefore)
- }
- query += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(args)+1) + " OFFSET $" + strconv.Itoa(len(args)+2)
- args = append(args, limit, filter.Offset)
- rows, err := r.sql.QueryContext(ctx, query, args...)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- return scanBatchImageJobs(rows)
- }
- func (r *batchImageRepository) GetBatchImageJobByID(ctx context.Context, id int64) (*service.BatchImageJob, error) {
- job, err := scanBatchImageJob(r.sql.QueryRowContext(ctx, batchImageJobSelectSQL+" WHERE id = $1", id))
- if err != nil {
- return nil, translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
- }
- return job, nil
- }
- func (r *batchImageRepository) TransitionBatchImageJobStatus(ctx context.Context, batchID, toStatus string, opts service.BatchImageTransitionOptions) error {
- if r.db == nil {
- return r.transitionBatchImageJobStatusWithSQL(ctx, r.sql, batchID, toStatus, opts)
- }
- tx, err := r.db.BeginTx(ctx, nil)
- if err != nil {
- return err
- }
- defer func() {
- _ = tx.Rollback()
- }()
- if err := r.transitionBatchImageJobStatusWithSQL(ctx, tx, batchID, toStatus, opts); err != nil {
- return err
- }
- return tx.Commit()
- }
- func (r *batchImageRepository) TouchBatchImageJobSubmitting(ctx context.Context, batchID string) error {
- _, err := r.sql.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET updated_at = $2
- WHERE batch_id = $1
- AND status IN ('created', 'uploading')`, batchID, time.Now())
- return err
- }
- func (r *batchImageRepository) FailStaleUnsubmittedBatchImageJob(ctx context.Context, batchID string, cutoff time.Time, code, message string) (bool, error) {
- now := time.Now()
- res, err := r.sql.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET status = 'failed',
- last_error_code = $2,
- last_error_message = $3,
- finished_at = CASE WHEN finished_at IS NULL THEN $4 ELSE finished_at END,
- updated_at = $4,
- version = version + 1
- WHERE batch_id = $1
- AND status IN ('created', 'uploading')
- AND provider_job_name IS NULL
- AND updated_at <= $5`, batchID, code, message, now, cutoff)
- if err != nil {
- return false, err
- }
- affected, err := res.RowsAffected()
- if err != nil {
- return false, err
- }
- if affected == 0 {
- return false, nil
- }
- return true, appendBatchImageEventWithSQL(ctx, r.sql, batchID, "billing_hold_recovery_failed_unsubmitted", map[string]any{
- "batch_id": batchID,
- "error_code": code,
- })
- }
- func (r *batchImageRepository) UpdateBatchImageJobProviderOutputRef(ctx context.Context, batchID, providerOutputRef string) error {
- res, err := r.sql.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET provider_output_ref = $2, updated_at = $3
- WHERE batch_id = $1`, batchID, providerOutputRef, time.Now())
- if err != nil {
- return translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
- }
- if affected, err := res.RowsAffected(); err == nil && affected == 0 {
- return service.ErrBatchImageJobNotFound
- }
- return nil
- }
- func (r *batchImageRepository) UpdateBatchImageJobProviderSubmit(ctx context.Context, params service.UpdateBatchImageJobProviderSubmitParams) error {
- if r.db == nil {
- return r.updateBatchImageJobProviderSubmitWithSQL(ctx, r.sql, params)
- }
- tx, err := r.db.BeginTx(ctx, nil)
- if err != nil {
- return err
- }
- defer func() {
- _ = tx.Rollback()
- }()
- if err := r.updateBatchImageJobProviderSubmitWithSQL(ctx, tx, params); err != nil {
- return err
- }
- return tx.Commit()
- }
- func (r *batchImageRepository) updateBatchImageJobProviderSubmitWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, params service.UpdateBatchImageJobProviderSubmitParams) error {
- var current string
- if err := sqlq.QueryRowContext(ctx, `SELECT status FROM batch_image_jobs WHERE batch_id = $1 FOR UPDATE`, params.BatchID).Scan(¤t); err != nil {
- return translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
- }
- if !service.CanTransitionBatchImageJob(current, service.BatchImageJobStatusSubmitted) {
- return service.ErrBatchImageInvalidTransition
- }
- now := time.Now()
- if _, err := sqlq.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET status = 'submitted',
- provider_job_name = $2,
- provider_input_ref = NULLIF($3, ''),
- provider_output_ref = NULLIF($4, ''),
- gcs_input_uri = NULLIF($5, ''),
- gcs_output_uri = NULLIF($6, ''),
- submitted_at = CASE WHEN submitted_at IS NULL THEN $7 ELSE submitted_at END,
- updated_at = $7,
- version = version + 1
- WHERE batch_id = $1`, params.BatchID, params.ProviderJobName, params.ProviderInputRef, params.ProviderOutputRef, params.GCSInputURI, params.GCSOutputURI, now); err != nil {
- return err
- }
- return appendBatchImageEventWithSQL(ctx, sqlq, params.BatchID, "provider_submitted", params.EventPayload)
- }
- func (r *batchImageRepository) RecordBatchImageJobSubmitFailure(ctx context.Context, batchID, code, message string, markFailed bool) error {
- now := time.Now()
- statusSQL := "status"
- if markFailed {
- statusSQL = "'failed'"
- }
- _, err := r.sql.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET status = `+statusSQL+`,
- last_error_code = $2,
- last_error_message = $3,
- finished_at = CASE WHEN `+statusSQL+` = 'failed' AND finished_at IS NULL THEN $4 ELSE finished_at END,
- updated_at = $4,
- version = version + 1
- WHERE batch_id = $1`, batchID, code, message, now)
- if err != nil {
- return err
- }
- eventType := "submit_failed"
- if !markFailed {
- eventType = "queue_failed"
- }
- return appendBatchImageEventWithSQL(ctx, r.sql, batchID, eventType, map[string]any{"error_code": code})
- }
- func (r *batchImageRepository) MarkBatchImageJobSettled(ctx context.Context, params service.MarkBatchImageJobSettledParams) error {
- if r.db == nil {
- return r.markBatchImageJobSettledWithSQL(ctx, r.sql, params)
- }
- tx, err := r.db.BeginTx(ctx, nil)
- if err != nil {
- return err
- }
- defer func() {
- _ = tx.Rollback()
- }()
- if err := r.markBatchImageJobSettledWithSQL(ctx, tx, params); err != nil {
- return err
- }
- return tx.Commit()
- }
- func (r *batchImageRepository) markBatchImageJobSettledWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, params service.MarkBatchImageJobSettledParams) error {
- now := time.Now()
- if params.Now != nil {
- now = *params.Now
- }
- outputExpiresAt := params.OutputExpiresAt
- res, err := sqlq.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET status = 'completed',
- actual_cost = $2,
- manifest_hash = $3,
- settled_at = CASE WHEN settled_at IS NULL THEN $4 ELSE settled_at END,
- finished_at = CASE WHEN finished_at IS NULL THEN $4 ELSE finished_at END,
- output_expires_at = CASE WHEN output_expires_at IS NULL THEN $5 ELSE output_expires_at END,
- updated_at = $4,
- version = version + 1
- WHERE batch_id = $1
- AND status = 'settling'
- AND (manifest_hash IS NULL OR manifest_hash = '' OR manifest_hash = $3)`, params.BatchID, params.ActualCost, params.ManifestHash, now, outputExpiresAt)
- if err != nil {
- return err
- }
- affected, err := res.RowsAffected()
- if err != nil {
- return err
- }
- if affected == 0 {
- job, getErr := scanBatchImageJob(sqlq.QueryRowContext(ctx, batchImageJobSelectSQL+" WHERE batch_id = $1", params.BatchID))
- if getErr != nil {
- return translatePersistenceError(getErr, service.ErrBatchImageJobNotFound, nil)
- }
- if job.Status != service.BatchImageJobStatusSettling {
- if job.Status == service.BatchImageJobStatusCompleted {
- return service.ErrBatchImageAlreadySettled
- }
- return service.ErrBatchImageSettlementInvalidStatus
- }
- return service.ErrBatchImageSettlementManifestConflict
- }
- return appendBatchImageEventWithSQL(ctx, sqlq, params.BatchID, "settlement_completed", params.EventPayload)
- }
- func (r *batchImageRepository) SetBatchImageJobSettlementFailed(ctx context.Context, batchID, code, message string) (int, error) {
- var retryCount int
- err := r.sql.QueryRowContext(ctx, `
- UPDATE batch_image_jobs
- SET last_error_code = $2,
- last_error_message = $3,
- retry_count = retry_count + 1,
- updated_at = $4
- WHERE batch_id = $1
- RETURNING retry_count`, batchID, code, message, time.Now()).Scan(&retryCount)
- if err != nil {
- return 0, translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
- }
- return retryCount, appendBatchImageEventWithSQL(ctx, r.sql, batchID, "settlement_failed", map[string]any{
- "error_code": code,
- })
- }
- func (r *batchImageRepository) transitionBatchImageJobStatusWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, batchID, toStatus string, opts service.BatchImageTransitionOptions) error {
- var current string
- if err := sqlq.QueryRowContext(ctx, `SELECT status FROM batch_image_jobs WHERE batch_id = $1 FOR UPDATE`, batchID).Scan(¤t); err != nil {
- return translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
- }
- if !service.CanTransitionBatchImageJob(current, toStatus) {
- return service.ErrBatchImageInvalidTransition
- }
- now := time.Now()
- if opts.Now != nil {
- now = *opts.Now
- }
- if _, err := sqlq.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET
- status = $2::varchar,
- version = version + 1,
- updated_at = $3,
- last_error_code = CASE WHEN $2::varchar = 'failed' THEN $4 ELSE last_error_code END,
- last_error_message = CASE WHEN $2::varchar = 'failed' THEN $5 ELSE last_error_message END,
- submitted_at = CASE WHEN $2::varchar = 'submitted' AND submitted_at IS NULL THEN $3 ELSE submitted_at END,
- started_at = CASE WHEN $2::varchar = 'running' AND started_at IS NULL THEN $3 ELSE started_at END,
- finished_at = CASE WHEN $2::varchar IN ('completed', 'failed', 'cancelled') AND finished_at IS NULL THEN $3 ELSE finished_at END,
- settled_at = CASE WHEN $2::varchar = 'completed' AND settled_at IS NULL THEN $3 ELSE settled_at END,
- output_deleted_at = CASE WHEN $2::varchar = 'output_deleted' AND output_deleted_at IS NULL THEN $3 ELSE output_deleted_at END
- WHERE batch_id = $1`, batchID, toStatus, now, opts.ErrorCode, opts.ErrorMessage); err != nil {
- return err
- }
- if opts.EventType != "" {
- return appendBatchImageEventWithSQL(ctx, sqlq, batchID, opts.EventType, opts.EventPayload)
- }
- return nil
- }
- func (r *batchImageRepository) CreateBatchImageItem(ctx context.Context, params service.CreateBatchImageItemParams) (*service.BatchImageItem, error) {
- item, err := createBatchImageItemWithSQL(ctx, r.sql, params)
- if err != nil {
- return nil, translatePersistenceError(err, nil, service.ErrBatchImageItemExists)
- }
- return item, nil
- }
- func (r *batchImageRepository) BulkCreateBatchImageItems(ctx context.Context, params []service.CreateBatchImageItemParams) error {
- if len(params) == 0 {
- return nil
- }
- if r.db == nil {
- for _, param := range params {
- if _, err := createBatchImageItemWithSQL(ctx, r.sql, param); err != nil {
- return translatePersistenceError(err, nil, service.ErrBatchImageItemExists)
- }
- }
- return nil
- }
- tx, err := r.db.BeginTx(ctx, nil)
- if err != nil {
- return err
- }
- defer func() {
- _ = tx.Rollback()
- }()
- for _, param := range params {
- if _, err := createBatchImageItemWithSQL(ctx, tx, param); err != nil {
- return translatePersistenceError(err, nil, service.ErrBatchImageItemExists)
- }
- }
- return tx.Commit()
- }
- func (r *batchImageRepository) ReplaceBatchImageItemsForJob(ctx context.Context, batchID string, items []service.CreateBatchImageItemParams, counts service.BatchImageCounts) error {
- if r.db == nil {
- return r.replaceBatchImageItemsForJobWithSQL(ctx, r.sql, batchID, items, counts)
- }
- tx, err := r.db.BeginTx(ctx, nil)
- if err != nil {
- return err
- }
- defer func() {
- _ = tx.Rollback()
- }()
- if err := r.replaceBatchImageItemsForJobWithSQL(ctx, tx, batchID, items, counts); err != nil {
- return err
- }
- return tx.Commit()
- }
- func (r *batchImageRepository) replaceBatchImageItemsForJobWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, batchID string, items []service.CreateBatchImageItemParams, counts service.BatchImageCounts) error {
- var id int64
- var status string
- if err := sqlq.QueryRowContext(ctx, `SELECT id, status FROM batch_image_jobs WHERE batch_id = $1 FOR UPDATE`, batchID).Scan(&id, &status); err != nil {
- return translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
- }
- // 仅允许 indexing 状态重建 item 表:防止锁过期后掉队的 worker
- // 重写已完成/已结算 job 的条目,造成账目与结果漂移。
- if status != service.BatchImageJobStatusIndexing {
- return service.ErrBatchImageIndexStateConflict
- }
- promptPreviews, err := r.batchImageItemPromptPreviews(ctx, sqlq, batchID)
- if err != nil {
- return err
- }
- if _, err := sqlq.ExecContext(ctx, `DELETE FROM batch_image_items WHERE job_id = $1`, batchID); err != nil {
- return err
- }
- for _, item := range items {
- item.JobID = batchID
- if item.PromptPreview == nil {
- if preview := promptPreviews[item.CustomID]; preview != "" {
- item.PromptPreview = &preview
- }
- }
- if _, err := createBatchImageItemWithSQL(ctx, sqlq, item); err != nil {
- return translatePersistenceError(err, nil, service.ErrBatchImageItemExists)
- }
- }
- _, err = sqlq.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET success_count = $2,
- fail_count = $3,
- updated_at = $4
- WHERE batch_id = $1`, batchID, counts.SuccessCount, counts.FailCount, time.Now())
- return err
- }
- func (r *batchImageRepository) batchImageItemPromptPreviews(ctx context.Context, sqlq batchImageSQLExecutor, batchID string) (map[string]string, error) {
- rows, err := sqlq.QueryContext(ctx, `SELECT custom_id, prompt_preview FROM batch_image_items WHERE job_id = $1 AND prompt_preview IS NOT NULL`, batchID)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- out := make(map[string]string)
- for rows.Next() {
- var customID string
- var preview sql.NullString
- if err := rows.Scan(&customID, &preview); err != nil {
- return nil, err
- }
- if preview.Valid && preview.String != "" {
- out[customID] = preview.String
- }
- }
- return out, rows.Err()
- }
- func (r *batchImageRepository) ListBatchImageItems(ctx context.Context, batchID string, filter service.BatchImageItemFilter) ([]*service.BatchImageItem, error) {
- limit := filter.Limit
- if limit <= 0 || limit > 500 {
- limit = 100
- }
- if filter.Offset < 0 {
- filter.Offset = 0
- }
- query := batchImageItemSelectSQL + " WHERE job_id = $1"
- args := []any{batchID}
- if filter.Status != "" {
- query += " AND status = $2"
- args = append(args, filter.Status)
- }
- query += " ORDER BY id ASC LIMIT $" + strconv.Itoa(len(args)+1) + " OFFSET $" + strconv.Itoa(len(args)+2)
- args = append(args, limit, filter.Offset)
- rows, err := r.sql.QueryContext(ctx, query, args...)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- var items []*service.BatchImageItem
- for rows.Next() {
- item, err := scanBatchImageItem(rows)
- if err != nil {
- return nil, err
- }
- items = append(items, item)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
- }
- func (r *batchImageRepository) ListBatchImageItemsForOwner(ctx context.Context, userID, apiKeyID int64, batchID string, filter service.BatchImageItemFilter) ([]*service.BatchImageItem, error) {
- if _, err := r.GetBatchImageJobByBatchIDForOwner(ctx, userID, apiKeyID, batchID); err != nil {
- return nil, err
- }
- return r.ListBatchImageItems(ctx, batchID, filter)
- }
- func (r *batchImageRepository) GetBatchImageJobForDownload(ctx context.Context, userID, apiKeyID int64, batchID string) (*service.BatchImageJob, error) {
- return r.GetBatchImageJobByBatchIDForOwner(ctx, userID, apiKeyID, batchID)
- }
- func (r *batchImageRepository) GetBatchImageItemForDownload(ctx context.Context, batchID, customID string) (*service.BatchImageItem, error) {
- item, err := scanBatchImageItem(r.sql.QueryRowContext(ctx, batchImageItemSelectSQL+`
- WHERE job_id = $1 AND custom_id = $2`, batchID, customID))
- if err != nil {
- return nil, translatePersistenceError(err, service.ErrBatchImageItemNotFound, nil)
- }
- return item, nil
- }
- func (r *batchImageRepository) ListBatchImageItemsForDownload(ctx context.Context, batchID string, status string, limit int) ([]*service.BatchImageItem, error) {
- return r.ListBatchImageItems(ctx, batchID, service.BatchImageItemFilter{Status: status, Limit: limit})
- }
- func (r *batchImageRepository) ListBatchImageJobsDueForInputCleanup(ctx context.Context, cutoff time.Time, limit int) ([]*service.BatchImageJob, error) {
- if limit <= 0 || limit > 1000 {
- limit = 100
- }
- rows, err := r.sql.QueryContext(ctx, batchImageJobSelectSQL+`
- WHERE input_deleted_at IS NULL
- AND provider_input_ref IS NOT NULL
- AND status IN ('completed', 'failed', 'cancelled', 'output_deleted')
- AND COALESCE(finished_at, settled_at, updated_at, created_at) <= $1
- ORDER BY id ASC
- LIMIT $2`, cutoff, limit)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- return scanBatchImageJobs(rows)
- }
- func (r *batchImageRepository) ListBatchImageJobsDueForOutputCleanup(ctx context.Context, now time.Time, limit int) ([]*service.BatchImageJob, error) {
- if limit <= 0 || limit > 1000 {
- limit = 100
- }
- rows, err := r.sql.QueryContext(ctx, batchImageJobSelectSQL+`
- WHERE output_deleted_at IS NULL
- AND provider_output_ref IS NOT NULL
- AND status = 'completed'
- AND output_expires_at IS NOT NULL
- AND output_expires_at <= $1
- ORDER BY output_expires_at ASC, id ASC
- LIMIT $2`, now, limit)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- return scanBatchImageJobs(rows)
- }
- func (r *batchImageRepository) ListStaleUnsubmittedBatchImageJobs(ctx context.Context, cutoff time.Time, limit int) ([]*service.BatchImageJob, error) {
- if limit <= 0 || limit > 1000 {
- limit = 100
- }
- rows, err := r.sql.QueryContext(ctx, batchImageJobSelectSQL+`
- WHERE status IN ('created', 'uploading')
- AND provider_job_name IS NULL
- AND COALESCE(hold_amount, estimated_cost, 0) > 0
- AND updated_at <= $1
- ORDER BY updated_at ASC, id ASC
- LIMIT $2`, cutoff, limit)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- return scanBatchImageJobs(rows)
- }
- func (r *batchImageRepository) MarkBatchImageInputDeleted(ctx context.Context, batchID string, deletedAt time.Time) error {
- res, err := r.sql.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET input_deleted_at = CASE WHEN input_deleted_at IS NULL THEN $2 ELSE input_deleted_at END,
- updated_at = $2,
- version = version + 1
- WHERE batch_id = $1`, batchID, deletedAt)
- if err != nil {
- return err
- }
- if affected, err := res.RowsAffected(); err == nil && affected == 0 {
- return service.ErrBatchImageJobNotFound
- }
- return appendBatchImageEventWithSQL(ctx, r.sql, batchID, "input_cleanup_completed", map[string]any{
- "batch_id": batchID,
- "cleanup_target": "input",
- "deleted_at": deletedAt.UTC().Format(time.RFC3339),
- })
- }
- func (r *batchImageRepository) MarkBatchImageOutputDeleted(ctx context.Context, batchID string, deletedAt time.Time) error {
- res, err := r.sql.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET status = CASE WHEN status = 'completed' THEN 'output_deleted' ELSE status END,
- output_deleted_at = CASE WHEN output_deleted_at IS NULL THEN $2 ELSE output_deleted_at END,
- finished_at = CASE WHEN status = 'completed' AND finished_at IS NULL THEN $2 ELSE finished_at END,
- updated_at = $2,
- version = version + 1
- WHERE batch_id = $1`, batchID, deletedAt)
- if err != nil {
- return err
- }
- if affected, err := res.RowsAffected(); err == nil && affected == 0 {
- return service.ErrBatchImageJobNotFound
- }
- return appendBatchImageEventWithSQL(ctx, r.sql, batchID, "output_cleanup_completed", map[string]any{
- "batch_id": batchID,
- "cleanup_target": "output",
- "deleted_at": deletedAt.UTC().Format(time.RFC3339),
- })
- }
- func (r *batchImageRepository) MarkBatchImageDownloaded(ctx context.Context, batchID string, downloadedAt time.Time) error {
- res, err := r.sql.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET downloaded_at = CASE WHEN downloaded_at IS NULL THEN $2 ELSE downloaded_at END,
- updated_at = $2
- WHERE batch_id = $1`, batchID, downloadedAt)
- if err != nil {
- return err
- }
- if affected, err := res.RowsAffected(); err == nil && affected == 0 {
- return service.ErrBatchImageJobNotFound
- }
- return appendBatchImageEventWithSQL(ctx, r.sql, batchID, "download_completed", map[string]any{
- "batch_id": batchID,
- "downloaded_at": downloadedAt.UTC().Format(time.RFC3339),
- })
- }
- func (r *batchImageRepository) MarkBatchImageJobUserDeleted(ctx context.Context, userID, apiKeyID int64, batchID string, deletedAt time.Time) error {
- res, err := r.sql.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET user_deleted_at = CASE WHEN user_deleted_at IS NULL THEN $4 ELSE user_deleted_at END,
- updated_at = $4
- WHERE batch_id = $1
- AND user_id = $2
- AND api_key_id = $3
- AND user_deleted_at IS NULL
- AND status IN ('completed', 'failed', 'cancelled', 'output_deleted')`, batchID, userID, apiKeyID, deletedAt)
- if err != nil {
- return err
- }
- if affected, err := res.RowsAffected(); err == nil && affected == 0 {
- return service.ErrBatchImageRecordDeleteNotReady
- }
- return appendBatchImageEventWithSQL(ctx, r.sql, batchID, "user_record_deleted", map[string]any{
- "batch_id": batchID,
- "deleted_at": deletedAt.UTC().Format(time.RFC3339),
- "user_id": userID,
- "api_key_id": apiKeyID,
- })
- }
- func (r *batchImageRepository) SetBatchImageOutputExpiresAt(ctx context.Context, batchID string, expiresAt time.Time) error {
- res, err := r.sql.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET output_expires_at = CASE WHEN output_expires_at IS NULL THEN $2 ELSE output_expires_at END,
- updated_at = $3
- WHERE batch_id = $1`, batchID, expiresAt, time.Now())
- if err != nil {
- return err
- }
- if affected, err := res.RowsAffected(); err == nil && affected == 0 {
- return service.ErrBatchImageJobNotFound
- }
- return nil
- }
- func (r *batchImageRepository) RecordBatchImageCleanupFailure(ctx context.Context, batchID, code, message string) error {
- _, err := r.sql.ExecContext(ctx, `
- UPDATE batch_image_jobs
- SET last_error_code = $2,
- last_error_message = $3,
- retry_count = retry_count + 1,
- updated_at = $4
- WHERE batch_id = $1`, batchID, code, message, time.Now())
- if err != nil {
- return err
- }
- return appendBatchImageEventWithSQL(ctx, r.sql, batchID, "output_cleanup_failed", map[string]any{"error_code": code})
- }
- func (r *batchImageRepository) AppendBatchImageEvent(ctx context.Context, batchID, eventType string, payload any) error {
- return appendBatchImageEventWithSQL(ctx, r.sql, batchID, eventType, payload)
- }
- func createBatchImageJobWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, params service.CreateBatchImageJobParams) (*service.BatchImageJob, error) {
- return scanBatchImageJob(sqlq.QueryRowContext(ctx, `
- INSERT INTO batch_image_jobs (
- batch_id, user_id, api_key_id, account_id, provider, model, task_name, parent_batch_id, status,
- provider_job_name, provider_input_ref, provider_output_ref, gcs_input_uri, gcs_output_uri,
- item_count, success_count, fail_count, cancelled_count,
- estimated_cost, hold_amount, actual_cost,
- base_unit_price, group_rate_multiplier, account_rate_multiplier,
- batch_discount_multiplier, hold_multiplier, billable_unit_price, hold_unit_price,
- pricing_snapshot_version,
- currency, hold_id,
- idempotency_key, request_hash, manifest_hash, retry_count, session_id, output_expires_at
- ) VALUES (
- $1, $2, $3, $4, $5, $6, $7, $8, $9,
- $10, $11, $12, $13, $14,
- $15, $16, $17, $18,
- $19, $20, $21,
- $22, $23, $24,
- $25, $26, $27, $28,
- $29,
- $30, $31,
- $32, $33, $34, $35, $36, $37
- )
- RETURNING `+batchImageJobColumns,
- params.BatchID, params.UserID, params.APIKeyID, params.AccountID, params.Provider, params.Model, params.TaskName, params.ParentBatchID, params.Status,
- params.ProviderJobName, params.ProviderInputRef, params.ProviderOutputRef, params.GCSInputURI, params.GCSOutputURI,
- params.ItemCount, params.SuccessCount, params.FailCount, params.CancelledCount,
- params.EstimatedCost, params.HoldAmount, params.ActualCost,
- params.BaseUnitPrice, params.GroupRateMultiplier, params.AccountRateMultiplier,
- params.BatchDiscountMultiplier, params.HoldMultiplier, params.BillableUnitPrice, params.HoldUnitPrice,
- params.PricingSnapshotVersion,
- params.Currency, params.HoldID,
- params.IdempotencyKey, params.RequestHash, params.ManifestHash, params.RetryCount, params.SessionID, params.OutputExpiresAt,
- ))
- }
- func createBatchImageItemWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, params service.CreateBatchImageItemParams) (*service.BatchImageItem, error) {
- return scanBatchImageItem(sqlq.QueryRowContext(ctx, `
- INSERT INTO batch_image_items (
- job_id, custom_id, status, request_hash, prompt_preview, provider_source_object,
- source_line_number, source_byte_offset, source_byte_length,
- mime_type, file_extension, image_count,
- error_code, error_message, billed_amount, indexed_at
- ) VALUES (
- $1, $2, $3, $4, $5, $6,
- $7, $8, $9,
- $10, $11, $12,
- $13, $14, $15, $16
- )
- RETURNING `+batchImageItemColumns,
- params.JobID, params.CustomID, params.Status, params.RequestHash, params.PromptPreview, params.ProviderSourceObject,
- params.SourceLineNumber, params.SourceByteOffset, params.SourceByteLength,
- params.MimeType, params.FileExtension, params.ImageCount,
- params.ErrorCode, params.ErrorMessage, params.BilledAmount, params.IndexedAt,
- ))
- }
- func appendBatchImageEventWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, batchID, eventType string, payload any) error {
- var payloadArg any
- if payload != nil {
- payloadBytes, err := json.Marshal(payload)
- if err != nil {
- return err
- }
- payloadArg = string(payloadBytes)
- }
- _, err := sqlq.ExecContext(ctx, `
- INSERT INTO batch_image_events (job_id, event_type, payload)
- VALUES ($1, $2, $3)`, batchID, eventType, payloadArg)
- return err
- }
- type rowScanner interface {
- Scan(dest ...any) error
- }
- const batchImageJobColumns = `
- id, batch_id, user_id, api_key_id, account_id, provider, model, task_name, parent_batch_id, status,
- provider_job_name, provider_input_ref, provider_output_ref, gcs_input_uri, gcs_output_uri,
- item_count, success_count, fail_count, cancelled_count,
- estimated_cost, hold_amount, actual_cost,
- base_unit_price, group_rate_multiplier, account_rate_multiplier,
- batch_discount_multiplier, hold_multiplier, billable_unit_price, hold_unit_price,
- pricing_snapshot_version,
- currency, hold_id,
- idempotency_key, request_hash, manifest_hash,
- retry_count, version, session_id, output_expires_at, input_deleted_at, output_deleted_at, downloaded_at, user_deleted_at,
- last_error_code, last_error_message,
- created_at, updated_at, submitted_at, started_at, finished_at, settled_at`
- const batchImageJobSelectSQL = `SELECT ` + batchImageJobColumns + ` FROM batch_image_jobs`
- func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) {
- var job service.BatchImageJob
- var apiKeyID, accountID sql.NullInt64
- var providerJobName, providerInputRef, providerOutputRef, gcsInputURI, gcsOutputURI sql.NullString
- var parentBatchID sql.NullString
- var holdAmount, actualCost sql.NullFloat64
- var holdID, idempotencyKey, requestHash, manifestHash sql.NullString
- var sessionID sql.NullString
- var outputExpiresAt, inputDeletedAt, outputDeletedAt, downloadedAt, userDeletedAt sql.NullTime
- var lastErrorCode, lastErrorMessage sql.NullString
- var submittedAt, startedAt, finishedAt, settledAt sql.NullTime
- err := row.Scan(
- &job.ID, &job.BatchID, &job.UserID, &apiKeyID, &accountID, &job.Provider, &job.Model, &job.TaskName, &parentBatchID, &job.Status,
- &providerJobName, &providerInputRef, &providerOutputRef, &gcsInputURI, &gcsOutputURI,
- &job.ItemCount, &job.SuccessCount, &job.FailCount, &job.CancelledCount,
- &job.EstimatedCost, &holdAmount, &actualCost,
- &job.BaseUnitPrice, &job.GroupRateMultiplier, &job.AccountRateMultiplier,
- &job.BatchDiscountMultiplier, &job.HoldMultiplier, &job.BillableUnitPrice, &job.HoldUnitPrice,
- &job.PricingSnapshotVersion,
- &job.Currency, &holdID,
- &idempotencyKey, &requestHash, &manifestHash,
- &job.RetryCount, &job.Version, &sessionID, &outputExpiresAt, &inputDeletedAt, &outputDeletedAt, &downloadedAt, &userDeletedAt,
- &lastErrorCode, &lastErrorMessage,
- &job.CreatedAt, &job.UpdatedAt, &submittedAt, &startedAt, &finishedAt, &settledAt,
- )
- if err != nil {
- return nil, err
- }
- job.APIKeyID = batchImageNullInt64Ptr(apiKeyID)
- job.AccountID = batchImageNullInt64Ptr(accountID)
- job.ProviderJobName = batchImageNullStringPtr(providerJobName)
- job.ProviderInputRef = batchImageNullStringPtr(providerInputRef)
- job.ProviderOutputRef = batchImageNullStringPtr(providerOutputRef)
- job.ParentBatchID = batchImageNullStringPtr(parentBatchID)
- job.GCSInputURI = batchImageNullStringPtr(gcsInputURI)
- job.GCSOutputURI = batchImageNullStringPtr(gcsOutputURI)
- job.HoldAmount = batchImageNullFloat64Ptr(holdAmount)
- job.ActualCost = batchImageNullFloat64Ptr(actualCost)
- job.HoldID = batchImageNullStringPtr(holdID)
- job.IdempotencyKey = batchImageNullStringPtr(idempotencyKey)
- job.RequestHash = batchImageNullStringPtr(requestHash)
- job.ManifestHash = batchImageNullStringPtr(manifestHash)
- job.SessionID = batchImageNullStringPtr(sessionID)
- job.OutputExpiresAt = batchImageNullTimePtr(outputExpiresAt)
- job.InputDeletedAt = batchImageNullTimePtr(inputDeletedAt)
- job.OutputDeletedAt = batchImageNullTimePtr(outputDeletedAt)
- job.DownloadedAt = batchImageNullTimePtr(downloadedAt)
- job.UserDeletedAt = batchImageNullTimePtr(userDeletedAt)
- job.LastErrorCode = batchImageNullStringPtr(lastErrorCode)
- job.LastErrorMessage = batchImageNullStringPtr(lastErrorMessage)
- job.SubmittedAt = batchImageNullTimePtr(submittedAt)
- job.StartedAt = batchImageNullTimePtr(startedAt)
- job.FinishedAt = batchImageNullTimePtr(finishedAt)
- job.SettledAt = batchImageNullTimePtr(settledAt)
- return &job, nil
- }
- func scanBatchImageJobs(rows *sql.Rows) ([]*service.BatchImageJob, error) {
- var jobs []*service.BatchImageJob
- for rows.Next() {
- job, err := scanBatchImageJob(rows)
- if err != nil {
- return nil, err
- }
- jobs = append(jobs, job)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return jobs, nil
- }
- const batchImageItemColumns = `
- id, job_id, custom_id, status, request_hash, prompt_preview, provider_source_object,
- source_line_number, source_byte_offset, source_byte_length,
- mime_type, file_extension, image_count,
- error_code, error_message, billed_amount,
- created_at, indexed_at`
- const batchImageItemSelectSQL = `SELECT ` + batchImageItemColumns + ` FROM batch_image_items`
- func scanBatchImageItem(row rowScanner) (*service.BatchImageItem, error) {
- var item service.BatchImageItem
- var requestHash, promptPreview, providerSourceObject sql.NullString
- var sourceLineNumber sql.NullInt64
- var sourceByteOffset, sourceByteLength sql.NullInt64
- var mimeType, fileExtension, errorCode, errorMessage sql.NullString
- var billedAmount sql.NullFloat64
- var indexedAt sql.NullTime
- err := row.Scan(
- &item.ID, &item.JobID, &item.CustomID, &item.Status, &requestHash, &promptPreview, &providerSourceObject,
- &sourceLineNumber, &sourceByteOffset, &sourceByteLength,
- &mimeType, &fileExtension, &item.ImageCount,
- &errorCode, &errorMessage, &billedAmount,
- &item.CreatedAt, &indexedAt,
- )
- if err != nil {
- return nil, err
- }
- item.RequestHash = batchImageNullStringPtr(requestHash)
- item.PromptPreview = batchImageNullStringPtr(promptPreview)
- item.ProviderSourceObject = batchImageNullStringPtr(providerSourceObject)
- item.SourceLineNumber = batchImageNullIntPtr(sourceLineNumber)
- item.SourceByteOffset = batchImageNullInt64Ptr(sourceByteOffset)
- item.SourceByteLength = batchImageNullInt64Ptr(sourceByteLength)
- item.MimeType = batchImageNullStringPtr(mimeType)
- item.FileExtension = batchImageNullStringPtr(fileExtension)
- item.ErrorCode = batchImageNullStringPtr(errorCode)
- item.ErrorMessage = batchImageNullStringPtr(errorMessage)
- item.BilledAmount = batchImageNullFloat64Ptr(billedAmount)
- item.IndexedAt = batchImageNullTimePtr(indexedAt)
- return &item, nil
- }
- func batchImageNullStringPtr(v sql.NullString) *string {
- if !v.Valid {
- return nil
- }
- return &v.String
- }
- func batchImageNullInt64Ptr(v sql.NullInt64) *int64 {
- if !v.Valid {
- return nil
- }
- return &v.Int64
- }
- func batchImageNullIntPtr(v sql.NullInt64) *int {
- if !v.Valid {
- return nil
- }
- i := int(v.Int64)
- return &i
- }
- func batchImageNullFloat64Ptr(v sql.NullFloat64) *float64 {
- if !v.Valid {
- return nil
- }
- return &v.Float64
- }
- func batchImageNullTimePtr(v sql.NullTime) *time.Time {
- if !v.Valid {
- return nil
- }
- return &v.Time
- }
- var _ service.BatchImageRepository = (*batchImageRepository)(nil)
|