Skip to content

Commit d509317

Browse files
committed
feat(azureservicebus): Azure Service Bus transport (/azureservicebus module) +
conformance runner
1 parent b40b885 commit d509317

6 files changed

Lines changed: 669 additions & 0 deletions

File tree

azureservicebus/azureservicebus.go

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// Package azureservicebus is an Azure Service Bus-backed [babelqueue.Transport] for
2+
// the BabelQueue Go runtime. Producing sends the canonical envelope as the message
3+
// body and projects the contract envelope fields onto native Service Bus fields
4+
// (Subject = URN, CorrelationID = trace_id, MessageID = meta.id, plus the bq-
5+
// application properties) — so a .NET/Java/... peer can route on Subject and correlate
6+
// on CorrelationID without parsing the body. Consuming uses the PeekLock reservation
7+
// model (ReceiveMessages -> process -> CompleteMessage); the broker's native
8+
// DeliveryCount is reconciled onto the envelope as attempts = max(body, DeliveryCount−1)
9+
// so a crash-redelivered message reflects its true delivery count without lowering the
10+
// runtime's own counter.
11+
//
12+
// tr, _ := azureservicebus.New(azureservicebus.WithConnectionString(cs))
13+
// app := babelqueue.NewApp(tr, babelqueue.WithDefaultQueue("orders"))
14+
//
15+
// This binding implements §4 of the BabelQueue broker-bindings contract. The envelope
16+
// is unchanged (schema_version stays 1); Azure Service Bus is purely additive.
17+
//
18+
// Full spec: https://babelqueue.com
19+
package azureservicebus
20+
21+
import (
22+
"context"
23+
"errors"
24+
"sync"
25+
"time"
26+
27+
"github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus"
28+
babelqueue "github.com/babelqueue/babelqueue-go"
29+
)
30+
31+
// Sender is the subset of *azservicebus.Sender the transport uses; the concrete
32+
// sender satisfies it, and a fake satisfies it in tests.
33+
type Sender interface {
34+
SendMessage(ctx context.Context, message *azservicebus.Message, options *azservicebus.SendMessageOptions) error
35+
}
36+
37+
// Receiver is the subset of *azservicebus.Receiver the transport uses.
38+
type Receiver interface {
39+
ReceiveMessages(ctx context.Context, maxMessages int, options *azservicebus.ReceiveMessagesOptions) ([]*azservicebus.ReceivedMessage, error)
40+
CompleteMessage(ctx context.Context, message *azservicebus.ReceivedMessage, options *azservicebus.CompleteMessageOptions) error
41+
}
42+
43+
// Client creates per-entity senders and receivers. A *azservicebus.Client is wrapped
44+
// to satisfy it (see [WithAzureClient]); a fake satisfies it in tests.
45+
type Client interface {
46+
NewSender(queueOrTopic string) (Sender, error)
47+
NewReceiver(queue string) (Receiver, error)
48+
}
49+
50+
// azureClient adapts a real *azservicebus.Client to the [Client] seam.
51+
type azureClient struct{ c *azservicebus.Client }
52+
53+
func (a azureClient) NewSender(queueOrTopic string) (Sender, error) {
54+
s, err := a.c.NewSender(queueOrTopic, nil)
55+
if err != nil {
56+
return nil, err
57+
}
58+
return s, nil
59+
}
60+
61+
func (a azureClient) NewReceiver(queue string) (Receiver, error) {
62+
r, err := a.c.NewReceiverForQueue(queue, nil)
63+
if err != nil {
64+
return nil, err
65+
}
66+
return r, nil
67+
}
68+
69+
// Transport implements [babelqueue.Transport] over Azure Service Bus. It is safe for
70+
// concurrent use; the per-queue sender/receiver caches are guarded by a mutex.
71+
type Transport struct {
72+
client Client
73+
connStr string
74+
maxWait time.Duration
75+
76+
mu sync.Mutex
77+
senders map[string]Sender
78+
receivers map[string]Receiver
79+
}
80+
81+
// Option customizes [New].
82+
type Option func(*Transport)
83+
84+
// WithConnectionString builds the client from an ASB connection string
85+
// (Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=...).
86+
func WithConnectionString(cs string) Option { return func(t *Transport) { t.connStr = cs } }
87+
88+
// WithAzureClient wraps a preconfigured *azservicebus.Client (e.g. built with a
89+
// namespace + azidentity TokenCredential), bypassing connection-string parsing.
90+
func WithAzureClient(c *azservicebus.Client) Option {
91+
return func(t *Transport) { t.client = azureClient{c} }
92+
}
93+
94+
// WithClient injects a [Client] (a fake in tests), bypassing all Azure wiring.
95+
func WithClient(c Client) Option { return func(t *Transport) { t.client = c } }
96+
97+
// WithMaxWaitTime caps how long a single Pop blocks waiting for a message. The
98+
// runtime's per-iteration poll timeout still bounds it; this caps the upper limit.
99+
func WithMaxWaitTime(d time.Duration) Option { return func(t *Transport) { t.maxWait = d } }
100+
101+
// New builds a transport. Provide a connection string ([WithConnectionString]), a
102+
// preconfigured Azure client ([WithAzureClient]), or an injected [Client]
103+
// ([WithClient]).
104+
func New(opts ...Option) (*Transport, error) {
105+
t := newTransport(opts...)
106+
if t.client != nil {
107+
return t, nil
108+
}
109+
if t.connStr != "" {
110+
c, err := azservicebus.NewClientFromConnectionString(t.connStr, nil)
111+
if err != nil {
112+
return nil, err
113+
}
114+
t.client = azureClient{c}
115+
return t, nil
116+
}
117+
return nil, errors.New("azureservicebus: provide WithConnectionString, WithAzureClient, or WithClient")
118+
}
119+
120+
func newTransport(opts ...Option) *Transport {
121+
t := &Transport{senders: make(map[string]Sender), receivers: make(map[string]Receiver)}
122+
for _, o := range opts {
123+
o(t)
124+
}
125+
return t
126+
}
127+
128+
func (t *Transport) sender(queue string) (Sender, error) {
129+
t.mu.Lock()
130+
defer t.mu.Unlock()
131+
if s, ok := t.senders[queue]; ok {
132+
return s, nil
133+
}
134+
s, err := t.client.NewSender(queue)
135+
if err != nil {
136+
return nil, err
137+
}
138+
t.senders[queue] = s
139+
return s, nil
140+
}
141+
142+
func (t *Transport) receiver(queue string) (Receiver, error) {
143+
t.mu.Lock()
144+
defer t.mu.Unlock()
145+
if r, ok := t.receivers[queue]; ok {
146+
return r, nil
147+
}
148+
r, err := t.client.NewReceiver(queue)
149+
if err != nil {
150+
return nil, err
151+
}
152+
t.receivers[queue] = r
153+
return r, nil
154+
}
155+
156+
// Publish sends body to queue with the §4 native projection.
157+
func (t *Transport) Publish(ctx context.Context, queue, body string) error {
158+
s, err := t.sender(queue)
159+
if err != nil {
160+
return err
161+
}
162+
return s.SendMessage(ctx, message(body), nil)
163+
}
164+
165+
// Pop reserves the next message (PeekLock), bounded by timeout (and WithMaxWaitTime).
166+
// It reconciles attempts to max(body.attempts, DeliveryCount − 1). Returns (nil, nil)
167+
// when no message arrives.
168+
func (t *Transport) Pop(ctx context.Context, queue string, timeout time.Duration) (*babelqueue.ReceivedMessage, error) {
169+
r, err := t.receiver(queue)
170+
if err != nil {
171+
return nil, err
172+
}
173+
wait := timeout
174+
if t.maxWait > 0 && (wait <= 0 || t.maxWait < wait) {
175+
wait = t.maxWait
176+
}
177+
rctx := ctx
178+
if wait > 0 {
179+
var cancel context.CancelFunc
180+
rctx, cancel = context.WithTimeout(ctx, wait)
181+
defer cancel()
182+
}
183+
msgs, err := r.ReceiveMessages(rctx, 1, nil)
184+
if err != nil {
185+
if errors.Is(err, context.DeadlineExceeded) {
186+
return nil, nil
187+
}
188+
return nil, err
189+
}
190+
if len(msgs) == 0 {
191+
return nil, nil
192+
}
193+
m := msgs[0]
194+
body := reconcileAttempts(string(m.Body), m.DeliveryCount)
195+
return &babelqueue.ReceivedMessage{Body: body, Queue: queue, Handle: m}, nil
196+
}
197+
198+
// Ack completes the reserved message (removing it from the entity).
199+
func (t *Transport) Ack(ctx context.Context, msg *babelqueue.ReceivedMessage) error {
200+
rm, ok := msg.Handle.(*azservicebus.ReceivedMessage)
201+
if !ok || rm == nil {
202+
return nil
203+
}
204+
r, err := t.receiver(msg.Queue)
205+
if err != nil {
206+
return err
207+
}
208+
return r.CompleteMessage(ctx, rm, nil)
209+
}
210+
211+
// message projects the envelope's contract fields onto a native Service Bus message.
212+
// They are a redundant, routable view of the body — the body stays authoritative.
213+
func message(body string) *azservicebus.Message {
214+
m := &azservicebus.Message{Body: []byte(body), ContentType: strPtr("application/json")}
215+
env, err := babelqueue.Decode([]byte(body))
216+
if err != nil {
217+
return m
218+
}
219+
if env.Job != "" {
220+
m.Subject = strPtr(env.Job)
221+
}
222+
if env.TraceID != "" {
223+
m.CorrelationID = strPtr(env.TraceID)
224+
}
225+
if env.Meta.ID != "" {
226+
m.MessageID = strPtr(env.Meta.ID)
227+
}
228+
props := make(map[string]any, 3)
229+
if env.Meta.SchemaVersion != 0 {
230+
props["bq-schema-version"] = env.Meta.SchemaVersion
231+
}
232+
if env.Meta.Lang != "" {
233+
props["bq-source-lang"] = env.Meta.Lang
234+
}
235+
if env.Meta.CreatedAt != 0 {
236+
props["bq-created-at"] = env.Meta.CreatedAt
237+
}
238+
if len(props) > 0 {
239+
m.ApplicationProperties = props
240+
}
241+
return m
242+
}
243+
244+
// reconcileAttempts sets the envelope's top-level attempts to
245+
// max(current, DeliveryCount − 1) so a first delivery reads 0 and a natively
246+
// redelivered message reflects its true count, without ever lowering a runtime-
247+
// incremented counter (the App manages retries by republishing with attempts+1).
248+
func reconcileAttempts(body string, deliveryCount uint32) string {
249+
if deliveryCount <= 1 {
250+
return body
251+
}
252+
native := int(deliveryCount) - 1
253+
env, err := babelqueue.Decode([]byte(body))
254+
if err != nil || native <= env.Attempts {
255+
return body
256+
}
257+
env.Attempts = native
258+
if b, err := env.Encode(); err == nil {
259+
return string(b)
260+
}
261+
return body
262+
}
263+
264+
func strPtr(s string) *string { return &s }
265+
266+
var _ babelqueue.Transport = (*Transport)(nil)

0 commit comments

Comments
 (0)