Add per-remote sync_interval and spread GitHub syncs across it
This commit is contained in:
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -47,9 +48,11 @@ func (db *DB) ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner stri
|
||||
// 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 was
|
||||
// synced longer than freshness ago. A zero freshness (prime scans) ignores the
|
||||
// recency gate but still honours a pending retry's backoff. The returned etag is
|
||||
// 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) {
|
||||
@@ -60,10 +63,11 @@ func (db *DB) claimSyncLease(ctx context.Context, table, remoteName, owner strin
|
||||
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 < now() - make_interval(secs => $3) END)
|
||||
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())
|
||||
`, remoteName, owner, freshness.Seconds(), lease.Seconds(), syncPhase(remoteName, freshness).Seconds())
|
||||
|
||||
var etag string
|
||||
if err := row.Scan(&etag); err != nil {
|
||||
@@ -75,6 +79,17 @@ func (db *DB) claimSyncLease(ctx context.Context, table, remoteName, owner strin
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -276,3 +277,86 @@ func TestGitHubSyncStateDeletedWithRemote(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPhaseStableAndSpread(t *testing.T) {
|
||||
const period = 5 * time.Minute
|
||||
phases := map[time.Duration]bool{}
|
||||
var lo, hi time.Duration = period, 0
|
||||
for i := 0; i < 13; i++ {
|
||||
name := fmt.Sprintf("github-rpm-%02d", i)
|
||||
p := syncPhase(name, period)
|
||||
if p != syncPhase(name, period) {
|
||||
t.Fatalf("%s: phase not stable", name)
|
||||
}
|
||||
if p < 0 || p >= period {
|
||||
t.Fatalf("%s: phase %v outside [0,%v)", name, p, period)
|
||||
}
|
||||
phases[p.Truncate(time.Second)] = true
|
||||
lo, hi = min(lo, p), max(hi, p)
|
||||
}
|
||||
if len(phases) != 13 {
|
||||
t.Fatalf("phases collide: %d distinct of 13", len(phases))
|
||||
}
|
||||
if hi-lo < period/2 {
|
||||
t.Fatalf("phases bunched in %v of %v", hi-lo, period)
|
||||
}
|
||||
if syncPhase("x", 0) != 0 {
|
||||
t.Fatal("zero period must give zero phase")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitHubSyncLeasePhasedSlots checks a remote is due once per slot: synced
|
||||
// before its latest boundary (or over a TTL ago) it is claimable by exactly one
|
||||
// replica; synced after the boundary it is not.
|
||||
func TestGitHubSyncLeasePhasedSlots(t *testing.T) {
|
||||
requireDB(t)
|
||||
const lease = 15 * time.Minute
|
||||
ttl := time.Hour
|
||||
|
||||
setSynced := func(name string, at time.Time) {
|
||||
t.Helper()
|
||||
if _, err := testDB.Pool.Exec(ctx(), `UPDATE github_rpm_sync_state SET last_synced_at = $2, sync_lease_owner = '', sync_lease_expires = NULL WHERE remote_name = $1`, name, at); err != nil {
|
||||
t.Fatalf("set last_synced_at: %v", err)
|
||||
}
|
||||
}
|
||||
claim := func(name, owner string) bool {
|
||||
t.Helper()
|
||||
ok, _, err := testDB.ClaimGitHubSyncLease(ctx(), name, owner, ttl, lease)
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
name := fmt.Sprintf("gh-slot-%d-%s", i, time.Now().Format("150405.000000"))
|
||||
seedGitHubRPMRemote(t, name)
|
||||
if !claim(name, "replica-1") {
|
||||
t.Fatalf("%s: first claim failed", name)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
phase := syncPhase(name, ttl)
|
||||
boundary := now.Add(-phase).Truncate(ttl).Add(phase)
|
||||
|
||||
setSynced(name, now.Add(-ttl-time.Second))
|
||||
if !claim(name, "replica-1") {
|
||||
t.Fatalf("%s: not due after a full TTL", name)
|
||||
}
|
||||
if claim(name, "replica-2") {
|
||||
t.Fatalf("%s: second replica claimed a held lease", name)
|
||||
}
|
||||
|
||||
setSynced(name, boundary.Add(-5*time.Second))
|
||||
if !claim(name, "replica-2") {
|
||||
t.Fatalf("%s: not due after crossing its boundary %v", name, boundary)
|
||||
}
|
||||
|
||||
if now.Sub(boundary) > 10*time.Second {
|
||||
setSynced(name, boundary.Add(5*time.Second))
|
||||
if claim(name, "replica-1") {
|
||||
t.Fatalf("%s: re-synced within the same slot", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
const remoteCols = `name, package_type, repo_type, base_url, mirrorlist, mirror_strategy, description, username, password,
|
||||
immutable_ttl, mutable_ttl, check_mutable,
|
||||
immutable_ttl, mutable_ttl, check_mutable, sync_interval,
|
||||
patterns, blocklist, mutable_patterns, immutable_patterns,
|
||||
ban_tags_enabled, ban_tags,
|
||||
quarantine_enabled, quarantine_days, stale_on_error,
|
||||
@@ -29,7 +29,7 @@ func normalizeMirrorStrategy(s string) string {
|
||||
func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error {
|
||||
return scanner.Scan(
|
||||
&r.Name, &r.PackageType, &r.RepoType, &r.BaseURL, &r.Mirrorlist, &r.MirrorStrategy, &r.Description, &r.Username, &r.Password,
|
||||
&r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable,
|
||||
&r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable, &r.SyncInterval,
|
||||
&r.Patterns, &r.Blocklist, &r.MutablePatterns, &r.ImmutablePatterns,
|
||||
&r.BanTagsEnabled, &r.BanTags,
|
||||
&r.QuarantineEnabled, &r.QuarantineDays, &r.StaleOnError,
|
||||
@@ -76,8 +76,8 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
|
||||
quarantine_enabled, quarantine_days, stale_on_error,
|
||||
releases_remote, managed_by,
|
||||
upstream_dial_timeout, upstream_tls_timeout, upstream_response_header_timeout,
|
||||
mirror_strategy
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26)
|
||||
mirror_strategy, sync_interval
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27)
|
||||
`,
|
||||
r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Mirrorlist, r.Description, r.Username, r.Password,
|
||||
r.ImmutableTTL, r.MutableTTL, r.CheckMutable,
|
||||
@@ -86,7 +86,7 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
|
||||
r.QuarantineEnabled, r.QuarantineDays, r.StaleOnError,
|
||||
r.ReleasesRemote, r.ManagedBy,
|
||||
r.UpstreamDialTimeout, r.UpstreamTLSTimeout, r.UpstreamResponseHeaderTimeout,
|
||||
normalizeMirrorStrategy(r.MirrorStrategy),
|
||||
normalizeMirrorStrategy(r.MirrorStrategy), r.SyncInterval,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
|
||||
quarantine_enabled=$17, quarantine_days=$18, stale_on_error=$19,
|
||||
releases_remote=$20, managed_by=$21,
|
||||
upstream_dial_timeout=$22, upstream_tls_timeout=$23, upstream_response_header_timeout=$24,
|
||||
mirror_strategy=$26,
|
||||
mirror_strategy=$26, sync_interval=$27,
|
||||
updated_at=NOW()
|
||||
WHERE name=$1
|
||||
`,
|
||||
@@ -113,7 +113,7 @@ func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
|
||||
r.ReleasesRemote, r.ManagedBy,
|
||||
r.UpstreamDialTimeout, r.UpstreamTLSTimeout, r.UpstreamResponseHeaderTimeout,
|
||||
r.Mirrorlist,
|
||||
normalizeMirrorStrategy(r.MirrorStrategy),
|
||||
normalizeMirrorStrategy(r.MirrorStrategy), r.SyncInterval,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user