Ctrl K
ComunidadGuías…

internal/kafka/dlq/replay.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Package dlq replays dead-letter messages to their origin topics.
package dlq

import (
	"context"
	"fmt"
	"strconv"
	"time"

	"github.com/Shopify/sarama"
	"github.com/ThreeDotsLabs/watermill"
	watermillKafka "github.com/ThreeDotsLabs/watermill-kafka/v2/pkg/kafka"
	"github.com/ThreeDotsLabs/watermill/message"
	"github.com/ThreeDotsLabs/watermill/message/router/middleware"
	"github.com/flexprice/flexprice/internal/config"
	"github.com/flexprice/flexprice/internal/kafka"
	"github.com/flexprice/flexprice/internal/logger"
)

// ReplayCountKey stores the number of previous replay attempts.
const ReplayCountKey = "replay_count"

// DefaultGroup tracks replay progress independently of application consumers.
const DefaultGroup = "dlq-replay-tool"

// Options controls a single replay run.
type Options struct {
	SourceTopic    string
	TargetOverride string
	Group          string
	SinceMs        int64
	FromStart      bool
	Max            int
	MaxReplays     int
	DryRun         bool
	idleTimeout    time.Duration
}

// Summary is the outcome of a replay run.
type Summary struct {
	Scanned     int
	Replayed    int
	Skipped     int
	Quarantined int
	ByTarget    map[string]int
	ByReason    map[string]int
}

// Replay drains each partition to the end offset captured when that partition starts.
// It returns partial progress on failure; uncommitted messages may be replayed again.
func Replay(ctx context.Context, cfg *config.Configuration, log *logger.Logger, opts Options) (*Summary, error) {
	if opts.SourceTopic == "" {
		return nil, fmt.Errorf("dlq replay: SourceTopic is required")
	}
	if opts.Group == "" {
		opts.Group = DefaultGroup
	}
	if opts.MaxReplays < 1 {
		opts.MaxReplays = 3
	}
	if opts.idleTimeout == 0 {
		opts.idleTimeout = 15 * time.Second
	}

	sum := &Summary{ByTarget: map[string]int{}, ByReason: map[string]int{}}

	// Keep autocommit enabled: Sarama needs its background loop to close offset managers.
	// Only handled messages are marked, so autocommit cannot skip unprocessed messages.
	saramaCfg, err := kafka.GetSaramaConfig(&cfg.Kafka)
	if err != nil {
		return sum, fmt.Errorf("build sarama config: %w", err)
	}
	// Closing a partition offset manager waits for the next autocommit tick; the shared
	// 5s interval made every partition cost 5s even when there was nothing to replay.
	saramaCfg.Consumer.Offsets.AutoCommit.Interval = 200 * time.Millisecond

	client, err := sarama.NewClient(cfg.Kafka.Brokers, saramaCfg)
	if err != nil {
		return sum, fmt.Errorf("connect kafka client: %w", err)
	}
	defer client.Close()

	consumer, err := sarama.NewConsumerFromClient(client)
	if err != nil {
		return sum, fmt.Errorf("create consumer: %w", err)
	}
	defer consumer.Close()

	offsetMgr, err := sarama.NewOffsetManagerFromClient(opts.Group, client)
	if err != nil {
		return sum, fmt.Errorf("create offset manager (group=%s): %w", opts.Group, err)
	}
	defer offsetMgr.Close()

	var pub message.Publisher
	if !opts.DryRun {
		pub, err = newPublisher(cfg)
		if err != nil {
			return sum, fmt.Errorf("create publisher: %w", err)
		}
		defer pub.Close()
	}

	marshaler := watermillKafka.DefaultMarshaler{}

	partitions, err := client.Partitions(opts.SourceTopic)
	if err != nil {
		return sum, fmt.Errorf("list partitions for %s: %w", opts.SourceTopic, err)
	}

	log.Info(ctx, "dlq replay: starting",
		"source", opts.SourceTopic, "group", opts.Group,
		"target_override", opts.TargetOverride, "partitions", len(partitions),
		"dry_run", opts.DryRun, "from_start", opts.FromStart,
		"since_ms", opts.SinceMs, "max", opts.Max, "max_replays", opts.MaxReplays)

	for _, p := range partitions {
		if opts.Max > 0 && sum.Replayed >= opts.Max {
			break
		}
		if err := replayPartition(ctx, client, consumer, offsetMgr, marshaler, pub, log, opts, p, sum); err != nil {
			return sum, fmt.Errorf("partition %d: %w", p, err)
		}
	}

	log.Info(ctx, "dlq replay: done",
		"scanned", sum.Scanned, "replayed", sum.Replayed,
		"skipped", sum.Skipped, "quarantined", sum.Quarantined,
		"by_target", sum.ByTarget)
	return sum, nil
}

func replayPartition(
	ctx context.Context,
	client sarama.Client,
	consumer sarama.Consumer,
	offsetMgr sarama.OffsetManager,
	marshaler watermillKafka.DefaultMarshaler,
	pub message.Publisher,
	log *logger.Logger,
	opts Options,
	partition int32,
	sum *Summary,
) error {
	hwm, err := client.GetOffset(opts.SourceTopic, partition, sarama.OffsetNewest)
	if err != nil {
		return fmt.Errorf("get newest offset: %w", err)
	}
	oldest, err := client.GetOffset(opts.SourceTopic, partition, sarama.OffsetOldest)
	if err != nil {
		return fmt.Errorf("get oldest offset: %w", err)
	}

	pom, err := offsetMgr.ManagePartition(opts.SourceTopic, partition)
	if err != nil {
		return fmt.Errorf("manage partition offsets: %w", err)
	}
	defer pom.Close()

	start, hasWork, err := resolveStart(client, pom, opts, partition, oldest, hwm)
	if err != nil || !hasWork {
		return err
	}

	runErr := drainPartition(ctx, consumer, pom, marshaler, pub, log, opts, partition, start, hwm, sum)

	// Commit handled messages even after a partial failure; leave the failed offset unmarked.
	if !opts.DryRun {
		offsetMgr.Commit()
	}
	return runErr
}

