Files
bookhoard/internal/sync/offline.go
T
John O'Keefe 082a3e0c3c fix(sync): offline detector wiped device name, auto_sync, and metadata on every transition
setOfflineMode/setOnlineMode only mean to flip sync_enabled, but they
called the full-row UpdateDevice with Go zero values — which wrote
DeviceName: "" and NULLed auto_sync/sync_frequency_minutes/
device_metadata on the device row at every offline->online flip. New
targeted SetDeviceSyncEnabled query touches exactly that column; the
real settings update keeps using UpdateDevice.

Caught as part of the H4 helper-census pass (plan §5.4 erasure list).
2026-10-17 16:38:00 -04:00

258 lines
6.5 KiB
Go

package sync
import (
"bookhoard/internal/database"
"context"
"log"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
const (
OnlineThreshold = 5 * time.Minute
OfflineCheckInterval = 2 * time.Minute
)
type OfflineDetector struct {
db *database.Queries
queue *SyncQueueProcessor
checkInterval time.Duration
onlineThreshold time.Duration
}
type DeviceOnlineStatus struct {
DeviceID pgtype.UUID
DeviceName string
DeviceType string
IsOnline bool
LastSeen time.Time
UserID pgtype.UUID
}
func NewOfflineDetector(db *database.Queries, queue *SyncQueueProcessor) *OfflineDetector {
return &OfflineDetector{
db: db,
queue: queue,
checkInterval: OfflineCheckInterval,
onlineThreshold: OnlineThreshold,
}
}
func (d *OfflineDetector) Start(ctx context.Context) {
log.Printf("Starting offline detector (threshold: %v)", d.onlineThreshold)
ticker := time.NewTicker(d.checkInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("Offline detector stopped")
return
case <-ticker.C:
d.checkDeviceStatus(ctx)
}
}
}
func (d *OfflineDetector) checkDeviceStatus(ctx context.Context) {
users, err := d.db.ListUsers(ctx)
if err != nil {
log.Printf("Failed to list users for offline check: %v", err)
return
}
for _, user := range users {
devices, err := d.db.ListDevicesByUser(ctx, user.ID)
if err != nil {
continue
}
for _, device := range devices {
if !device.SyncEnabled.Bool {
continue
}
status := d.getDeviceStatus(device)
if !status.IsOnline {
d.handleOfflineDevice(ctx, status)
} else if d.wasOffline(ctx, status.DeviceID) {
d.handleReconnectedDevice(ctx, status)
}
d.updateDeviceStatus(ctx, status)
}
}
}
func (d *OfflineDetector) getDeviceStatus(device database.Devices) DeviceOnlineStatus {
lastSeen := time.Now()
if device.LastSeen.Valid {
lastSeen = device.LastSeen.Time
}
isOnline := time.Since(lastSeen) < d.onlineThreshold
return DeviceOnlineStatus{
DeviceID: device.ID,
DeviceName: device.DeviceName,
DeviceType: device.DeviceType,
IsOnline: isOnline,
LastSeen: lastSeen,
UserID: device.UserID,
}
}
func (d *OfflineDetector) wasOffline(ctx context.Context, deviceID pgtype.UUID) bool {
device, err := d.db.GetDevice(ctx, deviceID)
if err != nil {
return false
}
if !device.LastSeen.Valid {
return false
}
timeSinceLastSeen := time.Since(device.LastSeen.Time)
return timeSinceLastSeen >= d.onlineThreshold && timeSinceLastSeen < d.onlineThreshold*2
}
func (d *OfflineDetector) handleOfflineDevice(ctx context.Context, status DeviceOnlineStatus) {
log.Printf("Device offline detected: %s (%s)", status.DeviceName, status.DeviceType)
d.setOfflineMode(ctx, status.DeviceID)
}
func (d *OfflineDetector) handleReconnectedDevice(ctx context.Context, status DeviceOnlineStatus) {
log.Printf("Device reconnected: %s (%s)", status.DeviceName, status.DeviceType)
d.setOnlineMode(ctx, status.DeviceID)
d.processQueuedItems(ctx, status.DeviceID)
}
func (d *OfflineDetector) setOfflineMode(ctx context.Context, deviceID pgtype.UUID) {
// Targeted flip: the full-row UpdateDevice used here zeroed the
// device name, auto_sync, frequency, and metadata on every
// transition (pg zero values are not "leave alone").
_, err := d.db.SetDeviceSyncEnabled(ctx, database.SetDeviceSyncEnabledParams{
ID: deviceID,
SyncEnabled: pgtype.Bool{Bool: false, Valid: true},
})
if err != nil {
log.Printf("Failed to set offline mode for device: %v", err)
}
}
func (d *OfflineDetector) setOnlineMode(ctx context.Context, deviceID pgtype.UUID) {
// Targeted flip: the full-row UpdateDevice used here zeroed the
// device name, auto_sync, frequency, and metadata on every
// transition (pg zero values are not "leave alone").
_, err := d.db.SetDeviceSyncEnabled(ctx, database.SetDeviceSyncEnabledParams{
ID: deviceID,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
})
if err != nil {
log.Printf("Failed to set online mode for device: %v", err)
}
}
func (d *OfflineDetector) updateDeviceStatus(ctx context.Context, status DeviceOnlineStatus) {
_, err := d.db.UpdateDeviceLastSeen(ctx, status.DeviceID)
if err != nil {
log.Printf("Failed to update device last seen: %v", err)
}
}
func (d *OfflineDetector) processQueuedItems(ctx context.Context, deviceID pgtype.UUID) {
log.Printf("Processing queued items for reconnected device %s", deviceID.Bytes)
items, err := d.db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
DeviceID: deviceID,
RowLimit: 50,
})
if err != nil {
log.Printf("Failed to list queued items for device: %v", err)
return
}
log.Printf("Found %d queued items for device", len(items))
priorityItems := make([]database.SyncQueue, 0)
for _, item := range items {
if item.Priority.Int32 <= PriorityBookCompletion {
priorityItems = append(priorityItems, item)
}
}
if len(priorityItems) > 0 {
log.Printf("Processing %d priority items for device", len(priorityItems))
for _, item := range priorityItems {
_, err := d.db.UpdateSyncQueueItemStatus(ctx, database.UpdateSyncQueueItemStatusParams{
ID: item.ID,
Status: pgtype.Text{String: "pending", Valid: true},
ErrorMessage: pgtype.Text{},
})
if err != nil {
log.Printf("Failed to re-priority queue item: %v", err)
}
}
}
}
func (d *OfflineDetector) ForceReconnectDevice(ctx context.Context, deviceID pgtype.UUID) error {
device, err := d.db.GetDevice(ctx, deviceID)
if err != nil {
return err
}
status := d.getDeviceStatus(device)
status.IsOnline = true
status.LastSeen = time.Now()
d.handleReconnectedDevice(ctx, status)
d.updateDeviceStatus(ctx, status)
return nil
}
func (d *OfflineDetector) GetDeviceStatus(ctx context.Context, deviceID pgtype.UUID) (*DeviceOnlineStatus, error) {
device, err := d.db.GetDevice(ctx, deviceID)
if err != nil {
return nil, err
}
deviceStatus := d.getDeviceStatus(device)
return &deviceStatus, nil
}
func (d *OfflineDetector) ListOfflineDevices(ctx context.Context) ([]DeviceOnlineStatus, error) {
users, err := d.db.ListUsers(ctx)
if err != nil {
return nil, err
}
var offlineDevices []DeviceOnlineStatus
for _, user := range users {
devices, err := d.db.ListDevicesByUser(ctx, user.ID)
if err != nil {
continue
}
for _, device := range devices {
status := d.getDeviceStatus(device)
if !status.IsOnline {
offlineDevices = append(offlineDevices, status)
}
}
}
return offlineDevices, nil
}