Files
NianAIGC/backend/internal/jobs/job.go
T

215 lines
6.4 KiB
Go

// Package jobs owns generation-job state, ownership, idempotency, and worker
// coordination independently of HTTP, provider, storage, and billing adapters.
package jobs
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
)
type Status string
const (
StatusQueued Status = "queued"
StatusRunning Status = "running"
StatusSucceeded Status = "succeeded"
StatusFailed Status = "failed"
StatusExpired Status = "expired"
StatusCancelled Status = "cancelled"
)
func (status Status) Terminal() bool {
return status == StatusSucceeded || status == StatusFailed || status == StatusExpired || status == StatusCancelled
}
func (status Status) Valid() bool {
return status == StatusQueued || status == StatusRunning || status.Terminal()
}
type Job struct {
ID string `json:"id"`
OwnerID string `json:"ownerId"`
ExternalClientID string `json:"externalClientId,omitempty"`
Capability string `json:"capability"`
Provider string `json:"provider"`
ReqKey string `json:"reqKey"`
Status Status `json:"status"`
Prompt string `json:"prompt,omitempty"`
InputAssetIDs []string `json:"inputAssetIds"`
InputURLs []string `json:"inputUrls"`
OutputAssetIDs []string `json:"outputAssetIds"`
ProviderTaskID string `json:"providerTaskId,omitempty"`
ProviderDispatchStartedAt *time.Time `json:"providerDispatchStartedAt,omitempty"`
RequestPayload json.RawMessage `json:"requestPayload"`
ResponsePayload json.RawMessage `json:"responsePayload,omitempty"`
Error *JobError `json:"error,omitempty"`
RetryOf string `json:"retryOf,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
IdempotencyFingerprint string `json:"idempotencyFingerprint,omitempty"`
Priority int `json:"priority,omitempty"`
Attempts int `json:"attempts,omitempty"`
MaxAttempts int `json:"maxAttempts,omitempty"`
ScheduledAt time.Time `json:"scheduledAt,omitempty"`
LockedAt *time.Time `json:"lockedAt,omitempty"`
LockedBy string `json:"lockedBy,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
DispatchReadyAt *time.Time `json:"dispatchReadyAt,omitempty"`
FinalizedAt *time.Time `json:"finalizedAt,omitempty"`
WebhookURL string `json:"webhookUrl,omitempty"`
WebhookAttempts int `json:"webhookAttempts,omitempty"`
WebhookLastStatus json.RawMessage `json:"webhookLastStatus,omitempty"`
UsageContext json.RawMessage `json:"usageContext,omitempty"`
Billing json.RawMessage `json:"billing,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type JobError struct {
Code any `json:"code,omitempty"`
Message string `json:"message"`
Retryable bool `json:"retryable,omitempty"`
}
type Scope struct {
OwnerID string
ExternalClientID string
}
func (scope Scope) Owns(job Job) bool {
if scope.OwnerID == "" || job.OwnerID != scope.OwnerID {
return false
}
return scope.ExternalClientID == "" || job.ExternalClientID == scope.ExternalClientID
}
type ErrorKind string
const (
ErrorInvalid ErrorKind = "invalid"
ErrorNotFound ErrorKind = "not_found"
ErrorConflict ErrorKind = "conflict"
)
type Error struct {
Kind ErrorKind
Status int
Message string
}
func (err *Error) Error() string { return err.Message }
var ErrUniqueIdempotency = errors.New("generation job idempotency key already exists")
var ErrTransitionConflict = errors.New("generation job transition conflict")
type ListFilter struct {
Scope Scope
Status Status
Capability string
Limit int
Before *time.Time
}
type CreateCommand struct {
Job Job
IdempotencyBody map[string]any
// HoldDispatch keeps a newly persisted job invisible to workers until an
// external prerequisite (currently the idempotent wallet charge) is durable.
HoldDispatch bool
}
func NormalizePriority(value int) int {
if value < -100 {
return -100
}
if value > 100 {
return 100
}
return value
}
func NormalizePublicLimit(value int) int {
if value == 0 {
return 50
}
if value < 1 {
return 1
}
if value > 200 {
return 200
}
return value
}
// Fingerprint excludes the compatibility body key idempotencyKey, recursively
// sorts object keys, preserves array order, and hashes the exact stable JSON.
func Fingerprint(body map[string]any) (string, error) {
source := make(map[string]any, len(body))
for key, value := range body {
if key != "idempotencyKey" {
source[key] = value
}
}
stable, err := stableJSON(source)
if err != nil {
return "", err
}
sum := sha256.Sum256(stable)
return hex.EncodeToString(sum[:]), nil
}
func stableJSON(value any) ([]byte, error) {
switch typed := value.(type) {
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
var output strings.Builder
output.WriteByte('{')
for index, key := range keys {
if index > 0 {
output.WriteByte(',')
}
encodedKey, _ := json.Marshal(key)
encodedValue, err := stableJSON(typed[key])
if err != nil {
return nil, err
}
output.Write(encodedKey)
output.WriteByte(':')
output.Write(encodedValue)
}
output.WriteByte('}')
return []byte(output.String()), nil
case []any:
var output strings.Builder
output.WriteByte('[')
for index, item := range typed {
if index > 0 {
output.WriteByte(',')
}
encoded, err := stableJSON(item)
if err != nil {
return nil, err
}
output.Write(encoded)
}
output.WriteByte(']')
return []byte(output.String()), nil
default:
encoded, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("encode idempotency fingerprint: %w", err)
}
return encoded, nil
}
}