// Package store persists the Sense desired state and reconciliation progress. package store import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" _ "modernc.org/sqlite" "yovision/sense/internal/device" ) var ErrNotFound = errors.New("store record not found") type ReconcileCandidate struct { Device device.Device FailureCount int NextAttempt *time.Time } type DeviceConvergence struct { ID string `json:"id"` PathName string `json:"path_name"` DesiredState device.DesiredState `json:"desired_state"` ActualState device.ActualState `json:"actual_state"` Generation int64 `json:"generation"` ObservedGeneration int64 `json:"observed_generation"` FailureCount int `json:"failure_count"` NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"` LastErrorCode string `json:"last_error_code,omitempty"` Converged bool `json:"converged"` } type ConvergenceSnapshot struct { Total int `json:"total"` Unconverged int `json:"unconverged"` Devices []DeviceConvergence `json:"devices"` } type SQLite struct { db *sql.DB } func OpenSQLite(ctx context.Context, dsn string) (*SQLite, error) { if err := ensureSQLiteDirectory(dsn); err != nil { return nil, err } db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } // M1 runs one writer per edge instance. A single connection also gives // deterministic quota transactions and avoids :memory: connection splits. db.SetMaxOpenConns(1) if err := db.PingContext(ctx); err != nil { db.Close() return nil, fmt.Errorf("ping sqlite: %w", err) } store := &SQLite{db: db} if err := store.Migrate(ctx); err != nil { db.Close() return nil, err } return store, nil } func (s *SQLite) Close() error { return s.db.Close() } func ensureSQLiteDirectory(dsn string) error { if !strings.HasPrefix(dsn, "file:") { return nil } path := strings.TrimPrefix(dsn, "file:") path = strings.SplitN(path, "?", 2)[0] if path == "" || path == ":memory:" || strings.HasPrefix(path, ":memory:") { return nil } directory := filepath.Dir(filepath.FromSlash(path)) if directory == "." { return nil } if err := os.MkdirAll(directory, 0o750); err != nil { return fmt.Errorf("create sqlite directory: %w", err) } return nil } func (s *SQLite) Migrate(ctx context.Context) error { if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil { return fmt.Errorf("enable sqlite foreign keys: %w", err) } if _, err := s.db.ExecContext(ctx, `PRAGMA busy_timeout = 5000`); err != nil { return fmt.Errorf("configure sqlite busy timeout: %w", err) } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin migration: %w", err) } defer tx.Rollback() for _, statement := range migrationStatements { if _, err := tx.ExecContext(ctx, statement); err != nil { return fmt.Errorf("apply sqlite migration: %w", err) } } if _, err := tx.ExecContext(ctx, ` INSERT INTO sense_schema_migrations(version, applied_at) VALUES (1, ?) ON CONFLICT(version) DO NOTHING`, formatTime(time.Now())); err != nil { return fmt.Errorf("record sqlite migration: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit sqlite migration: %w", err) } return nil } var migrationStatements = []string{ `CREATE TABLE IF NOT EXISTS sense_schema_migrations ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS sense_sites ( tenant_id TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, max_video_channels INTEGER NOT NULL DEFAULT 16 CHECK (max_video_channels BETWEEN 1 AND 128), PRIMARY KEY (tenant_id, id) )`, `CREATE TABLE IF NOT EXISTS sense_devices ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, site_id TEXT NOT NULL, serial_number TEXT NOT NULL, name TEXT NOT NULL, modality TEXT NOT NULL, desired_state TEXT NOT NULL CHECK (desired_state IN ('disabled', 'enabled')), actual_state TEXT NOT NULL CHECK (actual_state IN ('pending', 'online', 'offline', 'failed')), endpoint_ref TEXT NOT NULL DEFAULT '', credential_ref TEXT NOT NULL DEFAULT '', path_name TEXT NOT NULL DEFAULT '', generation INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (tenant_id, site_id, serial_number), FOREIGN KEY (tenant_id, site_id) REFERENCES sense_sites(tenant_id, id) )`, `CREATE TABLE IF NOT EXISTS sense_device_capabilities ( device_id TEXT NOT NULL, capability TEXT NOT NULL, PRIMARY KEY (device_id, capability), FOREIGN KEY (device_id) REFERENCES sense_devices(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS sense_reconcile_state ( device_id TEXT PRIMARY KEY, failure_count INTEGER NOT NULL DEFAULT 0, next_attempt_at TEXT, last_error_code TEXT, observed_generation INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL, FOREIGN KEY (device_id) REFERENCES sense_devices(id) ON DELETE CASCADE )`, `CREATE INDEX IF NOT EXISTS sense_devices_site_state_idx ON sense_devices(tenant_id, site_id, desired_state)`, `CREATE UNIQUE INDEX IF NOT EXISTS sense_devices_path_name_idx ON sense_devices(path_name) WHERE path_name <> ''`, `CREATE INDEX IF NOT EXISTS sense_reconcile_due_idx ON sense_reconcile_state(next_attempt_at)`, } func (s *SQLite) EnsureSite(ctx context.Context, site device.Site) error { site.ApplyDefaults() if err := site.Validate(); err != nil { return fmt.Errorf("validate site: %w", err) } _, err := s.db.ExecContext(ctx, ` INSERT INTO sense_sites(tenant_id, id, name, max_video_channels) VALUES (?, ?, ?, ?) ON CONFLICT(tenant_id, id) DO UPDATE SET name = excluded.name, max_video_channels = excluded.max_video_channels`, site.TenantID, site.ID, site.Name, site.MaxVideoChannels) if err != nil { return fmt.Errorf("ensure site: %w", err) } return nil } func (s *SQLite) CreateDevice(ctx context.Context, value device.Device) error { if value.Generation == 0 { value.Generation = 1 } if value.ActualState == "" { value.ActualState = device.ActualPending } if err := value.Validate(); err != nil { return fmt.Errorf("validate device: %w", err) } now := time.Now().UTC() if value.CreatedAt.IsZero() { value.CreatedAt = now } value.UpdatedAt = now tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin create device: %w", err) } defer tx.Rollback() if value.ConsumesVideoChannel() { if err := checkVideoQuota(ctx, tx, value.TenantID, value.SiteID); err != nil { return err } } _, err = tx.ExecContext(ctx, ` INSERT INTO sense_devices( id, tenant_id, site_id, serial_number, name, modality, desired_state, actual_state, endpoint_ref, credential_ref, path_name, generation, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, value.ID, value.TenantID, value.SiteID, value.SerialNumber, value.Name, value.Modality, value.DesiredState, value.ActualState, value.EndpointRef, value.CredentialRef, value.PathName, value.Generation, formatTime(value.CreatedAt), formatTime(value.UpdatedAt)) if err != nil { return fmt.Errorf("insert device: %w", err) } for _, capability := range sortedCapabilities(value.Capabilities) { if _, err := tx.ExecContext(ctx, `INSERT INTO sense_device_capabilities(device_id, capability) VALUES (?, ?)`, value.ID, capability); err != nil { return fmt.Errorf("insert device capability: %w", err) } } if _, err := tx.ExecContext(ctx, ` INSERT INTO sense_reconcile_state(device_id, updated_at) VALUES (?, ?)`, value.ID, formatTime(now)); err != nil { return fmt.Errorf("insert reconcile state: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit create device: %w", err) } return nil } func checkVideoQuota(ctx context.Context, tx *sql.Tx, tenantID, siteID string) error { var limit int err := tx.QueryRowContext(ctx, `SELECT max_video_channels FROM sense_sites WHERE tenant_id = ? AND id = ?`, tenantID, siteID).Scan(&limit) if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("site %s/%s: %w", tenantID, siteID, ErrNotFound) } if err != nil { return fmt.Errorf("read site quota: %w", err) } var current int err = tx.QueryRowContext(ctx, ` SELECT COUNT(*) FROM sense_devices d JOIN sense_device_capabilities c ON c.device_id = d.id WHERE d.tenant_id = ? AND d.site_id = ? AND d.desired_state = 'enabled' AND c.capability = 'video_capture'`, tenantID, siteID).Scan(¤t) if err != nil { return fmt.Errorf("count site video channels: %w", err) } if current >= limit { return &device.QuotaExceededError{TenantID: tenantID, SiteID: siteID, Limit: limit} } return nil } func (s *SQLite) SetDesiredState(ctx context.Context, id string, desired device.DesiredState) error { if desired != device.DesiredEnabled && desired != device.DesiredDisabled { return fmt.Errorf("invalid desired state %q", desired) } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin desired-state update: %w", err) } defer tx.Rollback() var tenantID, siteID, endpointRef, pathName string var current device.DesiredState err = tx.QueryRowContext(ctx, `SELECT tenant_id, site_id, desired_state, endpoint_ref, path_name FROM sense_devices WHERE id = ?`, id). Scan(&tenantID, &siteID, ¤t, &endpointRef, &pathName) if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } if err != nil { return fmt.Errorf("read device desired state: %w", err) } if current == desired { return tx.Commit() } if desired == device.DesiredEnabled { var videoCapability int if err := tx.QueryRowContext(ctx, ` SELECT COUNT(*) FROM sense_device_capabilities WHERE device_id = ? AND capability = 'video_capture'`, id).Scan(&videoCapability); err != nil { return fmt.Errorf("read video capability: %w", err) } if videoCapability > 0 { if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" { return fmt.Errorf("enabled video devices require endpoint ref and path name") } if err := checkVideoQuota(ctx, tx, tenantID, siteID); err != nil { return err } } } result, err := tx.ExecContext(ctx, ` UPDATE sense_devices SET desired_state = ?, actual_state = 'pending', generation = generation + 1, updated_at = ? WHERE id = ?`, desired, formatTime(time.Now()), id) if err != nil { return fmt.Errorf("update desired state: %w", err) } if affected, _ := result.RowsAffected(); affected != 1 { return ErrNotFound } if _, err := tx.ExecContext(ctx, ` UPDATE sense_reconcile_state SET failure_count = 0, next_attempt_at = NULL, last_error_code = NULL, updated_at = ? WHERE device_id = ?`, formatTime(time.Now()), id); err != nil { return fmt.Errorf("reset reconcile state: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit desired-state update: %w", err) } return nil } func (s *SQLite) GetDevice(ctx context.Context, id string) (device.Device, error) { row := s.db.QueryRowContext(ctx, deviceSelect+` WHERE d.id = ?`, id) value, err := scanDevice(row) if errors.Is(err, sql.ErrNoRows) { return device.Device{}, ErrNotFound } if err != nil { return device.Device{}, fmt.Errorf("get device: %w", err) } capabilities, err := s.capabilities(ctx, value.ID) if err != nil { return device.Device{}, err } value.Capabilities = capabilities return value, nil } func (s *SQLite) ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]ReconcileCandidate, error) { if limit <= 0 { return nil, nil } rows, err := s.db.QueryContext(ctx, `SELECT `+deviceColumns+`, r.failure_count, r.next_attempt_at FROM sense_devices d JOIN sense_reconcile_state r ON r.device_id = d.id WHERE EXISTS ( SELECT 1 FROM sense_device_capabilities c WHERE c.device_id = d.id AND c.capability = 'video_capture' ) AND (r.observed_generation < d.generation OR (d.desired_state = 'enabled' AND r.failure_count > 0)) AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= ?) ORDER BY d.updated_at, d.id LIMIT ?`, formatTime(now), limit) if err != nil { return nil, fmt.Errorf("list due reconcile devices: %w", err) } candidates := make([]ReconcileCandidate, 0) for rows.Next() { var candidate ReconcileCandidate var createdAt, updatedAt string var nextAttempt sql.NullString if err := rows.Scan( &candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality, &candidate.Device.DesiredState, &candidate.Device.ActualState, &candidate.Device.EndpointRef, &candidate.Device.CredentialRef, &candidate.Device.PathName, &candidate.Device.Generation, &createdAt, &updatedAt, &candidate.FailureCount, &nextAttempt, ); err != nil { rows.Close() return nil, fmt.Errorf("scan due reconcile device: %w", err) } candidate.Device.CreatedAt, err = parseTime(createdAt) if err != nil { rows.Close() return nil, err } candidate.Device.UpdatedAt, err = parseTime(updatedAt) if err != nil { rows.Close() return nil, err } if nextAttempt.Valid { value, parseErr := parseTime(nextAttempt.String) if parseErr != nil { rows.Close() return nil, parseErr } candidate.NextAttempt = &value } candidates = append(candidates, candidate) } if err := rows.Close(); err != nil { return nil, fmt.Errorf("close due reconcile rows: %w", err) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate due reconcile devices: %w", err) } for index := range candidates { capabilities, err := s.capabilities(ctx, candidates[index].Device.ID) if err != nil { return nil, err } candidates[index].Device.Capabilities = capabilities } return candidates, nil } func (s *SQLite) ClaimDueReconcile(ctx context.Context, claim ReconcileClaim) ([]ReconcileCandidate, error) { // SQLite is retained for one-process M1 development. It deliberately does // not claim cross-process leases; PostgreSQL is the production M2 boundary. return s.ListDueReconcile(ctx, claim.Now, claim.Limit) } func (s *SQLite) RenewReconcileLease( ctx context.Context, _, _, _ string, _ time.Time, _ time.Duration, ) (bool, error) { if err := ctx.Err(); err != nil { return false, err } return true, nil } func (s *SQLite) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) { if limit <= 0 { return nil, nil } rows, err := s.db.QueryContext(ctx, deviceSelect+` WHERE d.desired_state = 'enabled' AND EXISTS ( SELECT 1 FROM sense_device_capabilities c WHERE c.device_id = d.id AND c.capability = 'video_capture' ) ORDER BY d.id LIMIT ?`, limit) if err != nil { return nil, fmt.Errorf("list enabled video devices: %w", err) } values := make([]device.Device, 0) for rows.Next() { value, err := scanDevice(rows) if err != nil { rows.Close() return nil, fmt.Errorf("scan enabled video device: %w", err) } values = append(values, value) } if err := rows.Close(); err != nil { return nil, fmt.Errorf("close enabled video rows: %w", err) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate enabled video devices: %w", err) } for index := range values { capabilities, err := s.capabilities(ctx, values[index].ID) if err != nil { return nil, err } values[index].Capabilities = capabilities } return values, nil } func (s *SQLite) MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin reconciled update: %w", err) } defer tx.Rollback() result, err := tx.ExecContext(ctx, ` UPDATE sense_reconcile_state SET failure_count = 0, next_attempt_at = NULL, last_error_code = NULL, observed_generation = ?, updated_at = ? WHERE device_id = ?`, generation, formatTime(now), id) if err != nil { return fmt.Errorf("mark device reconciled: %w", err) } if affected, _ := result.RowsAffected(); affected != 1 { return ErrNotFound } if _, err := tx.ExecContext(ctx, ` UPDATE sense_devices SET actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END, updated_at = ? WHERE id = ?`, formatTime(now), id); err != nil { return fmt.Errorf("mark reconciled device pending: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit reconciled update: %w", err) } return nil } func (s *SQLite) CompleteReconcile( ctx context.Context, id string, generation int64, _, _ string, now time.Time, ) error { return s.MarkReconciled(ctx, id, generation, now) } func (s *SQLite) MarkReconcileFailure(ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode string, now time.Time) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin reconcile failure update: %w", err) } defer tx.Rollback() result, err := tx.ExecContext(ctx, ` UPDATE sense_reconcile_state SET failure_count = ?, next_attempt_at = ?, last_error_code = ?, updated_at = ? WHERE device_id = ?`, failureCount, formatTime(nextAttempt), errorCode, formatTime(now), id) if err != nil { return fmt.Errorf("mark reconcile failure: %w", err) } if affected, _ := result.RowsAffected(); affected != 1 { return ErrNotFound } if _, err := tx.ExecContext(ctx, ` UPDATE sense_devices SET actual_state = 'failed', updated_at = ? WHERE id = ?`, formatTime(now), id); err != nil { return fmt.Errorf("mark failed device state: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit reconcile failure update: %w", err) } return nil } func (s *SQLite) FailReconcile( ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode, _, _ string, now time.Time, ) error { return s.MarkReconcileFailure(ctx, id, failureCount, nextAttempt, errorCode, now) } func (s *SQLite) UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error { if state != device.ActualPending && state != device.ActualOnline && state != device.ActualOffline && state != device.ActualFailed { return fmt.Errorf("invalid actual state %q", state) } result, err := s.db.ExecContext(ctx, `UPDATE sense_devices SET actual_state = ?, updated_at = ? WHERE id = ?`, state, formatTime(now), id) if err != nil { return fmt.Errorf("update actual state: %w", err) } if affected, _ := result.RowsAffected(); affected != 1 { return ErrNotFound } return nil } // RequestReconcile invalidates the observed generation without changing the // desired state or retry backoff. Runtime probes use it when MediaMTX loses a // configured path, including after a MediaMTX process restart. func (s *SQLite) RequestReconcile(ctx context.Context, id string, now time.Time) error { result, err := s.db.ExecContext(ctx, ` UPDATE sense_reconcile_state SET observed_generation = 0, updated_at = ? WHERE device_id = ?`, formatTime(now), id) if err != nil { return fmt.Errorf("request device reconciliation: %w", err) } if affected, _ := result.RowsAffected(); affected != 1 { return ErrNotFound } return nil } // ConvergenceSnapshot returns only identifiers, state and counters. Endpoint // and credential references are deliberately excluded from diagnostics. func (s *SQLite) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot, error) { rows, err := s.db.QueryContext(ctx, ` SELECT d.id, d.path_name, d.desired_state, d.actual_state, d.generation, r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code FROM sense_devices d JOIN sense_reconcile_state r ON r.device_id = d.id WHERE d.desired_state = 'enabled' AND EXISTS ( SELECT 1 FROM sense_device_capabilities c WHERE c.device_id = d.id AND c.capability = 'video_capture' ) ORDER BY d.id`) if err != nil { return ConvergenceSnapshot{}, fmt.Errorf("query convergence snapshot: %w", err) } defer rows.Close() snapshot := ConvergenceSnapshot{Devices: make([]DeviceConvergence, 0)} for rows.Next() { var value DeviceConvergence var nextAttempt, lastError sql.NullString if err := rows.Scan( &value.ID, &value.PathName, &value.DesiredState, &value.ActualState, &value.Generation, &value.ObservedGeneration, &value.FailureCount, &nextAttempt, &lastError, ); err != nil { return ConvergenceSnapshot{}, fmt.Errorf("scan convergence snapshot: %w", err) } if nextAttempt.Valid { parsed, parseErr := parseTime(nextAttempt.String) if parseErr != nil { return ConvergenceSnapshot{}, parseErr } value.NextAttemptAt = &parsed } if lastError.Valid { value.LastErrorCode = lastError.String } value.Converged = value.ObservedGeneration == value.Generation && value.FailureCount == 0 && value.ActualState == device.ActualOnline if !value.Converged { snapshot.Unconverged++ } snapshot.Devices = append(snapshot.Devices, value) } if err := rows.Err(); err != nil { return ConvergenceSnapshot{}, fmt.Errorf("iterate convergence snapshot: %w", err) } snapshot.Total = len(snapshot.Devices) return snapshot, nil } const deviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality, d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref, d.path_name, d.generation, d.created_at, d.updated_at` const deviceSelect = `SELECT ` + deviceColumns + ` FROM sense_devices d` type scanner interface { Scan(dest ...any) error } func scanDevice(row scanner) (device.Device, error) { var value device.Device var createdAt, updatedAt string err := row.Scan( &value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber, &value.Name, &value.Modality, &value.DesiredState, &value.ActualState, &value.EndpointRef, &value.CredentialRef, &value.PathName, &value.Generation, &createdAt, &updatedAt, ) if err != nil { return device.Device{}, err } value.CreatedAt, err = parseTime(createdAt) if err != nil { return device.Device{}, err } value.UpdatedAt, err = parseTime(updatedAt) if err != nil { return device.Device{}, err } return value, nil } func (s *SQLite) capabilities(ctx context.Context, id string) ([]device.Capability, error) { rows, err := s.db.QueryContext(ctx, `SELECT capability FROM sense_device_capabilities WHERE device_id = ? ORDER BY capability`, id) if err != nil { return nil, fmt.Errorf("list device capabilities: %w", err) } defer rows.Close() values := make([]device.Capability, 0) for rows.Next() { var value device.Capability if err := rows.Scan(&value); err != nil { return nil, fmt.Errorf("scan device capability: %w", err) } values = append(values, value) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate device capabilities: %w", err) } return values, nil } func sortedCapabilities(values []device.Capability) []device.Capability { result := append([]device.Capability(nil), values...) sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) return result } func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) } func parseTime(value string) (time.Time, error) { parsed, err := time.Parse(time.RFC3339Nano, value) if err != nil { return time.Time{}, fmt.Errorf("parse stored timestamp: %w", err) } return parsed, nil }