Behavision: face recognition for retail, edge to head office
Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
217
agent/pkg/mqtt/client.go
Normal file
217
agent/pkg/mqtt/client.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// Broker client: the thin adapter behind the Publisher interface.
|
||||
//
|
||||
// Everything that decides *what to send and when* is in pump.go and is tested
|
||||
// without a broker. This file only knows how to put bytes on a topic, which is
|
||||
// why it is the one part that needs a real connection to exercise.
|
||||
//
|
||||
// Targets Mosquitto. No clustering, no shared subscriptions, no broker-side
|
||||
// rules — a store publishes its own events under its own prefix and that is
|
||||
// the whole interaction.
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
paho "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// ClientOptions configures a broker connection.
|
||||
type ClientOptions struct {
|
||||
// BrokerURL is tls://host:8883 in production, tcp://host:1883 for local
|
||||
// testing only. Credentials and footfall must never cross the internet in
|
||||
// the clear, so Connect refuses tcp:// to a non-loopback host.
|
||||
BrokerURL string
|
||||
ClientID string
|
||||
Username string
|
||||
Password string
|
||||
// CAFile pins a private CA. Empty uses the system roots, which is what a
|
||||
// Let's Encrypt certificate on the broker needs.
|
||||
CAFile string
|
||||
// InsecureSkipVerify disables certificate checking. Only ever for a
|
||||
// self-signed staging box, and it is logged loudly when set, because a
|
||||
// forgotten one silently removes the protection TLS was added for.
|
||||
InsecureSkipVerify bool
|
||||
// PublishTimeout bounds a single publish. Without it a half-open
|
||||
// connection blocks the pump indefinitely and the queue grows behind it.
|
||||
PublishTimeout time.Duration
|
||||
Log *log.Logger
|
||||
}
|
||||
|
||||
// Client implements Publisher.
|
||||
type Client struct {
|
||||
opts ClientOptions
|
||||
client paho.Client
|
||||
}
|
||||
|
||||
// NewClient dials the broker. It returns as soon as the connection is
|
||||
// established; reconnection afterwards is automatic and the pump reads
|
||||
// Connected() to decide whether to try.
|
||||
func NewClient(opts ClientOptions) (*Client, error) {
|
||||
if opts.BrokerURL == "" {
|
||||
return nil, errors.New("mqtt: no broker url")
|
||||
}
|
||||
if opts.PublishTimeout <= 0 {
|
||||
opts.PublishTimeout = 10 * time.Second
|
||||
}
|
||||
if err := checkTransport(opts.BrokerURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
po := paho.NewClientOptions().
|
||||
AddBroker(opts.BrokerURL).
|
||||
SetClientID(opts.ClientID).
|
||||
SetUsername(opts.Username).
|
||||
SetPassword(opts.Password).
|
||||
// The broker holds no state for us: every event is already durable on
|
||||
// our own disk, so a clean session avoids the broker queueing a
|
||||
// second copy we would then have to de-duplicate.
|
||||
SetCleanSession(true).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetConnectRetryInterval(5 * time.Second).
|
||||
SetMaxReconnectInterval(2 * time.Minute).
|
||||
SetKeepAlive(30 * time.Second).
|
||||
SetConnectTimeout(15 * time.Second).
|
||||
// Publishes must fail fast rather than pile up in memory while the
|
||||
// link is down; the spool is what holds them, not the client.
|
||||
SetMessageChannelDepth(1).
|
||||
SetOrderMatters(true)
|
||||
|
||||
if strings.HasPrefix(opts.BrokerURL, "tls://") ||
|
||||
strings.HasPrefix(opts.BrokerURL, "ssl://") {
|
||||
cfg, err := tlsConfig(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
po.SetTLSConfig(cfg)
|
||||
}
|
||||
|
||||
c := &Client{opts: opts}
|
||||
po.OnConnect = func(paho.Client) { c.logf("broker connected: %s", opts.BrokerURL) }
|
||||
po.OnConnectionLost = func(_ paho.Client, err error) {
|
||||
c.logf("broker connection lost: %v", err)
|
||||
}
|
||||
c.client = paho.NewClient(po)
|
||||
|
||||
tok := c.client.Connect()
|
||||
if !tok.WaitTimeout(20 * time.Second) {
|
||||
return c, fmt.Errorf("mqtt: connect to %s timed out", opts.BrokerURL)
|
||||
}
|
||||
if err := tok.Error(); err != nil {
|
||||
return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Publish sends one message at QoS 1 and waits for the broker's PUBACK.
|
||||
//
|
||||
// QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack
|
||||
// and delete an event that was dropped on the wire. QoS 2 costs two extra
|
||||
// round trips to remove a duplicate the server can drop itself from the event
|
||||
// id — at-least-once with idempotent consumers is the cheaper contract.
|
||||
func (c *Client) Publish(ctx context.Context, topic string, payload []byte) error {
|
||||
if c.client == nil {
|
||||
return errors.New("mqtt: no client")
|
||||
}
|
||||
if !c.client.IsConnected() {
|
||||
return errors.New("mqtt: not connected")
|
||||
}
|
||||
tok := c.client.Publish(topic, 1, false, payload)
|
||||
|
||||
// Honour both the caller's context and a hard timeout: a half-open TCP
|
||||
// connection can leave a token that never completes, which would stall the
|
||||
// pump forever with the queue growing behind it.
|
||||
done := make(chan struct{})
|
||||
go func() { tok.Wait(); close(done) }()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(c.opts.PublishTimeout):
|
||||
return fmt.Errorf("mqtt: publish to %s timed out", topic)
|
||||
case <-done:
|
||||
return tok.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// Connected reports whether the broker link is up.
|
||||
func (c *Client) Connected() bool {
|
||||
return c.client != nil && c.client.IsConnected()
|
||||
}
|
||||
|
||||
// Close disconnects cleanly, giving in-flight publishes a moment to land.
|
||||
func (c *Client) Close() {
|
||||
if c.client != nil && c.client.IsConnected() {
|
||||
c.client.Disconnect(1000)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) logf(format string, args ...any) {
|
||||
if c.opts.Log != nil {
|
||||
c.opts.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTransport refuses plaintext MQTT to anywhere but the local machine.
|
||||
//
|
||||
// The payloads carry customer visit records and the connection carries the
|
||||
// tenant's broker password. A tcp:// URL to a public host is not a
|
||||
// configuration choice, it is a mistake, and it is one that works — which is
|
||||
// exactly why it has to be rejected here rather than noticed later.
|
||||
func checkTransport(raw string) error {
|
||||
if !strings.HasPrefix(raw, "tcp://") && !strings.HasPrefix(raw, "mqtt://") {
|
||||
return nil
|
||||
}
|
||||
if os.Getenv("BEHAVISION_ALLOW_PLAINTEXT_MQTT") == "1" {
|
||||
return nil
|
||||
}
|
||||
// url.Parse, not hand-rolled splitting: an IPv6 literal is bracketed and
|
||||
// full of colons, so scanning for the first ":" turns "[::1]:1883" into
|
||||
// "[" and refuses a perfectly good loopback address.
|
||||
u, err := neturl.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mqtt: cannot parse broker url %q: %w", raw, err)
|
||||
}
|
||||
switch u.Hostname() {
|
||||
case "localhost", "127.0.0.1", "::1", "":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("mqtt: refusing plaintext connection to %q - use tls:// "+
|
||||
"(set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1 only for local testing)",
|
||||
u.Hostname())
|
||||
}
|
||||
|
||||
func tlsConfig(opts ClientOptions) (*tls.Config, error) {
|
||||
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if opts.InsecureSkipVerify {
|
||||
cfg.InsecureSkipVerify = true
|
||||
if opts.Log != nil {
|
||||
opts.Log.Print("WARNING: MQTT certificate verification is DISABLED")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
if opts.CAFile == "" {
|
||||
return cfg, nil // system roots
|
||||
}
|
||||
pem, err := os.ReadFile(opts.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mqtt: ca file: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("mqtt: no certificates found in %s", opts.CAFile)
|
||||
}
|
||||
cfg.RootCAs = pool
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// osWriteFile is indirected so tests can build without importing os twice.
|
||||
var osWriteFile = os.WriteFile
|
||||
104
agent/pkg/mqtt/client_test.go
Normal file
104
agent/pkg/mqtt/client_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlaintextToAPublicHostIsRefused(t *testing.T) {
|
||||
// The payloads carry customer visit records and the connection carries the
|
||||
// tenant's broker password. A tcp:// URL to a public host is not a config
|
||||
// choice, it is a mistake — and one that WORKS, which is exactly why it
|
||||
// has to fail here rather than be noticed after a year of traffic.
|
||||
for _, url := range []string{
|
||||
"tcp://broker.example.com:1883",
|
||||
"mqtt://66.116.226.234:1883",
|
||||
"tcp://10.0.0.5:1883",
|
||||
"tcp://[2001:db8::1]:1883",
|
||||
} {
|
||||
if _, err := NewClient(ClientOptions{BrokerURL: url}); err == nil ||
|
||||
!strings.Contains(err.Error(), "refusing plaintext") {
|
||||
t.Errorf("%s was not refused (err=%v)", url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextToLocalhostIsAllowed(t *testing.T) {
|
||||
// Local testing against a Mosquitto on the same box crosses no network.
|
||||
// Checked at the transport gate rather than through NewClient: dialling a
|
||||
// port nothing is listening on burns the full 20s connect timeout, and a
|
||||
// slow test is a test people start skipping.
|
||||
for _, url := range []string{"tcp://127.0.0.1:1883", "tcp://localhost:1883",
|
||||
"mqtt://[::1]:1883"} {
|
||||
if err := checkTransport(url); err != nil {
|
||||
t.Errorf("loopback %s was refused: %v", url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextEscapeHatchIsExplicit(t *testing.T) {
|
||||
// An override must exist for a lab, but it has to be a deliberate act,
|
||||
// not a config field someone leaves set.
|
||||
t.Setenv("BEHAVISION_ALLOW_PLAINTEXT_MQTT", "1")
|
||||
if err := checkTransport("tcp://broker.example.com:1883"); err != nil {
|
||||
t.Fatalf("escape hatch did not apply: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSUrlsSkipTheTransportCheck(t *testing.T) {
|
||||
for _, url := range []string{"tls://b:8883", "ssl://b:8883", "wss://b:443"} {
|
||||
if err := checkTransport(url); err != nil {
|
||||
t.Errorf("%s rejected: %v", url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyBrokerUrlIsAnError(t *testing.T) {
|
||||
if _, err := NewClient(ClientOptions{}); err == nil {
|
||||
t.Fatal("empty broker url accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigRejectsAnUnreadableCA(t *testing.T) {
|
||||
// Silently falling back to system roots when a pinned CA is missing would
|
||||
// quietly undo the pinning.
|
||||
if _, err := tlsConfig(ClientOptions{CAFile: "/nonexistent/ca.pem"}); err == nil {
|
||||
t.Fatal("missing CA file accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigRejectsAFileWithNoCertificates(t *testing.T) {
|
||||
f := t.TempDir() + "/not-a-cert.pem"
|
||||
if err := writeFile(f, "hello"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tlsConfig(ClientOptions{CAFile: f}); err == nil {
|
||||
t.Fatal("a file with no PEM certificates was accepted as a CA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSFloorIsTLS12(t *testing.T) {
|
||||
cfg, err := tlsConfig(ClientOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.MinVersion < 0x0303 {
|
||||
t.Fatalf("MinVersion %#x allows TLS below 1.2", cfg.MinVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
|
||||
// The pump calls this on every tick; a nil-client panic would take the
|
||||
// whole agent down instead of backing off.
|
||||
c := &Client{}
|
||||
if err := c.Publish(nil, "t", []byte("{}")); err == nil { //nolint:staticcheck
|
||||
t.Fatal("publish on an unconnected client reported success")
|
||||
}
|
||||
if c.Connected() {
|
||||
t.Fatal("an unconnected client reported Connected")
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
return osWriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
218
agent/pkg/mqtt/pump.go
Normal file
218
agent/pkg/mqtt/pump.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Package mqtt moves queued events to the broker.
|
||||
//
|
||||
// Split from the broker client on purpose: everything that decides *what to
|
||||
// send and when* lives here and is testable without a broker, while the paho
|
||||
// binding is a thin adapter that only knows how to put bytes on a topic.
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/spool"
|
||||
)
|
||||
|
||||
// Publisher is the broker, reduced to what the pump needs.
|
||||
type Publisher interface {
|
||||
// Publish must return nil only once the broker has confirmed receipt.
|
||||
// Returning early would let the pump ack an event that never arrived.
|
||||
Publish(ctx context.Context, topic string, payload []byte) error
|
||||
Connected() bool
|
||||
}
|
||||
|
||||
// Queue is the durable side, reduced likewise.
|
||||
type Queue interface {
|
||||
Peek(n int) ([]spool.Entry, error)
|
||||
Ack(seqs ...uint64) error
|
||||
Len() int
|
||||
Dropped() uint64
|
||||
}
|
||||
|
||||
const (
|
||||
batchSize = 32
|
||||
idleInterval = 2 * time.Second
|
||||
minRetry = 1 * time.Second
|
||||
maxRetry = 30 * time.Second
|
||||
defaultHeartbe = 60 * time.Second
|
||||
)
|
||||
|
||||
// Pump drains the queue into the broker and emits a heartbeat.
|
||||
type Pump struct {
|
||||
Queue Queue
|
||||
Publisher Publisher
|
||||
// Heartbeat topic. Without it "the site is offline" and "nobody visited"
|
||||
// are indistinguishable on the server, which for a footfall product is a
|
||||
// silent hole in the customer's report.
|
||||
HeartbeatTopic string
|
||||
HeartbeatPayload func() []byte
|
||||
HeartbeatInterval time.Duration
|
||||
Log *log.Logger
|
||||
|
||||
// Wake, when set, makes the pump drain immediately instead of waiting out
|
||||
// idleInterval. Without it a visit that lands one millisecond after a drain
|
||||
// sits on disk for two seconds before anyone is told - and that delay is on
|
||||
// the path a shop screen or a mobile app sees as "how long after someone
|
||||
// walks in does their face appear".
|
||||
//
|
||||
// A doorbell, not a queue: it carries nothing, because the pump re-reads
|
||||
// the spool either way. Buffered by one and written non-blockingly, so a
|
||||
// burst of arrivals cannot stall the recognition pipeline behind a pump
|
||||
// that is mid-publish.
|
||||
Wake <-chan struct{}
|
||||
}
|
||||
|
||||
// Waker is the writing end of the Wake channel, held by whatever appends to the
|
||||
// queue. NewWaker returns both halves so a caller cannot accidentally build one
|
||||
// that blocks its own producer.
|
||||
type Waker struct{ ch chan struct{} }
|
||||
|
||||
func NewWaker() *Waker { return &Waker{ch: make(chan struct{}, 1)} }
|
||||
|
||||
// Wake rings the pump. Never blocks: a full slot already means "there is work",
|
||||
// which is the entire message, so a second ring adds nothing.
|
||||
func (w *Waker) Wake() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case w.ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// C is the channel to hand the pump.
|
||||
func (w *Waker) C() <-chan struct{} {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.ch
|
||||
}
|
||||
|
||||
// Run drains until ctx is cancelled.
|
||||
func (p *Pump) Run(ctx context.Context) {
|
||||
interval := p.HeartbeatInterval
|
||||
if interval <= 0 {
|
||||
interval = defaultHeartbe
|
||||
}
|
||||
beat := time.NewTicker(interval)
|
||||
defer beat.Stop()
|
||||
retry := minRetry
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// Non-blocking, for the case where there is a backlog and the loop
|
||||
// never reaches the waiting select below.
|
||||
select {
|
||||
case <-beat.C:
|
||||
p.heartbeat(ctx)
|
||||
default:
|
||||
}
|
||||
|
||||
sent, err := p.drainOnce(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var wait time.Duration
|
||||
switch {
|
||||
case err != nil:
|
||||
// The broker is down or refusing. Back off rather than spinning:
|
||||
// a store with no internet would otherwise burn a core all night.
|
||||
p.logf("publish failed, retrying in %s: %v", retry, err)
|
||||
wait = retry
|
||||
if retry < maxRetry {
|
||||
retry *= 2
|
||||
if retry > maxRetry {
|
||||
retry = maxRetry
|
||||
}
|
||||
}
|
||||
case sent == 0:
|
||||
retry = minRetry
|
||||
wait = idleInterval
|
||||
default:
|
||||
// Something went through; there may be more waiting, so loop
|
||||
// immediately rather than sleeping through a backlog.
|
||||
retry = minRetry
|
||||
}
|
||||
if wait == 0 {
|
||||
continue
|
||||
}
|
||||
// The heartbeat must be able to interrupt this wait. Sleeping through
|
||||
// it would delay every beat by the idle interval, and on a quiet site
|
||||
// the pump is idle essentially always.
|
||||
// A nil Wake channel blocks forever in a select, which is exactly the
|
||||
// right behaviour: an agent with no waker falls back to the timer.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-beat.C:
|
||||
p.heartbeat(ctx)
|
||||
case <-p.Wake:
|
||||
// Something was queued. Loop straight round and drain it rather
|
||||
// than sleeping out the rest of the idle interval.
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainOnce sends at most one batch and returns how many were acked.
|
||||
func (p *Pump) drainOnce(ctx context.Context) (int, error) {
|
||||
if !p.Publisher.Connected() {
|
||||
return 0, errors.New("broker not connected")
|
||||
}
|
||||
entries, err := p.Queue.Peek(batchSize)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return 0, err
|
||||
}
|
||||
sent := 0
|
||||
for _, e := range entries {
|
||||
if err := p.Publisher.Publish(ctx, e.Topic, e.Payload); err != nil {
|
||||
// Stop at the first failure instead of skipping past it. Events
|
||||
// are a per-visitor timeline and the server reads them in order;
|
||||
// publishing around a stuck one would reorder a customer's visits.
|
||||
return sent, err
|
||||
}
|
||||
// Acked one at a time, immediately after its own confirmation. A batch
|
||||
// ack would re-send everything before a mid-batch failure on restart.
|
||||
if err := p.Queue.Ack(e.Seq); err != nil {
|
||||
return sent, err
|
||||
}
|
||||
sent++
|
||||
}
|
||||
return sent, nil
|
||||
}
|
||||
|
||||
func (p *Pump) heartbeat(ctx context.Context) {
|
||||
if p.HeartbeatTopic == "" || p.HeartbeatPayload == nil {
|
||||
return
|
||||
}
|
||||
if !p.Publisher.Connected() {
|
||||
return
|
||||
}
|
||||
// Not queued: a heartbeat is only meaningful now. Spooling them would
|
||||
// replay a week of "I am alive" the moment a site reconnects.
|
||||
if err := p.Publisher.Publish(ctx, p.HeartbeatTopic, p.HeartbeatPayload()); err != nil {
|
||||
p.logf("heartbeat failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pump) logf(format string, args ...any) {
|
||||
if p.Log != nil {
|
||||
p.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
288
agent/pkg/mqtt/pump_test.go
Normal file
288
agent/pkg/mqtt/pump_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/spool"
|
||||
)
|
||||
|
||||
type fakeBroker struct {
|
||||
mu sync.Mutex
|
||||
connected bool
|
||||
sent []string
|
||||
failAfter int // fail every publish once this many have succeeded
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeBroker) Publish(ctx context.Context, topic string, payload []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failAfter > 0 && len(f.sent) >= f.failAfter {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
return errors.New("broker refused")
|
||||
}
|
||||
f.sent = append(f.sent, string(payload))
|
||||
return nil
|
||||
}
|
||||
func (f *fakeBroker) Connected() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.connected
|
||||
}
|
||||
func (f *fakeBroker) delivered() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.sent...)
|
||||
}
|
||||
|
||||
func queue(t *testing.T, payloads ...string) *spool.Spool {
|
||||
t.Helper()
|
||||
s, err := spool.Open(t.TempDir(), 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range payloads {
|
||||
if err := s.Append("visit", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestItDrainsInOrderAndAcks(t *testing.T) {
|
||||
q := queue(t, "a", "b", "c")
|
||||
b := &fakeBroker{connected: true}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
|
||||
sent, err := p.drainOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent != 3 || q.Len() != 0 {
|
||||
t.Fatalf("sent %d, %d left in queue", sent, q.Len())
|
||||
}
|
||||
got := b.delivered()
|
||||
if len(got) != 3 || got[0] != `"a"` || got[2] != `"c"` {
|
||||
t.Fatalf("wrong order: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingIsAckedWhileTheBrokerIsDown(t *testing.T) {
|
||||
// Acking an event the broker never took is how footfall disappears.
|
||||
q := queue(t, "a", "b")
|
||||
b := &fakeBroker{connected: false}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
|
||||
if _, err := p.drainOnce(context.Background()); err == nil {
|
||||
t.Fatal("a disconnected broker was treated as success")
|
||||
}
|
||||
if q.Len() != 2 {
|
||||
t.Fatalf("events were dropped while offline: %d left", q.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailureStopsTheBatchInsteadOfSkippingPast(t *testing.T) {
|
||||
// Events are a per-visitor timeline read in order; publishing around a
|
||||
// stuck one would reorder a customer's visits on the server.
|
||||
q := queue(t, "a", "b", "c")
|
||||
b := &fakeBroker{connected: true, failAfter: 1}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
|
||||
sent, err := p.drainOnce(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("failure not reported")
|
||||
}
|
||||
if sent != 1 {
|
||||
t.Fatalf("sent %d, want 1 before stopping", sent)
|
||||
}
|
||||
if q.Len() != 2 {
|
||||
t.Fatalf("%d left in queue, want the 2 unsent", q.Len())
|
||||
}
|
||||
// And the survivors are the RIGHT two, still in order.
|
||||
rest, _ := q.Peek(10)
|
||||
if string(rest[0].Payload) != `"b"` {
|
||||
t.Fatalf("queue head is %s, want b", rest[0].Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmedEventsSurviveAMidBatchFailure(t *testing.T) {
|
||||
// Acking per-event rather than per-batch: a batch ack would re-send
|
||||
// everything before the failure after a restart, duplicating footfall.
|
||||
q := queue(t, "a", "b", "c")
|
||||
b := &fakeBroker{connected: true, failAfter: 2}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
p.drainOnce(context.Background())
|
||||
|
||||
if q.Len() != 1 {
|
||||
t.Fatalf("%d left, want only the unsent one", q.Len())
|
||||
}
|
||||
b.failAfter = 0
|
||||
sent, err := p.drainOnce(context.Background())
|
||||
if err != nil || sent != 1 {
|
||||
t.Fatalf("recovery sent %d (%v)", sent, err)
|
||||
}
|
||||
got := b.delivered()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("delivered %v - duplicates or losses", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecoversWhenTheBrokerComesBack(t *testing.T) {
|
||||
q := queue(t, "a")
|
||||
b := &fakeBroker{connected: false}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Run(ctx)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if len(b.delivered()) != 0 {
|
||||
t.Fatal("published while disconnected")
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.connected = true
|
||||
b.mu.Unlock()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(b.delivered()) == 1 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("queue never drained after the broker returned")
|
||||
}
|
||||
|
||||
func TestHeartbeatIsSentSeparatelyFromTheQueue(t *testing.T) {
|
||||
// "Site offline" and "nobody visited" must be distinguishable on the
|
||||
// server. And a heartbeat is only meaningful now, so it is never spooled -
|
||||
// otherwise a reconnecting site replays a week of "I am alive".
|
||||
q := queue(t)
|
||||
b := &fakeBroker{connected: true}
|
||||
p := &Pump{Queue: q, Publisher: b,
|
||||
HeartbeatTopic: "site/alive",
|
||||
HeartbeatPayload: func() []byte { return []byte(`{"up":true}`) },
|
||||
HeartbeatInterval: 20 * time.Millisecond}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Run(ctx)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if len(b.delivered()) == 0 {
|
||||
t.Fatal("no heartbeat was sent")
|
||||
}
|
||||
if q.Len() != 0 {
|
||||
t.Fatal("heartbeats were written to the durable queue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsPromptlyOnCancel(t *testing.T) {
|
||||
q := queue(t)
|
||||
b := &fakeBroker{connected: true}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { p.Run(ctx); close(done) }()
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run ignored cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- waking
|
||||
|
||||
// The delay this removes is on the path between a person walking in and their
|
||||
// face reaching a screen, so the test asserts a real wall-clock bound rather
|
||||
// than that a channel was read.
|
||||
func TestAWakeDrainsWithoutWaitingOutTheIdleInterval(t *testing.T) {
|
||||
q := queue(t)
|
||||
pub := &fakeBroker{connected: true}
|
||||
waker := NewWaker()
|
||||
p := &Pump{Queue: q, Publisher: pub, Wake: waker.C(),
|
||||
HeartbeatInterval: time.Hour}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Run(ctx)
|
||||
|
||||
// Let it reach the idle wait with an empty queue first, so what follows is
|
||||
// genuinely the wake path and not the drain it does on startup.
|
||||
waitUntil(t, func() bool { return len(pub.delivered()) == 0 }, time.Second)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
start := time.Now()
|
||||
if err := q.Append("visit", "e1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waker.Wake()
|
||||
|
||||
waitUntil(t, func() bool { return len(pub.delivered()) == 1 }, 2*time.Second)
|
||||
if took := time.Since(start); took >= idleInterval {
|
||||
t.Fatalf("took %s - the wake did not beat the %s idle tick", took, idleInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// A pump with no waker must behave exactly as it did before: a nil channel
|
||||
// blocks forever in a select, which is the correct fallback, not a hang.
|
||||
func TestAPumpWithNoWakerStillDrainsOnItsTimer(t *testing.T) {
|
||||
q := queue(t)
|
||||
pub := &fakeBroker{connected: true}
|
||||
p := &Pump{Queue: q, Publisher: pub, HeartbeatInterval: time.Hour}
|
||||
|
||||
if err := q.Append("visit", "e1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Run(ctx)
|
||||
|
||||
waitUntil(t, func() bool { return len(pub.delivered()) == 1 }, 3*time.Second)
|
||||
}
|
||||
|
||||
// The waker runs on the engine's webhook request. If it could ever block, a
|
||||
// burst of arrivals would apply backpressure into the recognition loop.
|
||||
func TestWakingNeverBlocksEvenWithNobodyListening(t *testing.T) {
|
||||
waker := NewWaker()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 10000; i++ {
|
||||
waker.Wake()
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Wake blocked with no pump reading - this would stall recognition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANilWakerIsSafe(t *testing.T) {
|
||||
var w *Waker
|
||||
w.Wake() // an agent assembled without one must still run
|
||||
if w.C() != nil {
|
||||
t.Fatal("a nil waker handed out a channel")
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntil(t *testing.T, cond func() bool, within time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(within)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("condition not met within %s", within)
|
||||
}
|
||||
Reference in New Issue
Block a user