Make artifact and media metadata first-class server records
Artifact and media metadata were previously reconstructed from job result refs and JSON sidecar files. This change regularizes metadata into SQLite, keeps filesystem blobs in place, and preserves backward compatibility via lazy fallback and backfill from existing job refs and JSON metadata. Constraint: Blob storage remains on the local filesystem in this phase Rejected: Migrate blobs into SQLite | larger scope and worse operational profile for current media sizes Rejected: Hard cutover without fallback | unsafe for historical data already on the test server Confidence: medium Scope-risk: moderate Directive: Treat SQLite as the metadata source of truth; JSON sidecars are compatibility fallback only Tested: go test ./...; deployed to test server 101.42.99.35; verified /v1/artifacts, /v1/artifacts/:id, signed media URL 200, unsigned content 401 Not-tested: Full historical backfill sweep over all existing artifact rows under production-sized data volume
This commit is contained in:
+158
-16
@@ -13,9 +13,12 @@ import (
|
||||
)
|
||||
|
||||
type store struct {
|
||||
cfg Config
|
||||
sessions SessionRepository
|
||||
jobs JobRepository
|
||||
routes RouteRepository
|
||||
media MediaRepository
|
||||
artifacts ArtifactRepository
|
||||
skills []skill
|
||||
projects []project
|
||||
sessionTTL time.Duration
|
||||
@@ -36,9 +39,12 @@ func newStore(cfg Config) (*store, error) {
|
||||
}
|
||||
|
||||
return &store{
|
||||
cfg: cfg,
|
||||
sessions: repo,
|
||||
jobs: repo,
|
||||
routes: repo,
|
||||
media: repo,
|
||||
artifacts: repo,
|
||||
skills: skills,
|
||||
projects: projects,
|
||||
sessionTTL: 30 * 24 * time.Hour,
|
||||
@@ -245,7 +251,14 @@ func (s *store) failJob(jobID, code, message string, details map[string]any) err
|
||||
}
|
||||
|
||||
func (s *store) completeJobWithResults(jobID string, refs []resultRef, usage map[string]any) error {
|
||||
return s.jobs.CompleteSyncResult(jobID, refs, cloneMap(usage))
|
||||
if err := s.jobs.CompleteSyncResult(jobID, refs, cloneMap(usage)); err != nil {
|
||||
return err
|
||||
}
|
||||
record, exists, err := s.jobs.GetJob(jobID)
|
||||
if err != nil || !exists || record == nil {
|
||||
return err
|
||||
}
|
||||
return s.syncArtifactsForJob(record)
|
||||
}
|
||||
|
||||
func (s *store) artifactsForJob(userID, jobID string) ([]artifact, bool, error) {
|
||||
@@ -253,10 +266,28 @@ func (s *store) artifactsForJob(userID, jobID string) ([]artifact, bool, error)
|
||||
if err != nil || !exists {
|
||||
return nil, exists, err
|
||||
}
|
||||
if err := s.syncArtifactsForJob(record); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if s.artifacts != nil {
|
||||
items, _, err := s.artifacts.ListArtifacts(userID, "", jobID, 1000, 0)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if len(items) > 0 || len(record.ResultRefs) == 0 {
|
||||
return items, true, nil
|
||||
}
|
||||
}
|
||||
return buildArtifacts(record), true, nil
|
||||
}
|
||||
|
||||
func (s *store) artifactRef(userID, artifactID string) (*artifact, resultRef, bool, error) {
|
||||
if s.artifacts != nil {
|
||||
item, ref, exists, err := s.artifacts.GetArtifact(userID, artifactID)
|
||||
if err != nil || exists {
|
||||
return item, ref, exists, err
|
||||
}
|
||||
}
|
||||
jobID, idx, ok := parseArtifactID(artifactID)
|
||||
if !ok {
|
||||
return nil, resultRef{}, false, nil
|
||||
@@ -265,6 +296,15 @@ func (s *store) artifactRef(userID, artifactID string) (*artifact, resultRef, bo
|
||||
if err != nil || !exists {
|
||||
return nil, resultRef{}, exists, err
|
||||
}
|
||||
if err := s.syncArtifactsForJob(record); err != nil {
|
||||
return nil, resultRef{}, true, err
|
||||
}
|
||||
if s.artifacts != nil {
|
||||
item, ref, exists, err := s.artifacts.GetArtifact(userID, artifactID)
|
||||
if err != nil || exists {
|
||||
return item, ref, exists, err
|
||||
}
|
||||
}
|
||||
items := buildArtifacts(record)
|
||||
if idx < 0 || idx >= len(items) || idx >= len(record.ResultRefs) {
|
||||
return nil, resultRef{}, false, nil
|
||||
@@ -273,6 +313,23 @@ func (s *store) artifactRef(userID, artifactID string) (*artifact, resultRef, bo
|
||||
return &item, record.ResultRefs[idx], true, nil
|
||||
}
|
||||
|
||||
func (s *store) listArtifacts(userID, projectID, jobID string, limit, offset int) ([]artifact, int, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
if err := s.backfillArtifacts(userID, strings.TrimSpace(projectID), strings.TrimSpace(jobID)); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if s.artifacts != nil {
|
||||
return s.artifacts.ListArtifacts(userID, projectID, jobID, limit, offset)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s *store) routesForProject(projectID string) (map[string]string, error) {
|
||||
return s.routes.GetRoutes(projectID)
|
||||
}
|
||||
@@ -285,6 +342,71 @@ func (s *store) unsetRouteOverride(projectID, routeKey string) error {
|
||||
return s.routes.UnsetRoute(projectID, normalizeRouteKey(routeKey))
|
||||
}
|
||||
|
||||
func (s *store) persistMediaContent(userID, projectID, artifactID, filename, contentType, visibility string, content []byte, mediaID string) (mediaRecord, error) {
|
||||
return persistMediaContent(s.cfg, s.media, userID, projectID, artifactID, filename, contentType, visibility, content, mediaID)
|
||||
}
|
||||
|
||||
func (s *store) getMedia(mediaID string) (*mediaRecord, bool, error) {
|
||||
if s.media != nil {
|
||||
record, exists, err := s.media.GetMedia(mediaID)
|
||||
if err != nil || exists {
|
||||
return record, exists, err
|
||||
}
|
||||
}
|
||||
record, exists, err := loadMediaRecordFromJSON(s.cfg, mediaID)
|
||||
if err != nil || !exists || record == nil {
|
||||
return record, exists, err
|
||||
}
|
||||
if s.media != nil {
|
||||
if upsertErr := s.media.UpsertMedia(*record); upsertErr != nil {
|
||||
return nil, false, upsertErr
|
||||
}
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (s *store) syncArtifactsForJob(record *job) error {
|
||||
if s.artifacts == nil || record == nil {
|
||||
return nil
|
||||
}
|
||||
return s.artifacts.UpsertArtifacts(record.UserID, record.ProjectID, buildArtifacts(record))
|
||||
}
|
||||
|
||||
func (s *store) backfillArtifacts(userID, projectID, jobID string) error {
|
||||
if s.artifacts == nil {
|
||||
return nil
|
||||
}
|
||||
if jobID != "" {
|
||||
record, exists, err := s.getJob(userID, jobID)
|
||||
if err != nil || !exists || record == nil {
|
||||
return err
|
||||
}
|
||||
if projectID != "" && record.ProjectID != projectID {
|
||||
return nil
|
||||
}
|
||||
return s.syncArtifactsForJob(record)
|
||||
}
|
||||
|
||||
const jobPageSize = 200
|
||||
totalJobs := 0
|
||||
for jobOffset := 0; ; jobOffset += jobPageSize {
|
||||
jobs, total, err := s.jobs.ListJobs(userID, "", "", projectID, jobPageSize, jobOffset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalJobs = total
|
||||
for idx := range jobs {
|
||||
if err := s.syncArtifactsForJob(&jobs[idx]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if jobOffset+len(jobs) >= totalJobs || len(jobs) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneJob(record *job) *job {
|
||||
if record == nil {
|
||||
return nil
|
||||
@@ -371,21 +493,29 @@ func buildArtifacts(record *job) []artifact {
|
||||
storageStatus = "embedded"
|
||||
}
|
||||
}
|
||||
items = append(items, artifact{
|
||||
ID: buildArtifactID(record.JobID, idx),
|
||||
JobID: record.JobID,
|
||||
MediaID: strings.TrimSpace(ref.MediaID),
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: ref.SizeBytes,
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: expiresAt,
|
||||
URL: strings.TrimSpace(ref.URL),
|
||||
Visibility: strings.TrimSpace(ref.Visibility),
|
||||
SHA256: strings.TrimSpace(ref.SHA256),
|
||||
StorageStatus: storageStatus,
|
||||
Ref: ref,
|
||||
})
|
||||
item := artifact{
|
||||
ID: buildArtifactID(record.JobID, idx),
|
||||
JobID: record.JobID,
|
||||
ProjectID: record.ProjectID,
|
||||
MediaID: strings.TrimSpace(ref.MediaID),
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: ref.SizeBytes,
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: expiresAt,
|
||||
URL: strings.TrimSpace(ref.URL),
|
||||
Visibility: strings.TrimSpace(ref.Visibility),
|
||||
SHA256: strings.TrimSpace(ref.SHA256),
|
||||
StorageStatus: storageStatus,
|
||||
SourceSkillID: strings.TrimSpace(record.SkillID),
|
||||
SourceModelID: strings.TrimSpace(record.ModelID),
|
||||
SourceRouteKey: strings.TrimSpace(record.RouteKey),
|
||||
SourceInput: cloneMap(record.Input),
|
||||
PromptText: primaryPromptText(record.Input),
|
||||
Usage: cloneMap(record.Usage),
|
||||
Ref: ref,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -401,6 +531,18 @@ func buildArtifactIDs(jobID string, refs []resultRef) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
func primaryPromptText(input map[string]any) string {
|
||||
if input == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(stringValue(
|
||||
input["prompt"],
|
||||
input["motion_prompt"],
|
||||
input["scene_prompt"],
|
||||
input["text"],
|
||||
))
|
||||
}
|
||||
|
||||
func buildArtifactID(jobID string, idx int) string {
|
||||
return "art_" + strings.TrimPrefix(jobID, "job_") + "_" + strconv.Itoa(idx)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user