169 lines
5.3 KiB
Go
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
|
|
}
|