func resolveStart(
	client sarama.Client,
	pom sarama.PartitionOffsetManager,
	opts Options,
	partition int32,
	oldest, hwm int64,
) (start int64, hasWork bool, err error) {
	switch {
	case opts.SinceMs > 0:
		start, err = client.GetOffset(opts.SourceTopic, partition, opts.SinceMs)
		if err != nil {
			return 0, false, fmt.Errorf("get offset for since_ms: %w", err)
		}
		if start == -1 {
			return 0, false, nil
		}
	case opts.FromStart:
		start = oldest
	default:
		start, _ = pom.NextOffset()
		if start < 0 {
			start = oldest
		}
	}

	if start < oldest {
		start = oldest
	}
	if start >= hwm {
		return 0, false, nil
	}
	return start, true, nil
}

func drainPartition(
	ctx context.Context,
	consumer sarama.Consumer,
	pom sarama.PartitionOffsetManager,
	marshaler watermillKafka.DefaultMarshaler,
	pub message.Publisher,
	log *logger.Logger,
	opts Options,
	partition int32,
	start, hwm int64,
	sum *Summary,
) error {
	pc, err := consumer.ConsumePartition(opts.SourceTopic, partition, start)
	if err != nil {
		return fmt.Errorf("consume partition: %w", err)
	}
	defer pc.Close()

	for {
		if opts.Max > 0 && sum.Replayed >= opts.Max {
			return nil
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(opts.idleTimeout):
			log.Info(ctx, "dlq replay: idle timeout before end offset, stopping partition",
				"partition", partition, "source", opts.SourceTopic)
			return nil
		case km := <-pc.Messages():
			if km == nil || km.Offset >= hwm {
				return nil
			}
			if err := handleMessage(ctx, marshaler, pub, log, opts, km, sum); err != nil {
				return err
			}
			if !opts.DryRun {
				pom.MarkOffset(km.Offset+1, "")
			}
			if km.Offset >= hwm-1 {
				return nil
			}
		}
	}
}

func handleMessage(
	ctx context.Context,
	marshaler watermillKafka.DefaultMarshaler,
	pub message.Publisher,
	log *logger.Logger,
	opts Options,
	km *sarama.ConsumerMessage,
	sum *Summary,
) error {
	sum.Scanned++

	msg, err := marshaler.Unmarshal(km)
	if err != nil {
		log.Error(ctx, "dlq replay: unmarshal failed, skipping",
			"error", err, "offset", km.Offset, "partition", km.Partition)
		sum.Skipped++
		return nil
	}

	reason := msg.Metadata.Get(middleware.ReasonForPoisonedKey)
	sum.ByReason[truncate(reason, 80)]++

	target := opts.TargetOverride
	if target == "" {
		target = msg.Metadata.Get(middleware.PoisonedTopicKey)
	}
	if target == "" {
		log.Info(ctx, "dlq replay: message has no topic_poisoned and no override, skipping",
			"uuid", msg.UUID, "offset", km.Offset)
		sum.Skipped++
		return nil
	}

	replayCount := parseCount(msg.Metadata.Get(ReplayCountKey))
	if replayCount >= opts.MaxReplays {
		log.Info(ctx, "dlq replay: message hit max replays, quarantining",
			"uuid", msg.UUID, "replay_count", replayCount, "max_replays", opts.MaxReplays)
		sum.Quarantined++
		return nil
	}

	if !opts.DryRun {
		for _, key := range []string{
			middleware.ReasonForPoisonedKey,
			middleware.PoisonedTopicKey,
			middleware.PoisonedHandlerKey,
			middleware.PoisonedSubscriberKey,
		} {
			delete(msg.Metadata, key)
		}
		msg.Metadata.Set(ReplayCountKey, strconv.Itoa(replayCount+1))
		if err := pub.Publish(target, msg); err != nil {
			return fmt.Errorf("publish to %s: %w", target, err)
		}
	}
	sum.ByTarget[target]++
	sum.Replayed++
	return nil
}

func newPublisher(cfg *config.Configuration) (message.Publisher, error) {
	saramaCfg, err := kafka.GetSaramaConfig(&cfg.Kafka)
	if err != nil {
		return nil, fmt.Errorf("build sarama config: %w", err)
	}
	saramaCfg.Producer.Return.Successes = true
	saramaCfg.Producer.Return.Errors = true
	return watermillKafka.NewPublisher(
		watermillKafka.PublisherConfig{
			Brokers:               cfg.Kafka.Brokers,
			Marshaler:             watermillKafka.DefaultMarshaler{},
			OverwriteSaramaConfig: saramaCfg,
		},
		watermill.NewStdLogger(false, false),
	)
}

func parseCount(s string) int {
	n, err := strconv.Atoi(s)
	if err != nil || n < 0 {
		return 0
	}
	return n
}

func truncate(s string, n int) string {
	if s == "" {
		return "(no reason)"
	}
	if len(s) <= n {
		return s
	}
	return s[:n]
}