Files
go-sip/internal/store/uploads.go
T

170 lines
5.8 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
type UploadRecord struct {
UploadID string
Binding []byte
Asset []byte
Grant []byte
ObjectKey string
State string
OSSID string
CreatedAt time.Time
CompletedAt *time.Time
}
func (s *Store) LoadUpload(uploadID string) (UploadRecord, error) {
if uploadID == "" {
return UploadRecord{}, errors.New("upload ID is required")
}
s.mu.Lock()
defer s.mu.Unlock()
var record UploadRecord
var createdAt string
var completedAt sql.NullString
var ossID sql.NullString
err := s.db.QueryRow(`SELECT upload_id, binding, asset, grant, object_key, state, oss_id, created_at, completed_at
FROM uploads WHERE upload_id = ?`, uploadID).Scan(
&record.UploadID, &record.Binding, &record.Asset, &record.Grant, &record.ObjectKey,
&record.State, &ossID, &createdAt, &completedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return UploadRecord{}, err
}
return UploadRecord{}, fmt.Errorf("load upload: %w", err)
}
record.OSSID = ossID.String
parsed, err := time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return UploadRecord{}, fmt.Errorf("parse upload created_at: %w", err)
}
record.CreatedAt = parsed
if completedAt.Valid && completedAt.String != "" {
parsed, err := time.Parse(time.RFC3339Nano, completedAt.String)
if err != nil {
return UploadRecord{}, fmt.Errorf("parse upload completed_at: %w", err)
}
record.CompletedAt = &parsed
}
return record, nil
}
func (s *Store) ReplaceUploadGrant(uploadID, objectKey string, grant []byte) error {
if uploadID == "" || objectKey == "" || len(grant) == 0 {
return errors.New("upload ID, object key and grant are required")
}
s.mu.Lock()
defer s.mu.Unlock()
result, err := s.db.Exec(`UPDATE uploads SET grant = ?, object_key = ? WHERE upload_id = ? AND state = 'granted'`, grant, objectKey, uploadID)
if err != nil {
return fmt.Errorf("replace upload grant: %w", err)
}
updated, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("inspect replaced upload grant: %w", err)
}
if updated == 0 {
var state string
if err := s.db.QueryRow(`SELECT state FROM uploads WHERE upload_id = ?`, uploadID).Scan(&state); err != nil {
return err
}
if state == "completed" {
return nil
}
return errors.New("upload is not in granted state")
}
return nil
}
func (s *Store) InsertUpload(record UploadRecord) error {
if record.UploadID == "" || len(record.Binding) == 0 || len(record.Asset) == 0 || len(record.Grant) == 0 || record.ObjectKey == "" {
return errors.New("complete upload record is required")
}
if record.CreatedAt.IsZero() {
record.CreatedAt = s.now().UTC()
}
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`INSERT INTO uploads(upload_id, binding, asset, grant, object_key, state, oss_id, created_at, completed_at)
VALUES(?, ?, ?, ?, ?, ?, NULL, ?, NULL)`, record.UploadID, record.Binding, record.Asset, record.Grant,
record.ObjectKey, record.State, record.CreatedAt.UTC().Format(time.RFC3339Nano))
if err != nil {
return fmt.Errorf("insert upload: %w", err)
}
return nil
}
func (s *Store) CompleteUpload(uploadID, ossID string, completedAt time.Time) error {
return s.completeUpload(uploadID, ossID, completedAt, nil, "", "", "", "")
}
// CompleteUploadAndOutbox atomically records verified OSS completion and the
// recording.ready event. A successful RPC therefore cannot lose the MQ handoff
// between the upload state update and outbox persistence.
func (s *Store) CompleteUploadAndOutbox(uploadID, ossID string, completedAt time.Time, eventID, tenantKey, exchange, routingKey string, body []byte) error {
if eventID == "" || tenantKey == "" || exchange == "" || routingKey == "" || len(body) == 0 {
return errors.New("verified upload outbox event is required")
}
return s.completeUpload(uploadID, ossID, completedAt, body, eventID, tenantKey, exchange, routingKey)
}
func (s *Store) completeUpload(uploadID, ossID string, completedAt time.Time, body []byte, eventID, tenantKey, exchange, routingKey string) error {
if uploadID == "" || ossID == "" {
return errors.New("upload ID and OSS ID are required")
}
if completedAt.IsZero() {
completedAt = s.now().UTC()
}
if eventID != "" && (exchange == "" || routingKey == "") {
return errors.New("exchange and routing key are required")
}
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin complete upload: %w", err)
}
defer func() { _ = tx.Rollback() }()
result, err := tx.Exec(`UPDATE uploads SET state = 'completed', oss_id = ?, completed_at = ? WHERE upload_id = ? AND state = 'granted'`,
ossID, completedAt.UTC().Format(time.RFC3339Nano), uploadID)
if err != nil {
return fmt.Errorf("complete upload: %w", err)
}
updated, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("inspect completed upload: %w", err)
}
if updated == 0 {
var state string
var existingOSSID sql.NullString
lookupErr := tx.QueryRow(`SELECT state, oss_id FROM uploads WHERE upload_id = ?`, uploadID).Scan(&state, &existingOSSID)
if lookupErr != nil {
if errors.Is(lookupErr, sql.ErrNoRows) {
return lookupErr
}
return fmt.Errorf("inspect upload state: %w", lookupErr)
}
if state == "completed" && existingOSSID.Valid && existingOSSID.String == ossID {
return tx.Commit()
}
return errors.New("upload is not in granted state")
}
if eventID != "" {
if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at)
VALUES(?, ?, ?, ?, ?, 'pending', ?)`, eventID, tenantKey, exchange, routingKey, body, completedAt.UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("persist verified upload outbox: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit completed upload: %w", err)
}
return nil
}