feat(sense): add reconciliation safety controls [T-012]
This commit is contained in:
@@ -514,6 +514,7 @@ func (s *Postgres) PatchControlDevice(
|
||||
if reconcileChanged {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||
lease_owner = NULL, lease_token = NULL, lease_until = NULL,
|
||||
updated_at = $1 WHERE device_id = $2`, now, deviceID); err != nil {
|
||||
return ControlMutationResult{}, errors.New("reset postgres Control API reconcile state")
|
||||
}
|
||||
@@ -631,6 +632,7 @@ func setControlDesiredStateTx(
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||
lease_owner = NULL, lease_token = NULL, lease_until = NULL,
|
||||
updated_at = $1 WHERE device_id = $2`, now, deviceID); err != nil {
|
||||
return ControlDevice{}, errors.New("reset postgres Control API desired-state reconciliation")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
OperationalLeaseOrphanScan = "mediamtx-orphan-scan"
|
||||
OperationalLeaseOrphanCleanup = "mediamtx-orphan-cleanup"
|
||||
|
||||
OrphanOwnedStale = "owned_stale"
|
||||
OrphanUnowned = "unowned"
|
||||
)
|
||||
|
||||
var ErrOrphanScanNotFound = errors.New("orphan scan not found")
|
||||
|
||||
type MediaPathOwnership struct {
|
||||
PathName string
|
||||
DeviceID string
|
||||
CurrentClaim bool
|
||||
}
|
||||
|
||||
type OrphanFinding struct {
|
||||
PathName string
|
||||
Classification string
|
||||
DeviceID string
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
type OrphanScan struct {
|
||||
ID string
|
||||
InstanceID string
|
||||
ObservedCount int
|
||||
OwnedStaleCount int
|
||||
UnownedCount int
|
||||
SafetyAllowed bool
|
||||
SafetyReason string
|
||||
CompletedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Findings []OrphanFinding
|
||||
}
|
||||
|
||||
type OrphanRepository interface {
|
||||
AcquireOperationalLease(
|
||||
context.Context, string, string, string, time.Time, time.Duration,
|
||||
) (bool, error)
|
||||
ReleaseOperationalLease(context.Context, string, string, string, time.Time) error
|
||||
ListMediaPathOwnership(context.Context) ([]MediaPathOwnership, error)
|
||||
SaveOrphanScan(context.Context, OrphanScan, string, string) error
|
||||
GetOrphanScan(context.Context, string) (OrphanScan, error)
|
||||
RecordOrphanCleanup(
|
||||
context.Context, string, string, string, string, string, time.Time,
|
||||
) error
|
||||
}
|
||||
|
||||
var _ OrphanRepository = (*Postgres)(nil)
|
||||
@@ -0,0 +1,268 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const orphanReportRetention = 7 * 24 * time.Hour
|
||||
|
||||
func (s *Postgres) AcquireOperationalLease(
|
||||
ctx context.Context,
|
||||
name, owner, token string,
|
||||
_ time.Time,
|
||||
duration time.Duration,
|
||||
) (bool, error) {
|
||||
if strings.TrimSpace(name) == "" || strings.TrimSpace(owner) == "" ||
|
||||
strings.TrimSpace(token) == "" || duration <= 0 {
|
||||
return false, errors.New("invalid operational lease")
|
||||
}
|
||||
var acquired int
|
||||
err := s.db.QueryRowContext(ctx, `INSERT INTO sense.operational_leases(
|
||||
lease_name, owner_id, fencing_token, lease_until, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3,
|
||||
clock_timestamp() + ($4 * interval '1 second'), clock_timestamp()
|
||||
)
|
||||
ON CONFLICT (lease_name) DO UPDATE SET
|
||||
owner_id = EXCLUDED.owner_id,
|
||||
fencing_token = EXCLUDED.fencing_token,
|
||||
lease_until = EXCLUDED.lease_until,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE sense.operational_leases.lease_until <= clock_timestamp()
|
||||
OR (sense.operational_leases.owner_id = $2
|
||||
AND sense.operational_leases.fencing_token = $3)
|
||||
RETURNING 1`, name, owner, token, duration.Seconds()).Scan(&acquired)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, errors.New("acquire postgres operational lease")
|
||||
}
|
||||
return acquired == 1, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ReleaseOperationalLease(
|
||||
ctx context.Context,
|
||||
name, owner, token string,
|
||||
_ time.Time,
|
||||
) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE sense.operational_leases
|
||||
SET lease_until = clock_timestamp(), updated_at = clock_timestamp()
|
||||
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3`,
|
||||
name, owner, token)
|
||||
if err != nil {
|
||||
return errors.New("release postgres operational lease")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ListMediaPathOwnership(ctx context.Context) ([]MediaPathOwnership, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
o.path_name, o.device_id,
|
||||
EXISTS (
|
||||
SELECT 1 FROM sense.devices d
|
||||
WHERE d.id = o.device_id AND d.path_name = o.path_name
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sense.device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||
)
|
||||
) AS current_claim
|
||||
FROM sense.media_path_ownership o
|
||||
ORDER BY o.path_name`)
|
||||
if err != nil {
|
||||
return nil, errors.New("list postgres MediaMTX path ownership")
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]MediaPathOwnership, 0)
|
||||
for rows.Next() {
|
||||
var value MediaPathOwnership
|
||||
if err := rows.Scan(&value.PathName, &value.DeviceID, &value.CurrentClaim); err != nil {
|
||||
return nil, errors.New("scan postgres MediaMTX path ownership")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.New("iterate postgres MediaMTX path ownership")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) SaveOrphanScan(
|
||||
ctx context.Context,
|
||||
scan OrphanScan,
|
||||
owner, token string,
|
||||
) error {
|
||||
if err := validateOrphanScan(scan); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin postgres orphan scan save")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var lease int
|
||||
err = tx.QueryRowContext(ctx, `SELECT 1 FROM sense.operational_leases
|
||||
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3
|
||||
AND lease_until > clock_timestamp()
|
||||
FOR UPDATE`, OperationalLeaseOrphanScan, owner, token).Scan(&lease)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrOperationalLeaseLost
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("verify postgres orphan scan lease")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.orphan_scan_runs(
|
||||
id, instance_id, observed_count, owned_stale_count, unowned_count,
|
||||
safety_allowed, safety_reason, completed_at, expires_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
scan.ID, scan.InstanceID, scan.ObservedCount, scan.OwnedStaleCount, scan.UnownedCount,
|
||||
scan.SafetyAllowed, scan.SafetyReason, scan.CompletedAt, scan.ExpiresAt,
|
||||
); err != nil {
|
||||
return errors.New("insert postgres orphan scan")
|
||||
}
|
||||
for _, finding := range scan.Findings {
|
||||
var deviceID any
|
||||
if finding.DeviceID != "" {
|
||||
deviceID = finding.DeviceID
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.orphan_scan_findings(
|
||||
scan_id, path_name, classification, device_id
|
||||
) VALUES ($1,$2,$3,$4)`, scan.ID, finding.PathName, finding.Classification, deviceID); err != nil {
|
||||
return errors.New("insert postgres orphan scan finding")
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.operational_leases
|
||||
SET lease_until = clock_timestamp(), updated_at = clock_timestamp()
|
||||
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3`,
|
||||
OperationalLeaseOrphanScan, owner, token); err != nil {
|
||||
return errors.New("release postgres orphan scan lease")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sense.orphan_scan_runs r
|
||||
WHERE r.completed_at < $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sense.orphan_cleanup_actions a WHERE a.scan_id = r.id
|
||||
)`, scan.CompletedAt.Add(-orphanReportRetention)); err != nil {
|
||||
return errors.New("expire postgres orphan scan reports")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres orphan scan")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOrphanScan(scan OrphanScan) error {
|
||||
if strings.TrimSpace(scan.ID) == "" || strings.TrimSpace(scan.InstanceID) == "" ||
|
||||
scan.ObservedCount < 0 || scan.OwnedStaleCount < 0 || scan.UnownedCount < 0 ||
|
||||
scan.OwnedStaleCount+scan.UnownedCount > scan.ObservedCount ||
|
||||
strings.TrimSpace(scan.SafetyReason) == "" || !scan.ExpiresAt.After(scan.CompletedAt) {
|
||||
return errors.New("invalid orphan scan")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(scan.Findings))
|
||||
staleCount, unownedCount := 0, 0
|
||||
for _, finding := range scan.Findings {
|
||||
if strings.TrimSpace(finding.PathName) == "" {
|
||||
return errors.New("invalid orphan finding path")
|
||||
}
|
||||
if _, duplicate := seen[finding.PathName]; duplicate {
|
||||
return errors.New("duplicate orphan finding path")
|
||||
}
|
||||
seen[finding.PathName] = struct{}{}
|
||||
switch finding.Classification {
|
||||
case OrphanOwnedStale:
|
||||
staleCount++
|
||||
if strings.TrimSpace(finding.DeviceID) == "" {
|
||||
return errors.New("owned stale finding lacks device")
|
||||
}
|
||||
case OrphanUnowned:
|
||||
unownedCount++
|
||||
if finding.DeviceID != "" {
|
||||
return errors.New("unowned finding has device")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid orphan finding classification %q", finding.Classification)
|
||||
}
|
||||
}
|
||||
if staleCount != scan.OwnedStaleCount || unownedCount != scan.UnownedCount {
|
||||
return errors.New("orphan scan counts do not match findings")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) GetOrphanScan(ctx context.Context, id string) (OrphanScan, error) {
|
||||
var scan OrphanScan
|
||||
err := s.db.QueryRowContext(ctx, `SELECT
|
||||
id, instance_id, observed_count, owned_stale_count, unowned_count,
|
||||
safety_allowed, safety_reason, completed_at, expires_at
|
||||
FROM sense.orphan_scan_runs WHERE id = $1`, id).Scan(
|
||||
&scan.ID, &scan.InstanceID, &scan.ObservedCount, &scan.OwnedStaleCount,
|
||||
&scan.UnownedCount, &scan.SafetyAllowed, &scan.SafetyReason,
|
||||
&scan.CompletedAt, &scan.ExpiresAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return OrphanScan{}, ErrOrphanScanNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return OrphanScan{}, errors.New("read postgres orphan scan")
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
f.path_name, f.classification, COALESCE(f.device_id, ''),
|
||||
COALESCE(a.status = 'deleted', false)
|
||||
FROM sense.orphan_scan_findings f
|
||||
LEFT JOIN sense.orphan_cleanup_actions a
|
||||
ON a.scan_id = f.scan_id AND a.path_name = f.path_name
|
||||
WHERE f.scan_id = $1 ORDER BY f.path_name`, id)
|
||||
if err != nil {
|
||||
return OrphanScan{}, errors.New("list postgres orphan scan findings")
|
||||
}
|
||||
defer rows.Close()
|
||||
scan.Findings = make([]OrphanFinding, 0)
|
||||
for rows.Next() {
|
||||
var finding OrphanFinding
|
||||
if err := rows.Scan(
|
||||
&finding.PathName, &finding.Classification, &finding.DeviceID, &finding.Deleted,
|
||||
); err != nil {
|
||||
return OrphanScan{}, errors.New("scan postgres orphan finding")
|
||||
}
|
||||
scan.Findings = append(scan.Findings, finding)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return OrphanScan{}, errors.New("iterate postgres orphan findings")
|
||||
}
|
||||
return scan, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) RecordOrphanCleanup(
|
||||
ctx context.Context,
|
||||
scanID, pathName, actorID, status, errorCode string,
|
||||
now time.Time,
|
||||
) error {
|
||||
if status != "deleted" && status != "failed" {
|
||||
return errors.New("invalid orphan cleanup status")
|
||||
}
|
||||
var storedError any
|
||||
if status == "failed" {
|
||||
if strings.TrimSpace(errorCode) == "" {
|
||||
return errors.New("failed orphan cleanup requires an error code")
|
||||
}
|
||||
storedError = errorCode
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sense.orphan_cleanup_actions(
|
||||
scan_id, path_name, classification, actor_id, status, error_code, attempted_at
|
||||
) VALUES ($1,$2,'owned_stale',$3,$4,$5,$6)
|
||||
ON CONFLICT (scan_id, path_name) DO UPDATE SET
|
||||
actor_id = EXCLUDED.actor_id,
|
||||
status = EXCLUDED.status,
|
||||
error_code = EXCLUDED.error_code,
|
||||
attempted_at = EXCLUDED.attempted_at
|
||||
WHERE sense.orphan_cleanup_actions.status <> 'deleted'`,
|
||||
scanID, pathName, actorID, status, storedError, now)
|
||||
if err != nil {
|
||||
return errors.New("record postgres orphan cleanup result")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -53,8 +53,8 @@ func (s *Postgres) Close() error {
|
||||
func (s *Postgres) verifySchemaAndPrivileges(ctx context.Context) error {
|
||||
var version sql.NullInt64
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 4 {
|
||||
return errors.New("postgres sense schema migration v4 is required")
|
||||
`SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 5 {
|
||||
return errors.New("postgres sense schema migration v5 is required")
|
||||
}
|
||||
var canReadQuotaView, canWriteQuotaView, canReadSiteSource, canWriteSiteSource bool
|
||||
var canReadAreaView, canWriteAreaView, canReadAreaSource, canWriteAreaSource bool
|
||||
@@ -95,6 +95,28 @@ func (s *Postgres) verifySchemaAndPrivileges(ctx context.Context) error {
|
||||
publicReceipts || publicOperations || publicOperationItems {
|
||||
return errors.New("postgres role violates Control API state privilege boundary")
|
||||
}
|
||||
var canUseOwnership, canUseLeases, canUseScans, canUseFindings, canUseActions bool
|
||||
var publicOwnership, publicLeases, publicScans, publicFindings, publicActions bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user, 'sense.media_path_ownership', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'sense.operational_leases', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'sense.orphan_scan_runs', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'sense.orphan_scan_findings', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'sense.orphan_cleanup_actions', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.media_path_ownership', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.operational_leases', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.orphan_scan_runs', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.orphan_scan_findings', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.orphan_cleanup_actions', 'SELECT,INSERT,UPDATE,DELETE')`).Scan(
|
||||
&canUseOwnership, &canUseLeases, &canUseScans, &canUseFindings, &canUseActions,
|
||||
&publicOwnership, &publicLeases, &publicScans, &publicFindings, &publicActions,
|
||||
); err != nil {
|
||||
return errors.New("verify postgres reconciliation safety privileges")
|
||||
}
|
||||
if !canUseOwnership || !canUseLeases || !canUseScans || !canUseFindings || !canUseActions ||
|
||||
publicOwnership || publicLeases || publicScans || publicFindings || publicActions {
|
||||
return errors.New("postgres role violates reconciliation safety privilege boundary")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -391,7 +413,8 @@ func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired devic
|
||||
return errors.New("update postgres desired state")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL, updated_at = $1
|
||||
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||
lease_owner = NULL, lease_token = NULL, lease_until = NULL, updated_at = $1
|
||||
WHERE device_id = $2`, now, id); err != nil {
|
||||
return errors.New("reset postgres reconcile state")
|
||||
}
|
||||
@@ -486,6 +509,217 @@ func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit in
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ClaimDueReconcile(
|
||||
ctx context.Context,
|
||||
claim ReconcileClaim,
|
||||
) ([]ReconcileCandidate, error) {
|
||||
if claim.Limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(claim.Owner) == "" || strings.TrimSpace(claim.Token) == "" ||
|
||||
claim.LeaseDuration <= 0 {
|
||||
return nil, errors.New("invalid postgres reconcile claim")
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("begin postgres reconcile claim")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, 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 <= clock_timestamp())
|
||||
AND (r.lease_until IS NULL OR r.lease_until <= clock_timestamp())
|
||||
ORDER BY d.updated_at, d.id
|
||||
FOR UPDATE OF r SKIP LOCKED
|
||||
LIMIT $1`, claim.Limit)
|
||||
if err != nil {
|
||||
return nil, errors.New("select postgres reconcile claims")
|
||||
}
|
||||
values := make([]ReconcileCandidate, 0)
|
||||
for rows.Next() {
|
||||
var candidate ReconcileCandidate
|
||||
var areaID sql.NullString
|
||||
var quotaVersion, areaVersion sql.NullInt64
|
||||
var nextAttempt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
|
||||
&areaID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
||||
&candidate.Device.DesiredState, &candidate.Device.ActualState,
|
||||
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
|
||||
&candidate.Device.ProfileToken, &candidate.Device.PathName,
|
||||
&candidate.Device.Generation, &candidate.Device.ResourceVersion,
|
||||
"aVersion, &areaVersion,
|
||||
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
|
||||
&candidate.FailureCount, &nextAttempt,
|
||||
); err != nil {
|
||||
rows.Close()
|
||||
return nil, errors.New("scan postgres reconcile claim")
|
||||
}
|
||||
candidate.Device.AreaID = areaID.String
|
||||
candidate.Device.QuotaSourceVersion = quotaVersion.Int64
|
||||
candidate.Device.AreaPolicySourceVersion = areaVersion.Int64
|
||||
if nextAttempt.Valid {
|
||||
value := nextAttempt.Time
|
||||
candidate.NextAttempt = &value
|
||||
}
|
||||
values = append(values, candidate)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, errors.New("close postgres reconcile claims")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.New("iterate postgres reconcile claims")
|
||||
}
|
||||
for _, candidate := range values {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
lease_owner = $1, lease_token = $2,
|
||||
lease_until = clock_timestamp() + ($3 * interval '1 second'),
|
||||
updated_at = clock_timestamp()
|
||||
WHERE device_id = $4`,
|
||||
claim.Owner, claim.Token, claim.LeaseDuration.Seconds(), candidate.Device.ID,
|
||||
); err != nil {
|
||||
return nil, errors.New("persist postgres reconcile claim")
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.New("commit postgres reconcile claim")
|
||||
}
|
||||
for index := range values {
|
||||
values[index].Device.Capabilities, err = s.capabilities(ctx, values[index].Device.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) RenewReconcileLease(
|
||||
ctx context.Context,
|
||||
id, owner, token string,
|
||||
now time.Time,
|
||||
duration time.Duration,
|
||||
) (bool, error) {
|
||||
if strings.TrimSpace(owner) == "" || strings.TrimSpace(token) == "" || duration <= 0 {
|
||||
return false, errors.New("invalid postgres reconcile lease renewal")
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
lease_until = clock_timestamp() + ($1 * interval '1 second'),
|
||||
updated_at = clock_timestamp()
|
||||
WHERE device_id = $2 AND lease_owner = $3 AND lease_token = $4
|
||||
AND lease_until > clock_timestamp()`, duration.Seconds(), id, owner, token)
|
||||
if err != nil {
|
||||
return false, errors.New("renew postgres reconcile lease")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, errors.New("read postgres reconcile lease renewal")
|
||||
}
|
||||
return affected == 1, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) CompleteReconcile(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
generation int64,
|
||||
owner, token string,
|
||||
now time.Time,
|
||||
) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin postgres fenced reconciliation completion")
|
||||
}
|
||||
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 = $1, lease_owner = NULL, lease_token = NULL,
|
||||
lease_until = NULL, updated_at = $2
|
||||
WHERE device_id = $3 AND lease_owner = $4 AND lease_token = $5
|
||||
AND lease_until > clock_timestamp()`, generation, now, id, owner, token)
|
||||
if err != nil {
|
||||
return errors.New("complete postgres fenced reconciliation")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.New("read postgres fenced reconciliation completion")
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrReconcileLeaseLost
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.media_path_ownership(
|
||||
path_name, device_id, tenant_id, site_id, first_claimed_at, last_confirmed_at
|
||||
)
|
||||
SELECT path_name, id, tenant_id, site_id, $1, $1
|
||||
FROM sense.devices
|
||||
WHERE id = $2 AND desired_state = 'enabled' AND btrim(path_name) <> ''
|
||||
ON CONFLICT (path_name) DO UPDATE SET
|
||||
device_id = EXCLUDED.device_id,
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
site_id = EXCLUDED.site_id,
|
||||
last_confirmed_at = EXCLUDED.last_confirmed_at`, now, id); err != nil {
|
||||
return errors.New("record postgres MediaMTX path ownership")
|
||||
}
|
||||
result, err = tx.ExecContext(ctx, `UPDATE sense.devices
|
||||
SET actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
|
||||
updated_at = $1 WHERE id = $2`, now, id)
|
||||
if err != nil {
|
||||
return errors.New("mark postgres fenced device state")
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres fenced reconciliation completion")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) FailReconcile(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
failureCount int,
|
||||
nextAttempt time.Time,
|
||||
errorCode, owner, token string,
|
||||
now time.Time,
|
||||
) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin postgres fenced reconciliation failure")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = $1, next_attempt_at = $2, last_error_code = $3,
|
||||
lease_owner = NULL, lease_token = NULL, lease_until = NULL, updated_at = $4
|
||||
WHERE device_id = $5 AND lease_owner = $6 AND lease_token = $7
|
||||
AND lease_until > clock_timestamp()`, failureCount, nextAttempt, errorCode, now, id, owner, token)
|
||||
if err != nil {
|
||||
return errors.New("persist postgres fenced reconciliation failure")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.New("read postgres fenced reconciliation failure")
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrReconcileLeaseLost
|
||||
}
|
||||
result, err = tx.ExecContext(ctx, `UPDATE sense.devices
|
||||
SET actual_state = 'failed', updated_at = $1 WHERE id = $2`, now, id)
|
||||
if err != nil {
|
||||
return errors.New("mark postgres fenced failed device")
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres fenced reconciliation failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
|
||||
@@ -287,12 +287,12 @@ func TestPostgresOpenRejectsOverprivilegedRuntimeRole(t *testing.T) {
|
||||
_, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
if _, err := admin.ExecContext(ctx,
|
||||
`GRANT UPDATE ON bell.site_quota_v1 TO yovision_t011_sense`); err != nil {
|
||||
`GRANT UPDATE ON bell.site_quota_v1 TO yovision_t012_sense`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = admin.ExecContext(context.Background(),
|
||||
`REVOKE UPDATE ON bell.site_quota_v1 FROM yovision_t011_sense`)
|
||||
`REVOKE UPDATE ON bell.site_quota_v1 FROM yovision_t012_sense`)
|
||||
}()
|
||||
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
|
||||
if value != nil {
|
||||
@@ -304,6 +304,27 @@ func TestPostgresOpenRejectsOverprivilegedRuntimeRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresOpenRejectsPublicReconciliationStatePrivilege(t *testing.T) {
|
||||
_, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
if _, err := admin.ExecContext(ctx,
|
||||
`GRANT SELECT ON sense.orphan_scan_runs TO PUBLIC`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = admin.ExecContext(context.Background(),
|
||||
`REVOKE SELECT ON sense.orphan_scan_runs FROM PUBLIC`)
|
||||
}()
|
||||
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
|
||||
if value != nil {
|
||||
_ = value.Close()
|
||||
t.Fatal("PUBLIC reconciliation state privilege was accepted")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "reconciliation safety privilege boundary") {
|
||||
t.Fatalf("expected reconciliation privilege-boundary error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresAreaPolicyAllowsNonImagingAndDeniesImagingCreate(t *testing.T) {
|
||||
store, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
@@ -597,12 +618,12 @@ func TestPostgresOpenRejectsAreaSourcePrivilege(t *testing.T) {
|
||||
_, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
if _, err := admin.ExecContext(ctx,
|
||||
`GRANT SELECT ON bell.areas TO yovision_t011_sense`); err != nil {
|
||||
`GRANT SELECT ON bell.areas TO yovision_t012_sense`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = admin.ExecContext(context.Background(),
|
||||
`REVOKE SELECT ON bell.areas FROM yovision_t011_sense`)
|
||||
`REVOKE SELECT ON bell.areas FROM yovision_t012_sense`)
|
||||
}()
|
||||
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
|
||||
if value != nil {
|
||||
@@ -969,6 +990,180 @@ func TestPostgresConcurrentControlBatchesUseStableDeviceLockOrder(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresConcurrentReconcileClaimHasOneWinner(t *testing.T) {
|
||||
first, admin := openPostgresTestStore(t)
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
if err := first.CreateDevice(context.Background(), videoDevice(1, "tenant", "site")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := OpenPostgres(context.Background(), os.Getenv(postgresTestDSNEnv))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = second.Close() })
|
||||
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||
start := make(chan struct{})
|
||||
counts := make(chan int, 2)
|
||||
errorsFound := make(chan error, 2)
|
||||
var wait sync.WaitGroup
|
||||
for index, repository := range []*Postgres{first, second} {
|
||||
wait.Add(1)
|
||||
go func(index int, repository *Postgres) {
|
||||
defer wait.Done()
|
||||
<-start
|
||||
values, err := repository.ClaimDueReconcile(context.Background(), ReconcileClaim{
|
||||
Owner: fmt.Sprintf("ins-%d", index), Token: fmt.Sprintf("token-%d", index),
|
||||
Now: now, LeaseDuration: 30 * time.Second, Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
errorsFound <- err
|
||||
return
|
||||
}
|
||||
counts <- len(values)
|
||||
}(index, repository)
|
||||
}
|
||||
close(start)
|
||||
wait.Wait()
|
||||
close(counts)
|
||||
close(errorsFound)
|
||||
for err := range errorsFound {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total, winners := 0, 0
|
||||
for count := range counts {
|
||||
total += count
|
||||
if count == 1 {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if total != 1 || winners != 1 {
|
||||
t.Fatalf("due row was not exclusively claimed: total=%d winners=%d", total, winners)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresExpiredReconcileLeaseFencesOldWorkerAndRecordsOwnership(t *testing.T) {
|
||||
postgres, admin := openPostgresTestStore(t)
|
||||
insertBellSite(t, admin, "tenant", "site", 2)
|
||||
value := videoDevice(1, "tenant", "site")
|
||||
if err := postgres.CreateDevice(context.Background(), value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||
first, err := postgres.ClaimDueReconcile(context.Background(), ReconcileClaim{
|
||||
Owner: "ins-a", Token: "token-a", Now: now, LeaseDuration: 30 * time.Second, Limit: 1,
|
||||
})
|
||||
if err != nil || len(first) != 1 {
|
||||
t.Fatalf("first claim failed: %+v %v", first, err)
|
||||
}
|
||||
early, err := postgres.ClaimDueReconcile(context.Background(), ReconcileClaim{
|
||||
Owner: "ins-b", Token: "token-b", Now: now.Add(10 * time.Second),
|
||||
LeaseDuration: 30 * time.Second, Limit: 1,
|
||||
})
|
||||
if err != nil || len(early) != 0 {
|
||||
t.Fatalf("live lease was stolen: %+v %v", early, err)
|
||||
}
|
||||
if _, err := admin.Exec(`UPDATE sense.reconcile_state
|
||||
SET lease_until = clock_timestamp() - interval '1 second'
|
||||
WHERE device_id = $1`, value.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := postgres.ClaimDueReconcile(context.Background(), ReconcileClaim{
|
||||
Owner: "ins-b", Token: "token-b", Now: now.Add(31 * time.Second),
|
||||
LeaseDuration: 30 * time.Second, Limit: 1,
|
||||
})
|
||||
if err != nil || len(second) != 1 {
|
||||
t.Fatalf("expired lease was not recoverable: %+v %v", second, err)
|
||||
}
|
||||
if err := postgres.CompleteReconcile(
|
||||
context.Background(), value.ID, value.Generation, "ins-a", "token-a", now.Add(32*time.Second),
|
||||
); !errors.Is(err, ErrReconcileLeaseLost) {
|
||||
t.Fatalf("old worker was not fenced: %v", err)
|
||||
}
|
||||
if err := postgres.CompleteReconcile(
|
||||
context.Background(), value.ID, value.Generation, "ins-b", "token-b", now.Add(32*time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ownershipDevice string
|
||||
if err := admin.QueryRow(`SELECT device_id FROM sense.media_path_ownership WHERE path_name = $1`,
|
||||
value.PathName).Scan(&ownershipDevice); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ownershipDevice != value.ID {
|
||||
t.Fatalf("wrong ownership was recorded: %q", ownershipDevice)
|
||||
}
|
||||
var leaseToken sql.NullString
|
||||
if err := admin.QueryRow(`SELECT lease_token FROM sense.reconcile_state WHERE device_id = $1`,
|
||||
value.ID).Scan(&leaseToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if leaseToken.Valid {
|
||||
t.Fatal("completion did not release the reconcile lease")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresOrphanReportLeaseAndCleanupAuditAreFencedAndIdempotent(t *testing.T) {
|
||||
postgres, _ := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||
acquired, err := postgres.AcquireOperationalLease(
|
||||
ctx, OperationalLeaseOrphanScan, "ins-a", "scan-token-a", now, 30*time.Second,
|
||||
)
|
||||
if err != nil || !acquired {
|
||||
t.Fatalf("scan lease failed: %v %v", acquired, err)
|
||||
}
|
||||
scan := OrphanScan{
|
||||
ID: "scan_" + strings.Repeat("0", 26), InstanceID: "ins-a",
|
||||
ObservedCount: 10, OwnedStaleCount: 1, UnownedCount: 1,
|
||||
SafetyAllowed: true, SafetyReason: "allowed",
|
||||
CompletedAt: now.Add(time.Second), ExpiresAt: now.Add(15 * time.Minute),
|
||||
Findings: []OrphanFinding{
|
||||
{PathName: "stale", Classification: OrphanOwnedStale, DeviceID: "old-device"},
|
||||
{PathName: "unknown", Classification: OrphanUnowned},
|
||||
},
|
||||
}
|
||||
if err := postgres.SaveOrphanScan(ctx, scan, "ins-a", "scan-token-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := postgres.GetOrphanScan(ctx, scan.ID)
|
||||
if err != nil || len(loaded.Findings) != 2 || !loaded.SafetyAllowed {
|
||||
t.Fatalf("stored scan mismatch: %+v %v", loaded, err)
|
||||
}
|
||||
if err := postgres.RecordOrphanCleanup(
|
||||
ctx, scan.ID, "stale", "operator", "deleted", "", now.Add(2*time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := postgres.RecordOrphanCleanup(
|
||||
ctx, scan.ID, "unknown", "operator", "deleted", "", now.Add(2*time.Second),
|
||||
); err == nil {
|
||||
t.Fatal("unowned path accepted a cleanup audit record")
|
||||
}
|
||||
if err := postgres.RecordOrphanCleanup(
|
||||
ctx, scan.ID, "stale", "operator-2", "failed", "media_error", now.Add(3*time.Second),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err = postgres.GetOrphanScan(ctx, scan.ID)
|
||||
if err != nil || !loaded.Findings[0].Deleted {
|
||||
t.Fatalf("successful cleanup was downgraded: %+v %v", loaded, err)
|
||||
}
|
||||
|
||||
acquired, err = postgres.AcquireOperationalLease(
|
||||
ctx, OperationalLeaseOrphanScan, "ins-b", "scan-token-b", now.Add(31*time.Second), 30*time.Second,
|
||||
)
|
||||
if err != nil || !acquired {
|
||||
t.Fatalf("expired scan lease was not recoverable: %v %v", acquired, err)
|
||||
}
|
||||
staleScan := scan
|
||||
staleScan.ID = "scan_" + strings.Repeat("1", 26)
|
||||
staleScan.CompletedAt = now.Add(32 * time.Second)
|
||||
staleScan.ExpiresAt = staleScan.CompletedAt.Add(15 * time.Minute)
|
||||
if err := postgres.SaveOrphanScan(ctx, staleScan, "ins-a", "scan-token-a"); !errors.Is(err, ErrOperationalLeaseLost) {
|
||||
t.Fatalf("stale scan worker was not fenced: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv(postgresTestDSNEnv)
|
||||
@@ -985,6 +1180,11 @@ func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) {
|
||||
t.Fatal("connect PostgreSQL test administrator")
|
||||
}
|
||||
if _, err := admin.ExecContext(context.Background(), `TRUNCATE
|
||||
sense.orphan_cleanup_actions,
|
||||
sense.orphan_scan_findings,
|
||||
sense.orphan_scan_runs,
|
||||
sense.operational_leases,
|
||||
sense.media_path_ownership,
|
||||
sense.control_idempotency_receipts,
|
||||
sense.batch_operation_items,
|
||||
sense.batch_operations,
|
||||
|
||||
@@ -21,6 +21,8 @@ var (
|
||||
ErrAreaPolicyUnavailable = errors.New("area policy unavailable")
|
||||
ErrAreaPolicyInvalid = errors.New("area policy invalid")
|
||||
ErrAreaPolicyDenied = errors.New("area policy denies imaging device")
|
||||
ErrReconcileLeaseLost = errors.New("reconcile lease lost")
|
||||
ErrOperationalLeaseLost = errors.New("operational lease lost")
|
||||
)
|
||||
|
||||
// Repository is the storage boundary used by the Sense process. SQLite stays
|
||||
@@ -31,6 +33,10 @@ type Repository interface {
|
||||
SetDesiredState(context.Context, string, device.DesiredState) error
|
||||
GetDevice(context.Context, string) (device.Device, error)
|
||||
ListDueReconcile(context.Context, time.Time, int) ([]ReconcileCandidate, error)
|
||||
ClaimDueReconcile(context.Context, ReconcileClaim) ([]ReconcileCandidate, error)
|
||||
RenewReconcileLease(context.Context, string, string, string, time.Time, time.Duration) (bool, error)
|
||||
CompleteReconcile(context.Context, string, int64, string, string, time.Time) error
|
||||
FailReconcile(context.Context, string, int, time.Time, string, string, string, time.Time) error
|
||||
ListEnabledVideoDevices(context.Context, int) ([]device.Device, error)
|
||||
MarkReconciled(context.Context, string, int64, time.Time) error
|
||||
MarkReconcileFailure(context.Context, string, int, time.Time, string, time.Time) error
|
||||
@@ -39,6 +45,17 @@ type Repository interface {
|
||||
ConvergenceSnapshot(context.Context) (ConvergenceSnapshot, error)
|
||||
}
|
||||
|
||||
// ReconcileClaim identifies one short-lived batch claim. Token is unique per
|
||||
// run and fences a worker whose lease expired and was acquired by another
|
||||
// process. SQLite accepts the shape but remains explicitly single-process.
|
||||
type ReconcileClaim struct {
|
||||
Owner string
|
||||
Token string
|
||||
Now time.Time
|
||||
LeaseDuration time.Duration
|
||||
Limit int
|
||||
}
|
||||
|
||||
func OpenRepository(ctx context.Context, driver, dsn string) (Repository, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(driver)) {
|
||||
case "", DriverSQLite:
|
||||
|
||||
@@ -430,6 +430,24 @@ func (s *SQLite) ListDueReconcile(ctx context.Context, now time.Time, limit int)
|
||||
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
|
||||
@@ -499,6 +517,16 @@ func (s *SQLite) MarkReconciled(ctx context.Context, id string, generation int64
|
||||
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 {
|
||||
@@ -526,6 +554,17 @@ func (s *SQLite) MarkReconcileFailure(ctx context.Context, id string, failureCou
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user