Replay Strategy #
In distributed systems, failure isn’t a possibility — it’s a certainty that’s just waiting for its moment. Networks will time out, services will go down, consumers will crash mid-process, and databases will occasionally get overloaded. The relevant question isn’t “will our system fail?” but “when it fails, what happens to the data and processes in flight?” Replay Strategy is the answer to that question: the system’s ability to reprocess failed events, messages, or requests — safely, in a controlled way, and without unwanted side effects. This article covers why replay matters, five types of replay with different purposes, three main challenges to anticipate, and implementation best practices with concrete code.
What Is a Replay Strategy? #
A Replay Strategy is a system design approach for reprocessing events, messages, or requests that failed, didn’t finish, or need to be re-verified, with the guarantee that repeating them won’t corrupt state or cause unwanted side effects.
What separates a Replay Strategy from merely “adding retry”: replay is an architectural decision, not a few lines of for i < 3 { retry() }. It covers how events are stored, how ordering is preserved, how duplication is handled, and how operators control the replay process in production.
Replay can happen at various layers of a system:
| Layer | Replay Example | Common Trigger |
|---|---|---|
| Message Queue | SQS, RabbitMQ retry when a consumer fails | Consumer crash, timeout |
| Event Streaming | Kafka consumer resets offset to an earlier position | Bug fix after deploy |
| HTTP / API | Retrying a timed-out request to a downstream | Network instability |
| Background Job | Rerunning a scheduled job that crashed midway | OOM, pod restart |
| CQRS Command | Replaying a command that failed to execute | Upstream validation error |
| Event Sourcing | Rebuilding entire state from the event log | Schema migration, calculation bug fix |
flowchart TD
A["Event / Request Arrives"] --> B{Processed Successfully?}
B -- Yes --> C[Done ✓]
B -- No --> D{Failure Type?}
D -- Transient\nshort timeout --> E[Immediate Retry]
D -- Needs time\nto recover --> F[Exponential Backoff]
D -- Max retries\nexceeded --> G[Dead Letter Queue]
D -- Bug fix\ndeployed --> H["Manual Replay\nEvent Store"]
D -- Rebuild state --> I["Event Sourcing\nReplay"]
E --> B
F --> B
G --> J[Alert + Review]
J --> HWhy Replay Strategy Is Critical #
There are three fundamental reasons replay isn’t an optional feature in modern systems.
Transient failures are the norm, not the exception. An overloaded downstream service, a briefly dropped database connection, a rate limit from a third-party API — these are all temporary failures that should be recoverable automatically. Without replay, every transient failure risks becoming permanent data loss.
Replay is the foundation of at-least-once delivery. Almost all modern message brokers — Kafka, SQS, RabbitMQ, Pub/Sub — guarantee at-least-once delivery, not exactly-once. That means the system must be ready to receive the same event more than once, and must be able to “redo” a process after a failure. Without a proper replay strategy, the system only works under ideal conditions.
Replay is an extremely valuable operational tool. This is the often-overlooked part: replay isn’t just for error recovery. Engineers use replay to reprocess events after a bug fix is deployed, to rebuild read models after a schema migration, to re-sync data after an incident, and to re-audit suspicious transactions.
sequenceDiagram
participant Prod as Event Producer
participant Queue as Message Queue
participant Consumer
participant DB
Prod->>Queue: Publish event (order.created)
Queue->>Consumer: Deliver event
Consumer->>DB: Process — DB timeout!
DB--xConsumer: Error
Consumer-->>Queue: NACK — no ack
Queue->>Consumer: Redeliver (retry #1)
Consumer->>DB: Process again
DB-->>Consumer: OK
Consumer-->>Queue: ACK ✓
Note over Prod,DB: Without replay: event lost.\nWith replay: event stays safe.The fundamental difference between a mature system and an immature one: a mature system assumes failure will happen and designs replay from the start. An immature system only adds replay after the first incident — and often too late, because the events are already gone.
Types of Replay Strategies #
Not all replay is the same. Each type has its own use case, strengths, and risks. Choosing the right type depends on the failure context and the system’s characteristics.
1. Immediate Retry #
Replay happens right after the failure, without delay. Suitable for failures that are truly transient and short — a momentarily dropped TCP connection, lock contention that releases quickly.
// ANTI-PATTERN: unlimited retry, no delay — bombarding the downstream
func processEvent(event Event) error {
for {
err := handler.Process(event)
if err == nil {
return nil
}
// if the downstream is down, this loops forever and makes things worse
}
}
// CORRECT: retry with a clear maximum
func processWithRetry(event Event, maxAttempts int) error {
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
lastErr = handler.Process(event)
if lastErr == nil {
return nil
}
log.Warnf("attempt %d/%d failed for event %s: %v",
attempt, maxAttempts, event.ID, lastErr)
}
return fmt.Errorf("all %d attempts failed: %w", maxAttempts, lastErr)
}
Immediate retry is only safe for failures that recover within milliseconds. Don’t use immediate retry if the downstream needs more than a few seconds to recover — use exponential backoff.
2. Delayed Retry with Exponential Backoff #
Replay happens after a delay that grows with each failure. This gives the downstream service time to recover without being bombarded by a retry storm.
// ANTI-PATTERN: fixed delay — not adaptive to how long the failure lasts
func processWithFixedRetry(event Event) error {
for i := 0; i < 5; i++ {
if err := handler.Process(event); err == nil {
return nil
}
time.Sleep(1 * time.Second) // always 1 second, no matter how long it's down
}
return errors.New("max retries exceeded")
}
// CORRECT: exponential backoff with jitter prevents thundering herds
func processWithBackoff(ctx context.Context, event Event) error {
baseDelay := 1 * time.Second
maxDelay := 5 * time.Minute
maxAttempts := 8
for attempt := 1; attempt <= maxAttempts; attempt++ {
err := handler.Process(event)
if err == nil {
return nil
}
if attempt == maxAttempts {
return fmt.Errorf("exhausted %d attempts: %w", maxAttempts, err)
}
// Delay: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped)...
delay := baseDelay * time.Duration(1<<uint(attempt-1))
if delay > maxDelay {
delay = maxDelay
}
// Jitter ±25%: so not all consumers retry at exactly the same time
jitter := time.Duration(rand.Int63n(int64(delay / 4)))
if rand.Intn(2) == 0 {
delay += jitter
} else {
delay -= jitter
}
log.Infof("retry attempt %d in %v for event %s", attempt+1, delay, event.ID)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
return nil
}
The delay pattern with backoff:
| Attempt | Base Delay | With Jitter (±25%) |
|---|---|---|
| 1 → 2 | 1 second | 0.75 – 1.25 seconds |
| 2 → 3 | 2 seconds | 1.5 – 2.5 seconds |
| 3 → 4 | 4 seconds | 3 – 5 seconds |
| 4 → 5 | 8 seconds | 6 – 10 seconds |
| 5 → 6 | 16 seconds | 12 – 20 seconds |
| 6 → 7 | 32 seconds | 24 – 40 seconds |
| 7 → 8 | 60 seconds (capped) | 45 – 75 seconds |
3. Dead Letter Queue (DLQ) #
When an event has exceeded its retry limit and still fails, it’s moved to a Dead Letter Queue — a separate queue for problematic events that can’t be processed right now. A DLQ is the safety net that ensures events don’t get lost.
flowchart LR
A[Main Queue] --> B[Consumer]
B -- Fails --> C[Retry 1]
C -- Fails --> D[Retry 2]
D -- Fails --> E["Retry 3\nMax Reached"]
E --> F[Dead Letter Queue]
F --> G["Alert / Monitor"]
G --> H{"Root Cause\nFound?"}
H -- Yes, fix deployed --> I["Manual Replay\nfrom DLQ"]
H -- Needs investigation --> J["Engineer Review\nEvent Details"]
I --> A// ANTI-PATTERN: dropping the event on failure — losing data
func (c *Consumer) handleMessage(msg Message) error {
if err := c.process(msg); err != nil {
log.Errorf("failed to process %s, dropping: %v", msg.ID, err)
return nil // event lost forever
}
return nil
}
// CORRECT: send to the DLQ with full metadata, don't drop the event
func (c *Consumer) handleWithDLQ(ctx context.Context, msg Message) error {
err := c.processWithBackoff(ctx, msg)
if err == nil {
return nil
}
// Enrich with metadata for later debugging
dlqMsg := DLQMessage{
OriginalMessage: msg,
FailureReason: err.Error(),
AttemptCount: msg.ApproximateReceiveCount,
FailedAt: time.Now(),
ConsumerVersion: c.version,
StackTrace: debug.Stack(),
}
if dlqErr := c.dlqQueue.Send(ctx, dlqMsg); dlqErr != nil {
// DLQ send failed too — log CRITICAL, needs immediate attention
log.Criticalf("FAILED to send to DLQ, event %s will be lost: %v",
msg.ID, dlqErr)
return dlqErr
}
log.Warnf("event %s sent to DLQ after %d attempts: %v",
msg.ID, msg.ApproximateReceiveCount, err)
return nil // ack the main message so it doesn't loop in the main queue
}
4. Manual Replay from the Event Store #
Events are stored durably and can be selectively replayed at any time — by timestamp, event ID, topic/partition, or specific business criteria.
// ANTI-PATTERN: replay without throttling — flooding the downstream all at once
func replayAllFromDLQ() {
events := dlq.GetAll() // could be millions of events
for _, e := range events {
process(e) // all processed as fast as possible — the system could go down
}
}
// CORRECT: controlled replay with a rate limiter and progress tracking
func (r *ReplayService) ReplayFromDLQ(ctx context.Context, opts ReplayOptions) error {
log.Infof("starting replay: from=%v to=%v filter=%s rate=%f/s dryRun=%v",
opts.From, opts.To, opts.Filter, opts.RatePerSecond, opts.DryRun)
events, err := r.dlqStore.Query(ctx, opts.From, opts.To, opts.Filter)
if err != nil {
return fmt.Errorf("query DLQ: %w", err)
}
log.Infof("found %d events to replay", len(events))
limiter := rate.NewLimiter(rate.Limit(opts.RatePerSecond), 1)
var succeeded, failed, skipped int
for i, event := range events {
// Can be cancelled at any time via context
if err := limiter.Wait(ctx); err != nil {
log.Infof("replay cancelled at event %d/%d", i, len(events))
return err
}
if opts.DryRun {
log.Infof("[DRY RUN] would replay event %s", event.ID)
skipped++
continue
}
if err := r.processor.Process(ctx, event); err != nil {
log.Errorf("replay failed for event %s: %v", event.ID, err)
failed++
continue
}
succeeded++
// Progress log every 100 events
if succeeded%100 == 0 {
log.Infof("replay progress: %d/%d (success=%d fail=%d skip=%d)",
i+1, len(events), succeeded, failed, skipped)
}
}
log.Infof("replay complete: total=%d succeeded=%d failed=%d skipped=%d",
len(events), succeeded, failed, skipped)
return nil
}
5. Event Sourcing Replay #
In an event sourcing architecture, the event log is the source of truth — application state is reconstructed from the sequence of events from the beginning. Replay here means rebuilding a projection or read model from scratch.
// ANTI-PATTERN: rebuilding directly in the production table without a backup
func rebuildProjection() {
db.Exec("DELETE FROM order_summary") // dangerous! no rollback if it fails
for _, event := range eventLog.GetAll() {
applyEvent(event)
}
}
// CORRECT: rebuild into a shadow table, swap atomically when done
func (p *ProjectionRebuilder) RebuildOrderProjection(ctx context.Context) error {
shadowTable := fmt.Sprintf("order_summary_rebuild_%d", time.Now().Unix())
// Create the shadow table first
if err := p.db.CreateTableLike(shadowTable, "order_summary"); err != nil {
return fmt.Errorf("create shadow table: %w", err)
}
// Stream all events from the start into the shadow table
events, err := p.eventLog.StreamAll(ctx, "orders", StreamOptions{
FromOffset: 0,
BatchSize: 500,
})
if err != nil {
p.db.DropTable(shadowTable) // cleanup
return fmt.Errorf("stream events: %w", err)
}
var count int
for event := range events {
if err := p.applyEventToTable(ctx, shadowTable, event); err != nil {
p.db.DropTable(shadowTable)
return fmt.Errorf("apply event %d: %w", event.Sequence, err)
}
count++
if count%1000 == 0 {
log.Infof("rebuilt %d events...", count)
}
}
// Atomic swap: rename shadow → production
if err := p.db.RenameTable("order_summary", "order_summary_old"); err != nil {
return err
}
if err := p.db.RenameTable(shadowTable, "order_summary"); err != nil {
return err
}
p.db.DropTable("order_summary_old")
log.Infof("projection rebuild complete: %d events applied", count)
return nil
}
Main Challenges in Replay #
These three challenges must be anticipated when designing a replay strategy — ignoring them is a source of bugs that are hard to track down.
Duplicate Processing #
Replay almost always produces duplication — the same event gets processed more than once. This isn’t a bug in the replay itself, but a consequence that must be handled with idempotency.
// ANTI-PATTERN: non-idempotent consumer — replay is a disaster
func (c *Consumer) handlePayment(event PaymentRequestedEvent) error {
// If this event is replayed, the payment is processed twice → double charge
return c.paymentGateway.Charge(event.UserID, event.Amount)
}
// CORRECT: idempotent consumer — check event_id before processing
func (c *Consumer) handlePayment(ctx context.Context, event PaymentRequestedEvent) error {
processed, err := c.processedEvents.Exists(ctx, event.EventID)
if err != nil {
return fmt.Errorf("check processed: %w", err)
}
if processed {
log.Infof("event %s already processed, skipping", event.EventID)
return nil // safe to skip during replay
}
// Process in a transaction: charge + mark as processed — atomic
return c.db.Transaction(func(tx *gorm.DB) error {
if err := c.paymentGateway.Charge(event.UserID, event.Amount); err != nil {
return err
}
return tx.Create(&ProcessedEvent{
EventID: event.EventID,
ProcessedAt: time.Now(),
}).Error
})
}
flowchart TD
A[Event Arrives for Processing] --> B{"event_id exists\nin processed_events?"}
B -- Yes --> C["Skip — return nil ✓\nReplay is safe"]
B -- No --> D["Process the Event\nin a DB Transaction"]
D --> E[Execute business operation]
E --> F["Insert event_id\ninto processed_events"]
F --> G{"Transaction\nCommitted?"}
G -- Yes --> H[ACK to the queue ✓]
G -- No --> I["Rollback\nEvent will be redelivered"]
I --> AEvent Ordering #
Replay can change the processing order of events. This is critical for events that depend on each other — for example OrderCreated must always be processed before OrderShipped.
// ANTI-PATTERN: publishing without a partition key — order isn't guaranteed across partitions
func (p *Producer) publishOrderEvent(event OrderEvent) error {
msg := &sarama.ProducerMessage{
Topic: "order-events",
// Without a key: Kafka distributes to partitions round-robin
// OrderCreated (partition 0) could be processed after OrderShipped (partition 1)
Value: sarama.ByteEncoder(mustMarshal(event)),
}
_, _, err := p.client.SendMessage(msg)
return err
}
// CORRECT: use the entity ID as the partition key
// All events for the same order are guaranteed to land in the same partition
func (p *Producer) publishOrderEvent(event OrderEvent) error {
msg := &sarama.ProducerMessage{
Topic: "order-events",
Key: sarama.StringEncoder(event.OrderID), // ← the ordering key
Value: sarama.ByteEncoder(mustMarshal(event)),
}
_, _, err := p.client.SendMessage(msg)
return err
}
Side Effects That Can’t Be Undone #
This is the trickiest challenge: some side effects aren’t naturally idempotent — sending emails, debiting balances, calling partner webhooks. A poorly designed replay will trigger all of these repeatedly.
// ANTI-PATTERN: side effects directly in the handler without a state check
func (h *OrderHandler) handleOrderConfirmed(event OrderConfirmedEvent) error {
h.updateDatabase(event)
h.emailService.SendConfirmation(event.UserEmail) // resent on every replay!
h.webhookService.Notify(event) // called again on every replay!
return nil
}
// CORRECT: check state first — side effects only fire if the state hasn't changed
func (h *OrderHandler) handleOrderConfirmed(ctx context.Context, event OrderConfirmedEvent) error {
order, err := h.orderRepo.FindByID(event.OrderID)
if err != nil {
return err
}
if order.Status == "CONFIRMED" {
// State is already correct — all side effects already ran before
// Replay is safe: no duplicate emails, no duplicate webhooks
return nil
}
// Update the state first
if err := h.orderRepo.UpdateStatus(event.OrderID, "CONFIRMED"); err != nil {
return err
}
// Side effects only execute once because the state has already changed
h.emailService.SendConfirmation(event.UserEmail)
h.webhookService.Notify(event)
return nil
}
Implementation Best Practices #
Store Events Durably #
Replay is only possible if the events still exist. Many systems don’t store events properly and only realize it during an incident.
Minimum requirements for an event store that supports replay:
| Requirement | Reason |
|---|---|
| Persistent storage, not just in-memory | Events survive restarts |
| Events aren’t deleted after processing | Replay needs the original events |
| Full metadata: ID, timestamp, schema version, source | Debugging and filtering during replay |
| Queryable by timestamp/ID/business criteria | Selective replay, not necessarily all events |
| Clear retention policy | Storage doesn’t grow without bound |
Throttle Mass Replays #
A mass replay without control is the fastest way to take down your own production system.
// ANTI-PATTERN: replaying the entire DLQ at once — flooding the system
func replayAll() {
events := dlq.GetAll() // could be millions of events
for _, e := range events {
process(e) // consumes all downstream resources at once
}
}
// CORRECT: throttle + dry run + kill switch via context
type ReplayConfig struct {
RatePerSecond float64 // how many events per second to process
BatchSize int // how many events per batch
DryRun bool // simulation without real execution
From time.Time // filter from a certain time
To time.Time // filter up to a certain time
Filter string // filter by event type or other criteria
}
func (r *ReplayService) ReplayWithControl(ctx context.Context, cfg ReplayConfig) error {
if cfg.DryRun {
log.Info("[DRY RUN] no events will actually be processed")
}
limiter := rate.NewLimiter(rate.Limit(cfg.RatePerSecond), cfg.BatchSize)
for _, event := range r.fetchEvents(cfg) {
// Kill switch: ctx.Cancel() from outside stops the replay at any time
if err := limiter.Wait(ctx); err != nil {
log.Info("replay stopped by cancellation")
return err
}
if !cfg.DryRun {
r.processor.Process(ctx, event)
}
}
return nil
}
Observability Is Mandatory #
Replay without visibility is a blind operation. You don’t know if it succeeded, how much has been processed, or if something failed again.
// CORRECT: informative logs and metrics for every replay operation
type ReplayMetrics struct {
TotalEvents int
Succeeded int
Failed int
Skipped int // idempotency skip
Duration time.Duration
TriggeredBy string // who triggered it: "alerting-system", "engineer-unis"
ReplayReason string // why: "bug-fix-deploy-v2.3.1", "db-migration"
}
func (r *ReplayService) logCompletion(m ReplayMetrics) {
log.Infof("[REPLAY COMPLETE] reason=%q triggered_by=%q "+
"total=%d succeeded=%d failed=%d skipped=%d duration=%v",
m.ReplayReason, m.TriggeredBy,
m.TotalEvents, m.Succeeded, m.Failed, m.Skipped, m.Duration)
// Send to the metrics system for dashboards and alerting
metrics.Gauge("replay.success_rate",
float64(m.Succeeded)/float64(m.TotalEvents)*100)
metrics.Histogram("replay.duration_seconds", m.Duration.Seconds())
// Alert if the failure rate is too high
failureRate := float64(m.Failed) / float64(m.TotalEvents)
if failureRate > 0.05 { // > 5% failures
alerting.Send(fmt.Sprintf("High replay failure rate: %.1f%%", failureRate*100))
}
}
Which Replay Type to Use When #
flowchart TD
A[Need Replay] --> B{"Short transient\nfailure?"}
B -- Yes, < 1 second --> C["Immediate Retry\nmax 3x"]
B -- No --> D{"Downstream needs\ntime to recover?"}
D -- Yes --> E["Exponential Backoff\nwith jitter"]
E --> F{"Max retries\nexceeded?"}
F -- Yes --> G[Dead Letter Queue]
D -- No --> H{"Bug already\nfixed?"}
H -- Yes, need to\nreprocess historical events --> I["Manual Replay\nfrom Event Store"]
H -- Need to rebuild\nall state --> J["Event Sourcing\nReplay"]
G --> K[Alert + Engineer Review]
K --> I| Situation | Right Replay |
|---|---|
| Transient failure, recovers in milliseconds | Immediate retry (≤ 3x) |
| Downstream needs recovery time (overload, deployment) | Exponential backoff + jitter |
| Event keeps failing after max retries | Dead Letter Queue |
| Bug fix deployed, need to reprocess historical events | Manual replay from the event store |
| Schema migration, rebuilding a read model from scratch | Event Sourcing replay |
| Rate limit from a third-party API | Delayed retry with backoff |
| Major incident, thousands of events in the DLQ | Throttled batch replay with monitoring |
Anti-Patterns to Avoid #
// ✗ Replay without idempotency — produces double processing
for _, e := range dlqEvents {
processPayment(e) // no check whether it was already processed!
}
// ✓ Always check event_id before reprocessing
// ✗ DLQ without metadata — hard to debug during investigation
dlq.Send(Message{Body: originalMsg.Body})
// ✓ Include the failure reason, attempt count, and full timestamp
dlq.Send(DLQMessage{
Body: originalMsg.Body,
FailureReason: err.Error(),
AttemptCount: originalMsg.ReceiveCount,
FailedAt: time.Now(),
StackTrace: debug.Stack(),
})
// ✗ Mass replay without a kill switch — can't stop it in an emergency
func replayAllDLQ() {
for _, e := range getAllEvents() {
process(e) // no way to stop if the system starts struggling
}
}
// ✓ Always use context as the kill switch
func replayAllDLQ(ctx context.Context) {
for _, e := range getAllEvents() {
if ctx.Err() != nil {
log.Info("replay cancelled")
return
}
process(ctx, e)
}
}
// ✗ Events stored only in an in-memory queue — lost on restart
queue := make(chan Event, 1000) // not persistent
// ✓ A durable event store: PostgreSQL, S3, or Kafka with a long retention
// ✗ Side effects without a guard — email sent on every replay
func handleOrderConfirmed(event Event) {
emailService.Send(event.UserEmail) // sent every time it's called
}
// ✓ Check state first, side effects only if the state hasn't changed
Replay Strategy Implementation Checklist #
EVENT STORE DESIGN:
□ Events stored in persistent storage with a clear retention policy
□ Every event has a unique ID, timestamp, schema version, and source
□ Events queryable by time, ID, type, or business criteria
□ Events aren't deleted after processing — only marked
CONSUMER IDEMPOTENCY:
□ All consumers check event_id before processing
□ Business operation and event_id recording in a single DB transaction
□ Consumers still return success if the event was already processed (skip)
DEAD LETTER QUEUE:
□ DLQ configured for all production queues
□ Every event in the DLQ stores: failure reason, attempt count, timestamp
□ Alerts attached when the DLQ receives a new event
□ DLQ review process documented in the runbook
MANUAL REPLAY:
□ Replay tooling available and documented
□ Replay supports dry run mode
□ Replay has a rate limiter and can be cancelled (context cancel)
□ Replay progress and results fully logged
SIDE EFFECTS:
□ Email, webhook, and charge operations protected with state checks
□ Side effects only execute if the state hasn't changed
□ Replay tested in staging before production
OBSERVABILITY:
□ Every replay operation has logs: triggered_by, reason, result
□ Metrics: success rate, failure count, duration
□ Alert if the replay failure rate exceeds the threshold
Summary #
- A Replay Strategy is an architectural decision — not just a retry loop; it covers how to store events, preserve ordering, handle duplication, and control the replay process in production.
- Failure is a certainty — design replay from the start, don’t wait for the first incident.
- Five types of replay — immediate retry (short transient), exponential backoff (needs recovery time), DLQ (permanent failure), manual replay (bug fix/audit), event sourcing replay (state rebuild).
- Idempotency is a hard requirement for replay — without idempotency, replay is a recipe for double processing and corrupted data.
- A DLQ is a mandatory safety net — events must never be lost just because they can’t be processed right now; include full metadata for debugging.
- Exponential backoff + jitter prevents retry storms — don’t retry immediately when the downstream needs recovery time; jitter prevents thundering herds.
- Throttle mass replays — replaying thousands of events without rate limiting can take down production; always provide a kill switch via context.
- Side effects need guards — check state before sending emails, webhooks, or charges; side effects should only happen once.
- Observability is mandatory — log triggered_by, reason, success/failure counts, and duration for every replay operation.