mirror of
https://github.com/hanzoai/amqp.git
synced 2026-10-30 14:57:07 +00:00
394 lines
11 KiB
Go
394 lines
11 KiB
Go
// Package protocol speaks AMQP 0-9-1 on a socket and JetStream underneath, so
|
|
// a client that only knows how to talk to RabbitMQ shares the platform bus
|
|
// with everything else on it.
|
|
//
|
|
// What it implements is a whole path, not a sample of one: connect, declare an
|
|
// exchange and a queue, bind, publish with properties, consume, ack, nack,
|
|
// reject, get, cancel, qos, publisher confirms, and the mandatory return. What
|
|
// it does NOT implement — headers exchanges, transactions, byte-counted qos —
|
|
// answers with the AMQP error the spec defines for it, on the channel, naming
|
|
// the method. A client learns it asked for something absent; it never watches a
|
|
// message vanish into a broker that said ok.
|
|
//
|
|
// See route.go for the whole map onto JetStream. It is small on purpose: a
|
|
// routing key and a subject are the same idea, so most of AMQP needs
|
|
// translation rather than emulation.
|
|
package protocol
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
|
|
luxlog "github.com/luxfi/log"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
// Config is what a Broker needs to know.
|
|
type Config struct {
|
|
// Addr is the TCP address the AMQP listener binds. Empty means :5672.
|
|
Addr string
|
|
|
|
// PubSubURL is the bus. Empty means the local default.
|
|
PubSubURL string
|
|
|
|
// PubSubCreds is an optional NATS credentials file.
|
|
PubSubCreds string
|
|
|
|
// Product and Version identify this server in connection.start's
|
|
// server-properties, which is what a client logs and a management tool
|
|
// displays. Empty means "hanzo-amqp" and "dev".
|
|
Product, Version string
|
|
|
|
// Heartbeat is the interval offered in connection.tune. Zero means 60s;
|
|
// a client is free to name a smaller one or turn it off.
|
|
Heartbeat time.Duration
|
|
|
|
// FrameMax is the largest frame payload accepted, and the ceiling on what
|
|
// a client may negotiate. Zero means 128 KiB.
|
|
FrameMax uint32
|
|
}
|
|
|
|
// Broker is the gateway: one listener speaking AMQP, one connection to the bus.
|
|
type Broker struct {
|
|
cfg Config
|
|
nc *nats.Conn
|
|
js jetstream.JetStream
|
|
top *Topology
|
|
|
|
ready chan struct{}
|
|
quit chan struct{}
|
|
stop sync.Once
|
|
wg sync.WaitGroup
|
|
|
|
mu sync.Mutex
|
|
ln net.Listener
|
|
live map[*session]struct{}
|
|
cons map[string]int
|
|
excl map[string]*session
|
|
body int // the bus's own max payload; the ceiling on a content body
|
|
}
|
|
|
|
// Defaults for what Config leaves empty.
|
|
const (
|
|
DefaultAddr = ":5672"
|
|
DefaultFrameMax = 128 << 10
|
|
DefaultHeartbeat = 60 * time.Second
|
|
DefaultProduct = "hanzo-amqp"
|
|
)
|
|
|
|
// NewBroker builds a Broker. It opens nothing; Connect and Accept, or Serve, do that.
|
|
func NewBroker(cfg Config) *Broker {
|
|
if cfg.Addr == "" {
|
|
cfg.Addr = DefaultAddr
|
|
}
|
|
if cfg.FrameMax < 8192 {
|
|
cfg.FrameMax = DefaultFrameMax
|
|
}
|
|
if cfg.Heartbeat <= 0 {
|
|
cfg.Heartbeat = DefaultHeartbeat
|
|
}
|
|
if cfg.Product == "" {
|
|
cfg.Product = DefaultProduct
|
|
}
|
|
if cfg.Version == "" {
|
|
cfg.Version = "dev"
|
|
}
|
|
return &Broker{
|
|
cfg: cfg,
|
|
ready: make(chan struct{}),
|
|
quit: make(chan struct{}),
|
|
live: map[*session]struct{}{},
|
|
cons: map[string]int{},
|
|
excl: map[string]*session{},
|
|
}
|
|
}
|
|
|
|
// Serve is Connect, a TCP listener on Config.Addr, and Accept on it. It blocks.
|
|
//
|
|
// Everything that can fail has failed by the time Ready closes, so a caller
|
|
// that must fail closed waits on Ready against a deadline rather than guessing
|
|
// with a sleep.
|
|
func (b *Broker) Serve() error {
|
|
if err := b.Connect(); err != nil {
|
|
return err
|
|
}
|
|
ln, err := net.Listen("tcp", b.cfg.Addr)
|
|
if err != nil {
|
|
b.top.Close()
|
|
b.nc.Close()
|
|
return fmt.Errorf("listen %s: %w", b.cfg.Addr, err)
|
|
}
|
|
return b.Accept(ln)
|
|
}
|
|
|
|
// Connect dials the bus and declares the stream and the topology bucket. It
|
|
// binds nothing, so a caller that holds the listener itself (a host that owns
|
|
// the port and hands over each connection) fails closed on the bus before any
|
|
// client can reach the gateway. Call it once, before Accept.
|
|
func (b *Broker) Connect() error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
var opts []nats.Option
|
|
if b.cfg.PubSubCreds != "" {
|
|
opts = append(opts, nats.UserCredentials(b.cfg.PubSubCreds))
|
|
}
|
|
nc, err := nats.Connect(b.cfg.PubSubURL, opts...)
|
|
if err != nil {
|
|
return fmt.Errorf("connect %s: %w", b.cfg.PubSubURL, err)
|
|
}
|
|
b.nc = nc
|
|
// The bus decides how large a message may be, so the gateway does not name
|
|
// a second number. A content header claiming more than this is refused on
|
|
// the header — before a byte of it is read — which is what keeps a lying
|
|
// body size from being an allocation.
|
|
b.body = int(nc.MaxPayload())
|
|
|
|
js, err := jetstream.New(nc)
|
|
if err != nil {
|
|
nc.Close()
|
|
return fmt.Errorf("jetstream: %w", err)
|
|
}
|
|
b.js = js
|
|
|
|
if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
|
|
Name: Stream,
|
|
Description: "AMQP 0-9-1 gateway traffic",
|
|
Subjects: []string{Root + ".>"},
|
|
Storage: jetstream.FileStorage,
|
|
Retention: jetstream.LimitsPolicy,
|
|
}); err != nil {
|
|
nc.Close()
|
|
return fmt.Errorf("stream %s: %w", Stream, err)
|
|
}
|
|
|
|
top, err := OpenTopology(ctx, js)
|
|
if err != nil {
|
|
nc.Close()
|
|
return err
|
|
}
|
|
b.top = top
|
|
return nil
|
|
}
|
|
|
|
// Accept serves AMQP on ln until Shutdown, which closes ln. It blocks. Ready
|
|
// closes as it begins. Call it once, after Connect succeeded.
|
|
func (b *Broker) Accept(ln net.Listener) error {
|
|
b.mu.Lock()
|
|
b.ln = ln
|
|
b.mu.Unlock()
|
|
close(b.ready)
|
|
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-b.quit:
|
|
b.wg.Wait()
|
|
return nil
|
|
default:
|
|
}
|
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
|
continue
|
|
}
|
|
b.wg.Wait()
|
|
return fmt.Errorf("accept: %w", err)
|
|
}
|
|
b.wg.Go(func() {
|
|
b.serve(conn)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Ready closes once the bus is answering and Accept has its listener.
|
|
func (b *Broker) Ready() <-chan struct{} { return b.ready }
|
|
|
|
// Addr is where the listener actually landed, which is the question worth
|
|
// asking when Config named port 0.
|
|
func (b *Broker) Addr() net.Addr {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.ln == nil {
|
|
return nil
|
|
}
|
|
return b.ln.Addr()
|
|
}
|
|
|
|
// Topology is what is declared right now, for an operator's status view.
|
|
func (b *Broker) Topology() *Topology { return b.top }
|
|
|
|
// Connections counts the AMQP sessions open.
|
|
func (b *Broker) Connections() int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return len(b.live)
|
|
}
|
|
|
|
// Shutdown stops accepting, drops every open connection, and closes the bus
|
|
// link. Idempotent, and safe on a Broker that never served.
|
|
func (b *Broker) Shutdown() {
|
|
b.stop.Do(func() {
|
|
close(b.quit)
|
|
b.mu.Lock()
|
|
ln := b.ln
|
|
live := make([]*session, 0, len(b.live))
|
|
for s := range b.live {
|
|
live = append(live, s)
|
|
}
|
|
b.mu.Unlock()
|
|
|
|
if ln != nil {
|
|
ln.Close()
|
|
}
|
|
for _, s := range live {
|
|
s.conn.Close()
|
|
}
|
|
b.wg.Wait()
|
|
if b.top != nil {
|
|
b.top.Close()
|
|
}
|
|
if b.nc != nil {
|
|
b.nc.Close()
|
|
}
|
|
})
|
|
}
|
|
|
|
func (b *Broker) serve(conn net.Conn) {
|
|
s := newSession(b, conn)
|
|
b.mu.Lock()
|
|
b.live[s] = struct{}{}
|
|
b.mu.Unlock()
|
|
defer func() {
|
|
b.mu.Lock()
|
|
delete(b.live, s)
|
|
b.mu.Unlock()
|
|
s.close()
|
|
}()
|
|
s.run()
|
|
}
|
|
|
|
// ensure makes a queue's JetStream consumer match what the queue is bound to,
|
|
// creating it if this is the declare that brought the queue into being.
|
|
//
|
|
// DeliverNew is the AMQP rule: a queue holds what was published after it was
|
|
// declared, never the stream's history. It is also why declaring the queue —
|
|
// not consuming from it — is what starts collecting.
|
|
//
|
|
// MaxAckPending is unlimited on purpose. AMQP's prefetch is a property of a
|
|
// CHANNEL, and several channels in several processes consume one queue; a
|
|
// queue-wide ceiling would let one idle consumer stall the others. The
|
|
// per-channel limit is the pull batch, which is where it belongs.
|
|
func (b *Broker) ensure(ctx context.Context, queue string) (*jetstream.ConsumerInfo, error) {
|
|
c, err := b.js.CreateOrUpdateConsumer(ctx, Stream, jetstream.ConsumerConfig{
|
|
Durable: queue,
|
|
Name: queue,
|
|
FilterSubjects: b.top.Filters(queue),
|
|
AckPolicy: jetstream.AckExplicitPolicy,
|
|
DeliverPolicy: jetstream.DeliverNewPolicy,
|
|
MaxAckPending: -1,
|
|
AckWait: 5 * time.Minute,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.CachedInfo(), nil
|
|
}
|
|
|
|
// dropConsumer removes a queue's consumer. A queue that was never declared on
|
|
// this stream has none, and that is not an error.
|
|
func (b *Broker) dropConsumer(ctx context.Context, queue string) error {
|
|
err := b.js.DeleteConsumer(ctx, Stream, queue)
|
|
if errors.Is(err, jetstream.ErrConsumerNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// purge empties a queue by moving its consumer past everything waiting. There
|
|
// is no per-queue delete to make: the stream is shared, so purging by message
|
|
// would take those messages from every other queue bound to the same subjects.
|
|
func (b *Broker) purge(ctx context.Context, queue string) (uint32, error) {
|
|
c, err := b.js.Consumer(ctx, Stream, queue)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
info, err := c.Info(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := b.js.DeleteConsumer(ctx, Stream, queue); err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err := b.ensure(ctx, queue); err != nil {
|
|
return 0, err
|
|
}
|
|
return uint32(info.NumPending), nil
|
|
}
|
|
|
|
// attach, detach and consumers count the live basic.consume calls per queue.
|
|
// It is what queue.declare-ok reports and what queue.delete(if-unused) tests,
|
|
// and it is this gateway's own view — one replica cannot see another's
|
|
// sockets, so a fleet of them reports a lower bound.
|
|
func (b *Broker) attach(queue string) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.cons[queue]++
|
|
}
|
|
|
|
func (b *Broker) detach(queue string) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.cons[queue] <= 1 {
|
|
delete(b.cons, queue)
|
|
return
|
|
}
|
|
b.cons[queue]--
|
|
}
|
|
|
|
// claim records that a session holds an exclusive queue, and reports whether
|
|
// it may. An exclusive queue belongs to the connection that declared it
|
|
// (§1.7.2.1), so a second connection asking for it is refused rather than made
|
|
// a co-owner — co-ownership meant either connection's close deleted the other's
|
|
// queue.
|
|
//
|
|
// It is enforced within this gateway and cannot be enforced across a fleet of
|
|
// them: a replica cannot see another replica's sockets, so two gateways can
|
|
// each hold what the other thinks is exclusive. Say so rather than implying a
|
|
// guarantee the shape cannot make.
|
|
func (b *Broker) claim(queue string, s *session) bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
held, ok := b.excl[queue]
|
|
if ok && held != s {
|
|
return false
|
|
}
|
|
b.excl[queue] = s
|
|
return true
|
|
}
|
|
|
|
// release drops every exclusive queue a session held.
|
|
func (b *Broker) release(s *session) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
for q, held := range b.excl {
|
|
if held == s {
|
|
delete(b.excl, q)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *Broker) consumers(queue string) int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.cons[queue]
|
|
}
|
|
|
|
func logf(format string, a ...any) {
|
|
luxlog.Default().Info(fmt.Sprintf(format, a...), "subsystem", "amqp")
|
|
}
|