Skip to content

Commit e7405c4

Browse files
feat(replay): Replay-Bypass guard — skip external side-effects on a deliberate replay (ADR-0027) (#9)
Backward-compatible capability upgrade: ReceivedMessage gains an additive Headers map; an optional HeaderPublisher interface (transports MAY implement) carries out-of-band headers; InMemoryTransport implements it. Redrive's new Bypass option stamps a bq-replay-bypass header; the runtime surfaces it to the handler's context; IsReplay + BypassExternalEffects let a handler re-run its idempotent core but skip effects that already fired. No envelope change (GR-1), no breaking Transport change. Concrete broker transports + other SDKs to follow.
1 parent b933ccb commit e7405c4

5 files changed

Lines changed: 208 additions & 6 deletions

File tree

app.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ func (a *App) Drain(ctx context.Context, queue string, max int) (int, error) {
160160
}
161161

162162
func (a *App) dispatch(ctx context.Context, msg *ReceivedMessage) {
163+
if msg.Headers[HeaderReplayBypass] != "" {
164+
ctx = withReplay(ctx)
165+
}
163166
env, _ := Decode([]byte(msg.Body))
164167
urn := env.URN()
165168

redrive.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ type RedriveOptions struct {
1818
// Select, when non-nil, picks which messages to redrive (e.g. by reason or URN).
1919
// Unselected messages are returned to the DLQ unchanged.
2020
Select func(Envelope) bool
21+
// Bypass stamps the bq-replay-bypass transport header on each redriven message, so a
22+
// handler can skip external side-effects that already ran (see [BypassExternalEffects]).
23+
// It takes effect only when the transport is a [HeaderPublisher]; otherwise it is a no-op
24+
// and the per-item Bypassed flag stays false (ADR-0027).
25+
Bypass bool
2126
// Timeout is the per-pop wait passed to the transport (default 1s).
2227
Timeout time.Duration
2328
}
@@ -31,6 +36,7 @@ type RedriveItem struct {
3136
From string // the DLQ it was read from
3237
To string // target queue (the plan, even on a dry run; "" when skipped/undecodable)
3338
Redriven bool // true only when actually re-published to To
39+
Bypassed bool // true when the bq-replay-bypass header was stamped on the redriven message
3440
}
3541

3642
// RedriveResult summarizes a [Redrive] run.
@@ -124,13 +130,15 @@ func Redrive(ctx context.Context, t Transport, dlq string, opts RedriveOptions)
124130
_ = t.Ack(ctx, p.msg)
125131
return res, err
126132
}
127-
if err := t.Publish(ctx, target, string(body)); err != nil {
133+
bypassed, err := publishRedriven(ctx, t, target, string(body), opts.Bypass)
134+
if err != nil {
128135
_ = t.Publish(ctx, dlq, p.msg.Body) // restore on a publish failure
129136
_ = t.Ack(ctx, p.msg)
130137
return res, err
131138
}
132139
_ = t.Ack(ctx, p.msg)
133140
item.Redriven = true
141+
item.Bypassed = bypassed
134142
res.Redriven++
135143
res.Items = append(res.Items, item)
136144
}
@@ -145,3 +153,15 @@ func sourceQueueOf(env Envelope) string {
145153
}
146154
return env.Meta.Queue
147155
}
156+
157+
// publishRedriven re-publishes a reset message to queue. When bypass is set and the transport
158+
// is a [HeaderPublisher], it stamps the bq-replay-bypass header and reports bypassed=true;
159+
// otherwise it publishes plainly and reports bypassed=false.
160+
func publishRedriven(ctx context.Context, t Transport, queue, body string, bypass bool) (bool, error) {
161+
if bypass {
162+
if hp, ok := t.(HeaderPublisher); ok {
163+
return true, hp.PublishWithHeaders(ctx, queue, body, map[string]string{HeaderReplayBypass: "1"})
164+
}
165+
}
166+
return false, t.Publish(ctx, queue, body)
167+
}

