Files
story/internal/outbox/store.go
T

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())
}