package repositories import ( "context" "fmt" "strconv" "time" "nearle/db" "nearle/models" "github.com/redis/go-redis/v9" ) // POS terminal presence, in Redis. // // ### Why Redis and not a table // // A heartbeat is a fact with an expiry date. Written to Postgres it needs a // row per till updated twice a minute — around 288,000 writes a day across a // hundred terminals — and a reaper job to mark a till offline once it stops, // because a row that says "online" has no way of ageing out on its own. // // A Redis key with a TTL does the ageing for free. A till that loses power // stops refreshing, the key expires, and it disappears from the board without // anything having to notice. That is the whole design. // // ### Keys // // pos:terminal:{terminalcode} HASH, TTL 90s — one till's state // pos:location:{locationid}:terminals SET, no TTL — which tills a shop has // // The set has no TTL on purpose, mirroring how `city:{tenantid}:active_deliveries` // is treated in the express backend: it is an index of what exists, not a claim // that any of it is alive right now. Membership means "this till has been seen // here"; liveness is whether the hash still exists. const ( // Three missed heartbeats. Two would make an ordinary GPRS hiccup look like // a dead till; five would take two and a half minutes to notice a real one. posPresenceTTL = 90 * time.Second posTerminalKeyFmt = "pos:terminal:%s" posLocationKeyFmt = "pos:location:%s:terminals" ) type PosPresenceRepository interface { Record(ctx context.Context, health models.PosHealth) error Terminal(ctx context.Context, terminalID string) (map[string]string, error) Location(ctx context.Context, locationID string) ([]map[string]string, error) } type posPresenceRepository struct{} func NewPosPresenceRepository() PosPresenceRepository { return &posPresenceRepository{} } // Record writes one heartbeat and refreshes its TTL. func (r *posPresenceRepository) Record(ctx context.Context, health models.PosHealth) error { if db.Rdb == nil { return fmt.Errorf("redis is not configured") } if health.Terminalid == "" { return fmt.Errorf("heartbeat has no terminal id") } terminalKey := fmt.Sprintf(posTerminalKeyFmt, health.Terminalid) fields := map[string]any{ "terminal_id": health.Terminalid, "location_id": health.Locationid, "store_name": health.Storename, "app_version": health.Appversion, "status": health.Status, "pending_bills": health.Pendingbills, "pending_registrations": health.Pendingregistrations, "oldest_pending_at": health.Oldestpendingat, "today_bills": health.Todaybills, "today_amount": health.Todayamount, "last_bill_at": health.Lastbillat, "reported_at": health.Reportedat, // Stamped here as well as at the till. The two disagreeing by more than // a few seconds means the terminal's clock is wrong — which matters, // because bills are filed under the business date the till decided. "received_at": time.Now().UTC().Format(time.RFC3339), } // Device readings only when the till actually reported them. A build that // does not collect battery level must not leave one behind saying 0%. if health.Batterylevel != nil { fields["battery_level"] = *health.Batterylevel } if health.Batterycharging != nil { fields["battery_charging"] = *health.Batterycharging } if health.Storagefreemb != nil { fields["storage_free_mb"] = *health.Storagefreemb } if health.Printerreachable != nil { fields["printer_reachable"] = *health.Printerreachable } if health.Drawerstatus != nil { fields["drawer_status"] = *health.Drawerstatus } // HSet leaves untouched fields in place, so a reading that stops being // reported would otherwise linger for ever at its last value. Clearing the // absent ones keeps the hash honest about what this till currently knows. stale := make([]string, 0, 5) for field, reported := range map[string]bool{ "battery_level": health.Batterylevel != nil, "battery_charging": health.Batterycharging != nil, "storage_free_mb": health.Storagefreemb != nil, "printer_reachable": health.Printerreachable != nil, "drawer_status": health.Drawerstatus != nil, } { if !reported { stale = append(stale, field) } } pipe := db.Rdb.TxPipeline() pipe.HSet(ctx, terminalKey, fields) if len(stale) > 0 { pipe.HDel(ctx, terminalKey, stale...) } pipe.Expire(ctx, terminalKey, posPresenceTTL) if health.Locationid != "" { // No TTL: this is the list of tills a shop has, not a claim that any of // them is alive. Liveness is whether the hash above still exists. pipe.SAdd(ctx, fmt.Sprintf(posLocationKeyFmt, health.Locationid), health.Terminalid) } _, err := pipe.Exec(ctx) return err } // Terminal returns one till's last known state, or nil if it has gone quiet. func (r *posPresenceRepository) Terminal(ctx context.Context, terminalID string) (map[string]string, error) { if db.Rdb == nil { return nil, fmt.Errorf("redis is not configured") } fields, err := db.Rdb.HGetAll(ctx, fmt.Sprintf(posTerminalKeyFmt, terminalID)).Result() if err != nil && err != redis.Nil { return nil, err } if len(fields) == 0 { // Expired or never seen. Both mean "not reporting", which is what the // caller needs to know; distinguishing them would need a durable record // this deliberately does not keep. return nil, nil } return fields, nil } // Location returns every till registered at a shop, live or dark. // // A till whose key has expired comes back as a stub with status "offline" // rather than being omitted. Omitting it would make a dead terminal // indistinguishable from one that was never installed — and the dead one is // precisely what somebody is looking for. func (r *posPresenceRepository) Location(ctx context.Context, locationID string) ([]map[string]string, error) { if db.Rdb == nil { return nil, fmt.Errorf("redis is not configured") } members, err := db.Rdb.SMembers(ctx, fmt.Sprintf(posLocationKeyFmt, locationID)).Result() if err != nil && err != redis.Nil { return nil, err } out := make([]map[string]string, 0, len(members)) for _, terminalID := range members { fields, err := r.Terminal(ctx, terminalID) if err != nil { return nil, err } if fields == nil { fields = map[string]string{ "terminal_id": terminalID, "location_id": locationID, "status": "offline", // Says why it is being reported offline, rather than leaving a // reader to guess whether the till said so or simply vanished. "reason": "no heartbeat within " + strconv.Itoa(int(posPresenceTTL.Seconds())) + "s", } } out = append(out, fields) } return out, nil }