replay.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package babelqueue
2+
3+
import "context"
4+
5+
// HeaderReplayBypass is the out-of-band transport header [Redrive] stamps (when its options
6+
// set Bypass) on a replayed message, and that the [App] runtime surfaces to the handler as
7+
// [IsReplay]. It lets a handler skip external side-effects that already ran, with no change to
8+
// the frozen envelope (ADR-0027).
9+
const HeaderReplayBypass = "bq-replay-bypass"
10+
11+
type replayKey struct{}
12+
13+
func withReplay(ctx context.Context) context.Context {
14+
return context.WithValue(ctx, replayKey{}, true)
15+
}
16+
17+
// IsReplay reports whether the message currently being handled was redriven with the
18+
// replay-bypass marker set — i.e. this is a deliberate replay, and external side-effects that
19+
// already happened should be skipped. It reads the marker the runtime put on the context from
20+
// the [HeaderReplayBypass] transport header.
21+
func IsReplay(ctx context.Context) bool {
22+
v, _ := ctx.Value(replayKey{}).(bool)
23+
return v
24+
}
25+
26+
// BypassExternalEffects runs fn unless the current message is a replay (see [IsReplay]), in
27+
// which case fn is skipped and nil is returned. Wrap the external, non-idempotent side of a
28+
// handler — sending an email, charging a card, calling a third party — so a replay re-runs the
29+
// idempotent core but does not re-fire effects that already happened:
30+
//
31+
// app.Handle("urn:babel:orders:created", func(ctx context.Context, env babelqueue.Envelope) error {
32+
// saveOrder(env) // idempotent core — always runs
33+
// return babelqueue.BypassExternalEffects(ctx, func() error {
34+
// return sendConfirmationEmail(env) // external effect — skipped on replay
35+
// })
36+
// })
37+
func BypassExternalEffects(ctx context.Context, fn func() error) error {
38+
if IsReplay(ctx) {
39+
return nil
40+
}
41+
return fn()
42+
}

replay_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package babelqueue
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestIsReplayAndBypassExternalEffects(t *testing.T) {
11+
bg := context.Background()
12+
if IsReplay(bg) {
13+
t.Error("a plain context must not be a replay")
14+
}
15+
rp := withReplay(bg)
16+
if !IsReplay(rp) {
17+
t.Error("a withReplay context must be a replay")
18+
}
19+
20+
ran := false
21+
if err := BypassExternalEffects(bg, func() error { ran = true; return nil }); err != nil {
22+
t.Fatal(err)
23+
}
24+
if !ran {
25+
t.Error("fn must run when not a replay")
26+
}
27+
28+
ran = false
29+
if err := BypassExternalEffects(rp, func() error { ran = true; return errors.New("must not run") }); err != nil {
30+
t.Fatalf("a replay must skip fn and return nil, got %v", err)
31+
}
32+
if ran {
33+
t.Error("fn must be skipped on a replay")
34+
}
35+
}
36+
37+
func TestInMemoryTransportHeadersRoundTrip(t *testing.T) {
38+
tr := NewInMemoryTransport()
39+
if err := tr.PublishWithHeaders(context.Background(), "q", "body", map[string]string{HeaderReplayBypass: "1"}); err != nil {
40+
t.Fatal(err)
41+
}
42+
msg, _ := tr.Pop(context.Background(), "q", 0)
43+
if msg == nil || msg.Headers[HeaderReplayBypass] != "1" {
44+
t.Fatalf("headers not carried through Pop: %+v", msg)
45+
}
46+
_ = tr.Publish(context.Background(), "q", "plain")
47+
m2, _ := tr.Pop(context.Background(), "q", 0)
48+
if m2.Headers[HeaderReplayBypass] != "" {
49+
t.Error("a plain Publish must carry no replay header")
50+
}
51+
}
52+
53+
func TestRedriveBypassStampsHeaderAndConsumeSkipsEffects(t *testing.T) {
54+
tr := NewInMemoryTransport()
55+
deadLettered(t, tr, "orders.dlq", "urn:babel:orders:created", "orders", map[string]any{"order_id": 1})
56+
57+
res, err := Redrive(context.Background(), tr, "orders.dlq", RedriveOptions{Bypass: true})
58+
if err != nil {
59+
t.Fatal(err)
60+
}
61+
if res.Redriven != 1 || len(res.Items) != 1 || !res.Items[0].Bypassed {
62+
t.Fatalf("expected one bypassed redrive: %+v", res)
63+
}
64+
65+
msg, _ := tr.Pop(context.Background(), "orders", 0)
66+
if msg == nil || msg.Headers[HeaderReplayBypass] != "1" {
67+
t.Fatalf("redriven message is missing the bypass header: %+v", msg)
68+
}
69+
70+
emailed := false
71+
app := NewApp(tr)
72+
app.Handle("urn:babel:orders:created", func(ctx context.Context, _ Envelope) error {
73+
if !IsReplay(ctx) {
74+
t.Error("the handler should see this delivery as a replay")
75+
}
76+
return BypassExternalEffects(ctx, func() error { emailed = true; return nil })
77+
})
78+
app.dispatch(context.Background(), msg)
79+
if emailed {
80+
t.Error("the external side-effect must be skipped on a bypassed replay")
81+
}
82+
}
83+
84+
func TestRedriveBypassWithoutHeaderSupportFallsBack(t *testing.T) {
85+
base := NewInMemoryTransport()
86+
deadLettered(t, base, "dlq", "urn:babel:orders:created", "orders", nil)
87+
tr := plainTransport{inner: base}
88+
89+
res, err := Redrive(context.Background(), tr, "dlq", RedriveOptions{Bypass: true})
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
if res.Redriven != 1 || res.Items[0].Bypassed {
94+
t.Fatalf("Bypass must be a no-op when the transport is not a HeaderPublisher: %+v", res)
95+
}
96+
}
97+
98+
// plainTransport wraps InMemoryTransport but exposes only the base Transport methods, so it is
99+
// deliberately NOT a HeaderPublisher.
100+
type plainTransport struct{ inner *InMemoryTransport }
101+
102+
func (p plainTransport) Publish(ctx context.Context, q, b string) error {
103+
return p.inner.Publish(ctx, q, b)
104+
}
105+
106+
func (p plainTransport) Pop(ctx context.Context, q string, d time.Duration) (*ReceivedMessage, error) {
107+
return p.inner.Pop(ctx, q, d)
108+
}
109+
110+
func (p plainTransport) Ack(ctx context.Context, m *ReceivedMessage) error {
111+
return p.inner.Ack(ctx, m)
112+
}

