175 lines
5.2 KiB
Go
175 lines
5.2 KiB
Go
package outbox
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var ErrDeduplicationConflict = errors.New("outbox deduplication key was reused with different payload")
|
|
|
|
type Event struct {
|
|
ID string
|
|
TenantID *string
|
|
AggregateType string
|
|
AggregateID string
|
|
EventType string
|
|
DeduplicationKey *string
|
|
Payload json.RawMessage
|
|
AttemptCount int
|
|
}
|
|
|
|
type NewEvent struct {
|
|
TenantID *string
|
|
AggregateType string
|
|
AggregateID string
|
|
EventType string
|
|
DeduplicationKey *string
|
|
Payload json.RawMessage
|
|
}
|
|
|
|
type Store struct{ pool *pgxpool.Pool }
|
|
|
|
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
|
|
|
func (s *Store) Enqueue(ctx context.Context, tx pgx.Tx, event NewEvent) (string, error) {
|
|
if event.AggregateType == "" || event.AggregateID == "" || event.EventType == "" || !json.Valid(event.Payload) {
|
|
return "", errors.New("aggregate, event type and valid JSON payload are required")
|
|
}
|
|
payloadHash := hashPayload(event.Payload)
|
|
var eventID string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO outbox_event (
|
|
tenant_id, aggregate_type, aggregate_id, event_type,
|
|
deduplication_key, payload, payload_hash
|
|
) VALUES ($1::uuid, $2, $3::uuid, $4, $5, $6::jsonb, $7)
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id::text`,
|
|
event.TenantID, event.AggregateType, event.AggregateID, event.EventType,
|
|
event.DeduplicationKey, event.Payload, payloadHash,
|
|
).Scan(&eventID)
|
|
if err == nil {
|
|
return eventID, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) || event.DeduplicationKey == nil {
|
|
return "", fmt.Errorf("enqueue outbox event: %w", err)
|
|
}
|
|
var storedHash string
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT id::text, payload_hash
|
|
FROM outbox_event
|
|
WHERE COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid) =
|
|
COALESCE($1::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
|
AND event_type = $2 AND deduplication_key = $3
|
|
FOR UPDATE`, event.TenantID, event.EventType, event.DeduplicationKey,
|
|
).Scan(&eventID, &storedHash)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read deduplicated outbox event: %w", err)
|
|
}
|
|
if storedHash != payloadHash {
|
|
return "", ErrDeduplicationConflict
|
|
}
|
|
return eventID, nil
|
|
}
|
|
|
|
func (s *Store) ClaimBatch(ctx context.Context, workerID string, batchSize int, lockTimeout time.Duration) ([]Event, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
WITH candidates AS (
|
|
SELECT id
|
|
FROM outbox_event
|
|
WHERE (status = 'pending' AND available_at <= now())
|
|
OR (status = 'processing' AND locked_at < now() - $3::interval)
|
|
ORDER BY available_at, created_at
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT $2
|
|
)
|
|
UPDATE outbox_event event
|
|
SET status = 'processing', locked_at = now(), locked_by = $1,
|
|
attempt_count = event.attempt_count + 1, updated_at = now()
|
|
FROM candidates
|
|
WHERE event.id = candidates.id
|
|
RETURNING event.id::text, event.tenant_id::text, event.aggregate_type,
|
|
event.aggregate_id::text, event.event_type, event.deduplication_key,
|
|
event.payload, event.attempt_count`,
|
|
workerID, batchSize, postgresInterval(lockTimeout),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claim outbox events: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
events := make([]Event, 0, batchSize)
|
|
for rows.Next() {
|
|
var event Event
|
|
if err := rows.Scan(
|
|
&event.ID, &event.TenantID, &event.AggregateType, &event.AggregateID,
|
|
&event.EventType, &event.DeduplicationKey, &event.Payload, &event.AttemptCount,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan outbox event: %w", err)
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate outbox events: %w", err)
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
func (s *Store) MarkCompleted(ctx context.Context, eventID, workerID string) error {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE outbox_event
|
|
SET status = 'completed', completed_at = now(), locked_at = NULL,
|
|
locked_by = NULL, last_error_code = NULL, updated_at = now()
|
|
WHERE id = $1::uuid AND status = 'processing' AND locked_by = $2`, eventID, workerID)
|
|
if err != nil {
|
|
return fmt.Errorf("complete outbox event: %w", err)
|
|
}
|
|
if tag.RowsAffected() != 1 {
|
|
return errors.New("outbox event lock was lost before completion")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) MarkFailed(
|
|
ctx context.Context,
|
|
event Event,
|
|
workerID, errorCode string,
|
|
maxAttempts int,
|
|
nextAttempt time.Time,
|
|
) error {
|
|
status := "pending"
|
|
if event.AttemptCount >= maxAttempts {
|
|
status = "dead"
|
|
}
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE outbox_event
|
|
SET status = $3::varchar, available_at = $4, locked_at = NULL, locked_by = NULL,
|
|
last_error_code = $5, dead_at = CASE WHEN $3::varchar = 'dead' THEN now() ELSE NULL END,
|
|
updated_at = now()
|
|
WHERE id = $1::uuid AND status = 'processing' AND locked_by = $2`,
|
|
event.ID, workerID, status, nextAttempt, errorCode,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("fail outbox event: %w", err)
|
|
}
|
|
if tag.RowsAffected() != 1 {
|
|
return errors.New("outbox event lock was lost before failure update")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hashPayload(payload []byte) string {
|
|
digest := sha256.Sum256(payload)
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func postgresInterval(duration time.Duration) string {
|
|
return fmt.Sprintf("%f seconds", duration.Seconds())
|
|
}
|