package database import ( "context" "errors" "hash/fnv" "time" "github.com/jackc/pgx/v5" "git.unkin.net/unkin/artifactapi/internal/provider" "git.unkin.net/unkin/artifactapi/pkg/models" ) // ListGitHubRPMRemotes returns every github_rpm remote so the syncer can sweep // them on each poll tick. func (db *DB) ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error) { rows, err := db.Pool.Query(ctx, `SELECT `+remoteCols+` FROM remotes WHERE package_type = $1 ORDER BY name`, models.PackageGitHubRPM) if err != nil { return nil, err } defer rows.Close() var remotes []models.Remote for rows.Next() { var r models.Remote if err := scanRemote(rows, &r); err != nil { return nil, err } remotes = append(remotes, r) } return remotes, rows.Err() } // ClaimGitHubSyncLease atomically claims the per-remote github_rpm sync lease. // See claimSyncLease. func (db *DB) ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) { return db.claimSyncLease(ctx, "github_rpm_sync_state", remoteName, owner, freshness, lease) } // ReleaseGitHubSyncLease records a github_rpm scan outcome and frees the lease. // See releaseSyncLease. func (db *DB) ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error { return db.releaseSyncLease(ctx, "github_rpm_sync_state", remoteName, owner, res) } // claimSyncLease atomically claims a per-remote sync lease in table. It succeeds // (claimed=true) only when the remote is due and no live lease is held by // another replica. Due means: a pending retry after a failed scan has reached // next_retry_at, or, with no retry pending, the remote was never synced or not // since its latest slot boundary. Boundaries recur every freshness, offset by a // stable per-remote phase so remotes sharing a TTL are spread across it rather // than due together. A zero freshness (prime scans) ignores the recency gate // but still honours a pending retry's backoff. The returned etag is // the stored releases-list ETag, shared across replicas so a conditional request // can short-circuit an unchanged repo. func (db *DB) claimSyncLease(ctx context.Context, table, remoteName, owner string, freshness, lease time.Duration) (bool, string, error) { row := db.Pool.QueryRow(ctx, ` INSERT INTO `+table+` AS s (remote_name, sync_lease_owner, sync_lease_expires) VALUES ($1, $2, now() + make_interval(secs => $4)) ON CONFLICT (remote_name) DO UPDATE SET sync_lease_owner = $2, sync_lease_expires = now() + make_interval(secs => $4) WHERE (CASE WHEN s.next_retry_at IS NOT NULL THEN s.next_retry_at <= now() ELSE s.last_synced_at IS NULL OR s.last_synced_at < CASE WHEN $3 <= 0 THEN now() ELSE to_timestamp(floor((extract(epoch from now())::float8 - $5) / NULLIF($3, 0)) * $3 + $5) END END) AND (s.sync_lease_expires IS NULL OR s.sync_lease_expires < now()) RETURNING s.etag `, remoteName, owner, freshness.Seconds(), lease.Seconds(), syncPhase(remoteName, freshness).Seconds()) var etag string if err := row.Scan(&etag); err != nil { if errors.Is(err, pgx.ErrNoRows) { return false, "", nil } return false, "", err } return true, etag, nil } // syncPhase is remoteName's stable offset within period, so every replica // agrees on when each remote is due. func syncPhase(remoteName string, period time.Duration) time.Duration { if period <= 0 { return 0 } h := fnv.New64a() _, _ = h.Write([]byte(remoteName)) return time.Duration(h.Sum64() % uint64(period)) } // releaseSyncLease records a scan outcome and frees the lease; only the owning // replica may release. Success advances last_synced_at, persists the etag and // clears any retry. Failure leaves last_synced_at and etag untouched (the last // good metadata keeps serving) and schedules next_retry_at with exponential // backoff from the consecutive-failure count, never before res.RetryAt. func (db *DB) releaseSyncLease(ctx context.Context, table, remoteName, owner string, res provider.SyncResult) error { var retryAt *time.Time if !res.RetryAt.IsZero() { retryAt = &res.RetryAt } _, err := db.Pool.Exec(ctx, ` UPDATE `+table+` SET last_synced_at = CASE WHEN $3 THEN last_synced_at ELSE now() END, etag = CASE WHEN $3 THEN etag ELSE $4 END, sync_failures = CASE WHEN $3 THEN sync_failures + 1 ELSE 0 END, next_retry_at = CASE WHEN $3 THEN GREATEST( now() + make_interval(secs => LEAST($5 * power(2, LEAST(sync_failures, 20)), $6)), $7::timestamptz) END, sync_lease_owner = '', sync_lease_expires = NULL WHERE remote_name = $1 AND sync_lease_owner = $2 `, remoteName, owner, res.Failed, res.Etag, res.Backoff.Seconds(), res.MaxBackoff.Seconds(), retryAt) return err }