transport.go

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ type ReceivedMessage struct {
1212
Body string
1313
Queue string
1414
Handle any
15+
// Headers are out-of-band transport headers a [HeaderPublisher] carried with the
16+
// message (e.g. the bq-replay-bypass marker). Nil for transports that don't surface
17+
// them; reads are nil-safe.
18+
Headers map[string]string
1519
}
1620

1721
// Transport is the minimal broker contract the [App] runtime talks to: publish a
@@ -29,24 +33,45 @@ type Transport interface {
2933
Ack(ctx context.Context, msg *ReceivedMessage) error
3034
}
3135

36+
// HeaderPublisher is an optional [Transport] capability: publish a body together with
37+
// out-of-band transport headers (e.g. the replay-bypass marker), for brokers that carry
38+
// per-message metadata. A transport that does not implement it simply does not propagate
39+
// headers — callers fall back to plain Publish (ADR-0027).
40+
type HeaderPublisher interface {
41+
PublishWithHeaders(ctx context.Context, queue, body string, headers map[string]string) error
42+
}
43+
3244
// InMemoryTransport is an in-process [Transport] for tests and broker-free local
3345
// runs. It is safe for concurrent use. Pop returns immediately (it does not block
3446
// for the timeout) — when the queue is empty it returns (nil, nil).
47+
type inMemoryMessage struct {
48+
body string
49+
headers map[string]string
50+
}
51+
3552
type InMemoryTransport struct {
3653
mu sync.Mutex
37-
queues map[string][]string
54+
queues map[string][]inMemoryMessage
3855
}
3956

4057
// NewInMemoryTransport returns an empty in-process transport.
4158
func NewInMemoryTransport() *InMemoryTransport {
42-
return &InMemoryTransport{queues: make(map[string][]string)}
59+
return &InMemoryTransport{queues: make(map[string][]inMemoryMessage)}
4360
}
4461

4562
// Publish appends body to the in-memory queue.
4663
func (t *InMemoryTransport) Publish(_ context.Context, queue, body string) error {
4764
t.mu.Lock()
4865
defer t.mu.Unlock()
49-
t.queues[queue] = append(t.queues[queue], body)
66+
t.queues[queue] = append(t.queues[queue], inMemoryMessage{body: body})
67+
return nil
68+
}
69+
70+
// PublishWithHeaders appends body plus out-of-band headers ([HeaderPublisher]).
71+
func (t *InMemoryTransport) PublishWithHeaders(_ context.Context, queue, body string, headers map[string]string) error {
72+
t.mu.Lock()
73+
defer t.mu.Unlock()
74+
t.queues[queue] = append(t.queues[queue], inMemoryMessage{body: body, headers: headers})
5075
return nil
5176
}
5277

@@ -58,9 +83,9 @@ func (t *InMemoryTransport) Pop(_ context.Context, queue string, _ time.Duration
5883
if len(q) == 0 {
5984
return nil, nil
6085
}
61-
body := q[0]
86+
m := q[0]
6287
t.queues[queue] = q[1:]
63-
return &ReceivedMessage{Body: body, Queue: queue}, nil
88+
return &ReceivedMessage{Body: m.body, Queue: queue, Headers: m.headers}, nil
6489
}
6590

6691
// Ack is a no-op: Pop already removed the message.

0 commit comments

Comments
 (0)