155 lines
6.2 KiB
Go
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}})
|
||
|
|
})
|
||
|
|
}
|