Deployed as a StatefulSet with three replicas, and MQTT has no queue groups — every subscriber receives every message. All three pods would commit the same bill and publish three acks. Nothing double-counts, because the ingest deduplicates on the till's UUID and holds an advisory lock, but it is three times the database work and three times the traffic for one sale. Ordinal 0 consumes; the others stay idle. A StatefulSet already guarantees stable unique ordinals, so this is a deterministic election with no lock, no lease and no new dependency. If that pod dies the set recreates it and tills hold their bills meanwhile, which is what they are built to do. POS_MQTT_CONSUMER=always/never overrides it for deployments that are not a StatefulSet. Anything without an ordinal name — a Deployment pod, a bare container, local development — consumes, because a lone instance that silently refused to would be a far more confusing failure than one that did. The client id now defaults to the pod name rather than a constant. Two connections sharing an id evict each other in a reconnect loop that looks exactly like a flapping network, and takes a while to recognise as anything else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
317 lines
10 KiB
Go
317 lines
10 KiB
Go
package messaging
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"nearle/models"
|
|
"nearle/services"
|
|
|
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
|
)
|
|
|
|
// MQTT ingest for the Nearle POS terminals.
|
|
//
|
|
// The broker is Eclipse Mosquitto, shared with the rider fleet. An audit of the
|
|
// estate found no reachable NATS and no MQTT gateway on the NATS boxes that do
|
|
// exist, so a NATS consumer that briefly lived here was deleted rather than
|
|
// left to rot — a client for a protocol nothing speaks is worse than none.
|
|
//
|
|
// Enabled with MQTT_URL. Unset, the terminals reach the same service over HTTP
|
|
// instead, and this file does nothing.
|
|
//
|
|
// Only one replica consumes: see posConsumerElected.
|
|
|
|
const (
|
|
// Namespaced under `nearle/` alongside the rider app's
|
|
// `nearle/riders/{riderId}/...`, so one broker ACL rule covers each system
|
|
// and it is obvious from a topic which one it belongs to.
|
|
//
|
|
// Wildcards for MQTT are `+` per level, where NATS uses `*`.
|
|
topicOrders = "nearle/pos/+/+/order"
|
|
topicCustomers = "nearle/pos/+/+/customer"
|
|
topicHealth = "nearle/pos/+/+/health"
|
|
)
|
|
|
|
type PosMqttConsumer struct {
|
|
client mqtt.Client
|
|
svc services.PosService
|
|
}
|
|
|
|
// StartPosMqttConsumer connects and subscribes.
|
|
//
|
|
// Returns (nil, nil) when MQTT_URL is unset — a deployment without a broker is
|
|
// supported, and the caller carries on with the HTTP endpoints.
|
|
func StartPosMqttConsumer(svc services.PosService) (*PosMqttConsumer, error) {
|
|
url := strings.TrimSpace(os.Getenv("MQTT_URL"))
|
|
if url == "" {
|
|
log.Println("pos: MQTT_URL not set, plain-MQTT ingest disabled")
|
|
return nil, nil
|
|
}
|
|
|
|
// Only one replica consumes.
|
|
//
|
|
// MQTT has no queue groups — every subscriber receives every message, so
|
|
// three replicas would each commit the same bill and publish three acks.
|
|
// The ingest is idempotent, so nothing double-counts, but it is three times
|
|
// the database work and three times the traffic for one sale.
|
|
//
|
|
// A StatefulSet gives pods stable ordinal names, so ordinal 0 is a
|
|
// deterministic election with no coordination and no extra dependency. If
|
|
// that pod dies the set recreates it; tills hold their bills and re-send in
|
|
// the meantime, which is exactly what they are built to do.
|
|
if !posConsumerElected() {
|
|
log.Printf("pos: replica %q is not the elected consumer, MQTT ingest idle here",
|
|
os.Getenv("HOSTNAME"))
|
|
return nil, nil
|
|
}
|
|
|
|
c := &PosMqttConsumer{svc: svc}
|
|
|
|
opts := mqtt.NewClientOptions().
|
|
AddBroker(url).
|
|
// Stable, so the broker resumes this session and redelivers anything
|
|
// in flight rather than treating every restart as a new subscriber.
|
|
// Defaults to the pod name so replicas can never collide: a second
|
|
// connection with the same client id evicts the first, and the two then
|
|
// fight in a reconnect loop that looks like a flapping network.
|
|
SetClientID(getEnvDefault("MQTT_CLIENT_ID",
|
|
getEnvDefault("HOSTNAME", "nearle-pos-ingest"))).
|
|
SetCleanSession(false).
|
|
SetAutoReconnect(true).
|
|
SetMaxReconnectInterval(30 * time.Second).
|
|
SetKeepAlive(30 * time.Second).
|
|
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
|
|
log.Printf("pos: MQTT connection lost: %v", err)
|
|
})
|
|
|
|
if user := os.Getenv("MQTT_USER"); user != "" {
|
|
opts.SetUsername(user).SetPassword(os.Getenv("MQTT_PASSWORD"))
|
|
}
|
|
|
|
// Re-subscribed on every (re)connect rather than once at startup: with a
|
|
// broker that did not persist the session, a reconnect would otherwise come
|
|
// back silently subscribed to nothing.
|
|
opts.SetOnConnectHandler(func(client mqtt.Client) {
|
|
log.Printf("pos: connected to MQTT broker %s", url)
|
|
for topic, handler := range map[string]mqtt.MessageHandler{
|
|
topicOrders: c.handleOrders,
|
|
topicCustomers: c.handleCustomers,
|
|
topicHealth: c.handleHealth,
|
|
} {
|
|
if token := client.Subscribe(topic, 1, handler); token.Wait() && token.Error() != nil {
|
|
log.Printf("pos: could not subscribe to %s: %v", topic, token.Error())
|
|
continue
|
|
}
|
|
log.Printf("pos: subscribed to %s", topic)
|
|
}
|
|
})
|
|
|
|
client := mqtt.NewClient(opts)
|
|
if token := client.Connect(); token.Wait() && token.Error() != nil {
|
|
return nil, fmt.Errorf("could not connect to the MQTT broker at %s: %w", url, token.Error())
|
|
}
|
|
c.client = client
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (c *PosMqttConsumer) handleOrders(_ mqtt.Client, msg mqtt.Message) {
|
|
var batch models.PosOrderBatch
|
|
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
|
|
// Dropped rather than retried: there is no batch id to answer with, and
|
|
// the till will time out and re-send anyway.
|
|
log.Printf("pos: discarding unreadable order batch on %s: %v", msg.Topic(), err)
|
|
return
|
|
}
|
|
|
|
store, terminal := topicIdentity(msg.Topic())
|
|
if batch.Storeid == "" {
|
|
batch.Storeid = store
|
|
}
|
|
if batch.Terminalid == "" {
|
|
batch.Terminalid = terminal
|
|
}
|
|
|
|
ack, err := c.svc.IngestOrders(batch)
|
|
if err != nil {
|
|
// Nothing committed, so nothing is acknowledged. The terminal keeps
|
|
// every bill and retries — which is the entire point of the design.
|
|
log.Printf("pos: order batch %s from %s/%s failed, not acking: %v",
|
|
batch.Batchid, store, terminal, err)
|
|
return
|
|
}
|
|
|
|
c.publishAck(store, terminal, ack)
|
|
log.Printf("pos: order batch %s from %s/%s — %d accepted, %d rejected",
|
|
batch.Batchid, store, terminal, len(ack.Accepted), len(ack.Rejected))
|
|
}
|
|
|
|
func (c *PosMqttConsumer) handleCustomers(_ mqtt.Client, msg mqtt.Message) {
|
|
var batch models.PosCustomerBatch
|
|
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
|
|
log.Printf("pos: discarding unreadable customer batch on %s: %v", msg.Topic(), err)
|
|
return
|
|
}
|
|
|
|
store, terminal := topicIdentity(msg.Topic())
|
|
if batch.Storeid == "" {
|
|
batch.Storeid = store
|
|
}
|
|
if batch.Terminalid == "" {
|
|
batch.Terminalid = terminal
|
|
}
|
|
|
|
ack, err := c.svc.IngestCustomers(batch)
|
|
if err != nil {
|
|
log.Printf("pos: customer batch %s from %s/%s failed, not acking: %v",
|
|
batch.Batchid, store, terminal, err)
|
|
return
|
|
}
|
|
|
|
c.publishAck(store, terminal, ack)
|
|
}
|
|
|
|
// handleHealth records one heartbeat.
|
|
//
|
|
// Never acknowledged. Presence is fire-and-forget: a till whose heartbeat
|
|
// failed must carry on selling, and a blank square on a dashboard is a far
|
|
// better outcome than a terminal that stopped because Redis was busy.
|
|
func (c *PosMqttConsumer) handleHealth(_ mqtt.Client, msg mqtt.Message) {
|
|
var health models.PosHealth
|
|
if err := json.Unmarshal(msg.Payload(), &health); err != nil {
|
|
log.Printf("pos: discarding unreadable heartbeat on %s: %v", msg.Topic(), err)
|
|
return
|
|
}
|
|
|
|
// From the topic, not the body — the same rule bills follow.
|
|
store, terminal := topicIdentity(msg.Topic())
|
|
if health.Locationid == "" {
|
|
health.Locationid = store
|
|
}
|
|
if health.Terminalid == "" {
|
|
health.Terminalid = terminal
|
|
}
|
|
|
|
// The broker's Last Will arrives here too, as a bare {"status":"offline"}
|
|
// with no other fields, which is exactly what should be recorded when a
|
|
// till loses power mid-shift.
|
|
if health.Status == "" {
|
|
health.Status = "online"
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := c.svc.RecordHealth(ctx, health); err != nil {
|
|
log.Printf("pos: could not record heartbeat from %s/%s: %v", store, terminal, err)
|
|
}
|
|
}
|
|
|
|
// publishAck answers the till that sent the batch, and only that till.
|
|
func (c *PosMqttConsumer) publishAck(store, terminal string, ack *models.PosAck) {
|
|
if store == "" || terminal == "" {
|
|
log.Printf("pos: cannot ack batch %s — the topic named no terminal", ack.Batchid)
|
|
return
|
|
}
|
|
|
|
payload, err := json.Marshal(ack)
|
|
if err != nil {
|
|
log.Printf("pos: could not encode ack for batch %s: %v", ack.Batchid, err)
|
|
return
|
|
}
|
|
|
|
topic := fmt.Sprintf("nearle/pos/%s/%s/ack", store, terminal)
|
|
|
|
// QoS 1: losing an ack means the till re-sends bills that are already
|
|
// banked. Harmless, because the ingest deduplicates — but wasted traffic on
|
|
// a shop line that may not have much to spare.
|
|
token := c.client.Publish(topic, 1, false, payload)
|
|
if !token.WaitTimeout(10*time.Second) || token.Error() != nil {
|
|
log.Printf("pos: could not publish ack to %s: %v", topic, token.Error())
|
|
}
|
|
}
|
|
|
|
// topicIdentity reads the store and terminal out of
|
|
// `nearle/pos/<store>/<terminal>/<kind>`.
|
|
//
|
|
// Taken from the topic rather than the body on purpose: a till that could name
|
|
// a store in its payload could post sales into another shop's books.
|
|
func topicIdentity(topic string) (store, terminal string) {
|
|
parts := strings.Split(topic, "/")
|
|
if len(parts) < 5 {
|
|
return "", ""
|
|
}
|
|
return parts[2], parts[3]
|
|
}
|
|
|
|
// PublishCatalogueChanged tells every till in a store to pull now.
|
|
//
|
|
// Retained, so a terminal that was switched off during the change still hears
|
|
// about it when it comes back.
|
|
func (c *PosMqttConsumer) PublishCatalogueChanged(storeID, revision string) error {
|
|
payload, err := json.Marshal(map[string]string{"revision": revision})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token := c.client.Publish(fmt.Sprintf("nearle/pos/%s/catalogue", storeID), 1, true, payload)
|
|
token.Wait()
|
|
return token.Error()
|
|
}
|
|
|
|
// Close disconnects, allowing a moment for in-flight acks to leave.
|
|
func (c *PosMqttConsumer) Close() {
|
|
if c == nil || c.client == nil {
|
|
return
|
|
}
|
|
quiesce, err := strconv.Atoi(getEnvDefault("MQTT_QUIESCE_MS", "2000"))
|
|
if err != nil || quiesce < 0 {
|
|
quiesce = 2000
|
|
}
|
|
c.client.Disconnect(uint(quiesce))
|
|
}
|
|
|
|
// posConsumerElected decides whether this replica runs the MQTT ingest.
|
|
//
|
|
// Rules, in order:
|
|
//
|
|
// - POS_MQTT_CONSUMER=always or =never settles it outright, for deployments
|
|
// that are not a StatefulSet or that want the consumer somewhere specific.
|
|
// - A StatefulSet pod name ending in `-0` is elected. Ordinals are stable and
|
|
// unique, so this needs no lock, no lease and no coordination.
|
|
// - Anything else — a bare container, a Deployment, local development —
|
|
// is elected, because a single instance that refused to consume would be a
|
|
// far more confusing failure than one that did.
|
|
func posConsumerElected() bool {
|
|
switch strings.ToLower(strings.TrimSpace(os.Getenv("POS_MQTT_CONSUMER"))) {
|
|
case "always", "true", "yes":
|
|
return true
|
|
case "never", "false", "no":
|
|
return false
|
|
}
|
|
|
|
host := strings.TrimSpace(os.Getenv("HOSTNAME"))
|
|
if i := strings.LastIndex(host, "-"); i >= 0 {
|
|
if ordinal := host[i+1:]; ordinal != "" && strings.Trim(ordinal, "0123456789") == "" {
|
|
// A StatefulSet ordinal. Only the first replica consumes.
|
|
return ordinal == "0"
|
|
}
|
|
}
|
|
|
|
// Not an ordinal-named pod, so there is nothing to elect against.
|
|
return true
|
|
}
|
|
|
|
func getEnvDefault(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|