package mysql import ( "context" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "wucher/internal/domain/auth" ) func (r *AuthRepository) WithTransaction(ctx context.Context, fn func(repo auth.Repository, outbox auth.EmailOutboxWriter) error) error { if fn == nil { return nil } return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { txRepo := &AuthRepository{db: tx} return fn(txRepo, txRepo) }) } func (r *AuthRepository) CreateEmailOutboxMessage(ctx context.Context, message *auth.EmailOutboxMessage) error { if message == nil { return gorm.ErrInvalidData } return r.db.WithContext(ctx).Create(message).Error } func (r *AuthRepository) ClaimPendingEmailOutboxMessages( ctx context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string, ) ([]auth.EmailOutboxMessage, error) { if limit <= 0 { limit = 1 } if lockTTL <= 0 { lockTTL = time.Minute } now = now.UTC() staleBefore := now.Add(-lockTTL) workerID = strings.TrimSpace(workerID) if workerID == "" { workerID = "worker" } var messages []auth.EmailOutboxMessage err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx. Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). Where( "((status = ? AND available_at <= ?) OR (status = ? AND locked_at IS NOT NULL AND locked_at < ?))", auth.EmailOutboxStatusPending, now, auth.EmailOutboxStatusProcessing, staleBefore, ). Order("available_at ASC, created_at ASC"). Limit(limit). Find(&messages).Error; err != nil { return err } if len(messages) == 0 { return nil } ids := make([][]byte, 0, len(messages)) for i := range messages { ids = append(ids, messages[i].ID) } if err := tx.Model(&auth.EmailOutboxMessage{}). Where("id IN ?", ids). Updates(map[string]any{ "status": auth.EmailOutboxStatusProcessing, "locked_at": now, "locked_by": workerID, "updated_at": now, "attempts": gorm.Expr("attempts + ?", 1), }).Error; err != nil { return err } for i := range messages { messages[i].Status = auth.EmailOutboxStatusProcessing messages[i].Attempts++ ts := now messages[i].LockedAt = &ts messages[i].LockedBy = workerID messages[i].UpdatedAt = now } return nil }) return messages, err } func (r *AuthRepository) MarkEmailOutboxMessagePublished(ctx context.Context, id []byte, publishedAt time.Time) error { publishedAt = publishedAt.UTC() return r.db.WithContext(ctx). Model(&auth.EmailOutboxMessage{}). Where("id = ?", id). Updates(map[string]any{ "status": auth.EmailOutboxStatusPublished, "published_at": publishedAt, "locked_at": nil, "locked_by": "", "last_error": "", "updated_at": publishedAt, }).Error } func (r *AuthRepository) MarkEmailOutboxMessageRetry(ctx context.Context, id []byte, availableAt time.Time, lastErr string) error { availableAt = availableAt.UTC() return r.db.WithContext(ctx). Model(&auth.EmailOutboxMessage{}). Where("id = ?", id). Updates(map[string]any{ "status": auth.EmailOutboxStatusPending, "available_at": availableAt, "locked_at": nil, "locked_by": "", "last_error": truncateOutboxError(strings.TrimSpace(lastErr), 4096), "updated_at": time.Now().UTC(), }).Error } func (r *AuthRepository) MarkEmailOutboxMessageDead(ctx context.Context, id []byte, failedAt time.Time, lastErr string) error { failedAt = failedAt.UTC() return r.db.WithContext(ctx). Model(&auth.EmailOutboxMessage{}). Where("id = ?", id). Updates(map[string]any{ "status": auth.EmailOutboxStatusDead, "available_at": failedAt, "locked_at": nil, "locked_by": "", "last_error": truncateOutboxError(strings.TrimSpace(lastErr), 4096), "updated_at": failedAt, }).Error } func (r *AuthRepository) CountPendingEmailOutboxMessages(ctx context.Context, now time.Time) (int64, error) { var count int64 err := r.db.WithContext(ctx). Model(&auth.EmailOutboxMessage{}). Where( "(status = ? AND available_at <= ?) OR status = ?", auth.EmailOutboxStatusPending, now.UTC(), auth.EmailOutboxStatusProcessing, ). Count(&count).Error return count, err } func truncateOutboxError(v string, max int) string { if max <= 0 || len(v) <= max { return v } return v[:max] }