|
| 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