169 lines
5.3 KiB
Go

package mysql
import (
"context"
"database/sql"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
)
// UserOutboxRecord is a durable user-service fact waiting for MQ fanout.
type UserOutboxRecord struct {
AppCode string
EventID string
EventType string
AggregateType string
AggregateID int64
PayloadJSON string
CreatedAtMS int64
RetryCount int
LastError string
WorkerID string
LockUntilMS int64
}
// ClaimPendingUserOutbox locks pending user facts so one worker can publish them.
func (r *Repository) ClaimPendingUserOutbox(ctx context.Context, workerID string, batchSize int) ([]UserOutboxRecord, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if batchSize <= 0 {
batchSize = 100
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
workerID = "user-outbox-worker"
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
nowMS := time.Now().UTC().UnixMilli()
lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
appCode := appcode.FromContext(ctx)
records := make([]UserOutboxRecord, 0, batchSize)
claimBranches := []struct {
query string
args []any
}{
{
query: `
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
FROM user_outbox FORCE INDEX (idx_user_outbox_status_created)
WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`,
args: []any{appCode, nowMS},
},
{
query: `
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
FROM user_outbox FORCE INDEX (idx_user_outbox_status_created)
WHERE app_code = ? AND status = 'retryable' AND next_retry_at_ms <= ? AND lock_until_ms = 0
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`,
args: []any{appCode, nowMS},
},
{
query: `
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
FROM user_outbox FORCE INDEX (idx_user_outbox_lock)
WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`,
args: []any{appCode, nowMS},
},
}
for _, branch := range claimBranches {
remaining := batchSize - len(records)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchRecords, err := queryUserOutboxRecords(ctx, tx, branch.query, args...)
if err != nil {
return nil, err
}
records = append(records, branchRecords...)
}
for _, record := range records {
if _, err := tx.ExecContext(ctx, `
UPDATE user_outbox
SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?
`, workerID, lockUntilMS, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
for index := range records {
records[index].WorkerID = workerID
records[index].LockUntilMS = lockUntilMS
}
return records, nil
}
func queryUserOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]UserOutboxRecord, error) {
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]UserOutboxRecord, 0)
for rows.Next() {
var record UserOutboxRecord
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.AggregateType, &record.AggregateID, &record.PayloadJSON, &record.CreatedAtMS); err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
// MarkUserOutboxDelivered marks a user fact as published after RocketMQ accepts it.
func (r *Repository) MarkUserOutboxDelivered(ctx context.Context, eventID string) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE user_outbox
SET status = 'delivered', worker_id = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
WHERE app_code = ? AND event_id = ?
`, time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID)
return err
}
// MarkUserOutboxRetryable releases a failed publish with a bounded retry delay.
func (r *Repository) MarkUserOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE user_outbox
SET status = 'retryable', worker_id = '', lock_until_ms = 0, retry_count = retry_count + 1,
next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ? AND status = 'running'
`, nextRetryAtMS, trimUserOutboxError(lastErr), time.Now().UTC().UnixMilli(), appcode.FromContext(ctx), eventID)
return err
}
func trimUserOutboxError(value string) string {
value = strings.TrimSpace(value)
if len(value) > 512 {
return value[:512]
}
return value
}