Files

155 lines
6.2 KiB
Go

package media
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/objectstore"
"data-terminal-cloud/internal/outbox"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type verificationStore interface {
VerifyDigest(context.Context, string, string, int64) error
ReadPrefix(context.Context, string, int64) ([]byte, error)
}
type Processor struct {
pool *pgxpool.Pool
store verificationStore
audit *audit.Repository
localDeleteGrace time.Duration
}
func NewProcessor(pool *pgxpool.Pool, store verificationStore, auditRepository *audit.Repository, localDeleteGrace ...time.Duration) (*Processor, error) {
if pool == nil || store == nil || auditRepository == nil {
return nil, errors.New("media processor requires database, object store and audit repository")
}
grace := time.Hour
if len(localDeleteGrace) > 0 {
grace = localDeleteGrace[0]
}
if grace <= 0 {
return nil, errors.New("media local delete grace must be positive")
}
return &Processor{pool: pool, store: store, audit: auditRepository, localDeleteGrace: grace}, nil
}
func (p *Processor) Handle(ctx context.Context, event outbox.Event) error {
var payload struct {
BatchID string `json:"batch_id"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil || payload.BatchID == "" {
return errors.New("media verification event has no batch_id")
}
var batchID, tenantID, status string
err := p.pool.QueryRow(ctx, `SELECT id::text,tenant_id::text,status FROM upload_batch WHERE public_id=$1::uuid`, payload.BatchID).Scan(&batchID, &tenantID, &status)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if status == "COMPLETED" || status == "FAILED" || status == "EXPIRED" {
return nil
}
if status != "VERIFYING" {
return fmt.Errorf("media batch %s is in unexpected status %s", payload.BatchID, status)
}
rows, err := p.pool.Query(ctx, `
SELECT ms.id::text,ms.object_ref,ms.expected_digest,ms.expected_size,ms.codec,ms.duration_ms
FROM media_segment ms JOIN upload_batch_segment ubs ON ubs.media_segment_id=ms.id
WHERE ubs.upload_batch_id=$1::uuid AND ms.status='VERIFYING'
ORDER BY ms.track,ms.sequence_no`, batchID)
if err != nil {
return err
}
type pendingSegment struct {
id, objectRef, digest, codec string
size int64
durationMS int
}
var pending []pendingSegment
for rows.Next() {
var item pendingSegment
if err := rows.Scan(&item.id, &item.objectRef, &item.digest, &item.size, &item.codec, &item.durationMS); err != nil {
rows.Close()
return err
}
pending = append(pending, item)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, segment := range pending {
err := p.store.VerifyDigest(ctx, segment.objectRef, segment.digest, segment.size)
if err == nil {
prefix, readErr := p.store.ReadPrefix(ctx, segment.objectRef, 64*1024)
if readErr != nil {
_, _ = p.pool.Exec(ctx, `UPDATE upload_batch SET verification_attempts=verification_attempts+1,last_error_code='OBJECT_NOT_VISIBLE',updated_at=now() WHERE id=$1::uuid`, batchID)
return outbox.Retryable(fmt.Errorf("read media segment header: %w", readErr))
}
validationCode := validateAudio(segment.codec, segment.durationMS, prefix)
if validationCode != "" {
if _, updateErr := p.pool.Exec(ctx, `UPDATE media_segment SET status='REJECTED',verification_attempts=verification_attempts+1,last_error_code=$2,updated_at=now() WHERE id=$1::uuid AND status='VERIFYING'`, segment.id, validationCode); updateErr != nil {
return updateErr
}
continue
}
if _, updateErr := p.pool.Exec(ctx, `UPDATE media_segment SET status='VERIFIED',verification_attempts=verification_attempts+1,verified_at=now(),local_delete_after=now()+($2::bigint*interval '1 microsecond'),last_error_code=NULL,updated_at=now() WHERE id=$1::uuid AND status='VERIFYING'`, segment.id, p.localDeleteGrace.Microseconds()); updateErr != nil {
return updateErr
}
continue
}
if errors.Is(err, objectstore.ErrObjectSizeMismatch) || errors.Is(err, objectstore.ErrObjectDigestMismatch) {
code := "DIGEST_MISMATCH"
if errors.Is(err, objectstore.ErrObjectSizeMismatch) {
code = "SIZE_MISMATCH"
}
if _, updateErr := p.pool.Exec(ctx, `UPDATE media_segment SET status='REJECTED',verification_attempts=verification_attempts+1,last_error_code=$2,updated_at=now() WHERE id=$1::uuid AND status='VERIFYING'`, segment.id, code); updateErr != nil {
return updateErr
}
continue
}
_, _ = p.pool.Exec(ctx, `UPDATE upload_batch SET verification_attempts=verification_attempts+1,last_error_code='OBJECT_NOT_VISIBLE',updated_at=now() WHERE id=$1::uuid`, batchID)
return outbox.Retryable(fmt.Errorf("verify media segment object: %w", err))
}
return database.Transact(ctx, p.pool, func(tx pgx.Tx) error {
var verified, rejected, remaining int
if err := tx.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE ms.status='VERIFIED'),count(*) FILTER (WHERE ms.status='REJECTED'),count(*) FILTER (WHERE ms.status NOT IN ('VERIFIED','REJECTED','DELETED'))
FROM media_segment ms JOIN upload_batch_segment ubs ON ubs.media_segment_id=ms.id WHERE ubs.upload_batch_id=$1::uuid`, batchID,
).Scan(&verified, &rejected, &remaining); err != nil {
return err
}
if remaining > 0 {
return fmt.Errorf("media batch still has %d non-terminal segments", remaining)
}
batchStatus := "COMPLETED"
lastError := any(nil)
if rejected > 0 {
batchStatus = "FAILED"
lastError = "SEGMENT_REJECTED"
}
if _, err := tx.Exec(ctx, `UPDATE upload_batch SET status=$2,verified_count=$3,rejected_count=$4,verification_attempts=verification_attempts+1,last_error_code=$5,completed_at=now(),updated_at=now() WHERE id=$1::uuid`, batchID, batchStatus, verified, rejected, lastError); err != nil {
return err
}
auditResult := "success"
if rejected > 0 {
auditResult = "failure"
}
return p.audit.Record(ctx, tx, audit.Event{TenantID: &tenantID, ActorType: "worker", Action: "media.upload_batch.verify", ResourceType: "upload_batch", ResourceID: &payload.BatchID, Result: auditResult, Detail: map[string]any{"status": batchStatus, "verified": verified, "rejected": rejected}})
})
}