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:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

217
agent/pkg/mqtt/client.go Normal file
View 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

View 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
View 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
View 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)
}