// Package healthcheck periodically flips devices to offline when they stop // sending telemetry/acks, per the platform's Device Shadow health-check // design (a goroutine + graceful shutdown, deliberately simple). package healthcheck import ( "context" "log/slog" "time" ) // DeviceStore is the subset of shadow.Store the checker needs, kept as an // interface so tests don't require a real Redis. type DeviceStore interface { KnownDevices(ctx context.Context) ([]string, error) MarkOfflineIfStale(ctx context.Context, deviceID string, timeout time.Duration) (bool, error) } // OnOfflineFunc is called once per device that just transitioned to offline. type OnOfflineFunc func(deviceID string) type Checker struct { store DeviceStore timeout time.Duration interval time.Duration onOffline OnOfflineFunc logger *slog.Logger stop chan struct{} done chan struct{} } func New(store DeviceStore, timeout, interval time.Duration, onOffline OnOfflineFunc, logger *slog.Logger) *Checker { return &Checker{ store: store, timeout: timeout, interval: interval, onOffline: onOffline, logger: logger, } } // Start begins the periodic scan loop. Call once. func (c *Checker) Start() { c.stop = make(chan struct{}) c.done = make(chan struct{}) go c.loop() } // Stop signals the loop to exit and waits for it to finish. func (c *Checker) Stop() { close(c.stop) <-c.done } func (c *Checker) loop() { defer close(c.done) ticker := time.NewTicker(c.interval) defer ticker.Stop() for { select { case <-ticker.C: c.checkAll() case <-c.stop: return } } } func (c *Checker) checkAll() { ctx, cancel := context.WithTimeout(context.Background(), c.interval) defer cancel() ids, err := c.store.KnownDevices(ctx) if err != nil { c.logger.Error("healthcheck: list known devices failed", "error", err) return } for _, id := range ids { changed, err := c.store.MarkOfflineIfStale(ctx, id, c.timeout) if err != nil { c.logger.Error("healthcheck: check device failed", "device_id", id, "error", err) continue } if changed { c.logger.Info("device marked offline", "device_id", id) if c.onOffline != nil { c.onOffline(id) } } } }