Add per-remote sync_interval and spread GitHub syncs (#166)
ci/woodpecker/tag/docker Pipeline was successful
ci/woodpecker/tag/docker Pipeline was successful
github_* remotes sharing a mutable_ttl all become due at the same moment, so many remotes created or synced together burst GitHub at once. - add per-remote `sync_interval` (seconds) for github_rpm/github_deb/github_alpine remotes - offset each remote's slots by a stable name-hashed phase so remotes on one interval spread out - compute slots and the first-sync gate in the lease claim from the DB clock so replicas agree - wait for the first slot on a scheduled first sync and skip the create-time prime; cold-cache primes stay immediate - keep the mutable_ttl freshness predicate unchanged when `sync_interval` is unset - document `sync_interval` in README, rpm docs and UI types Reviewed-on: #166 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
This commit was merged in pull request #166.
This commit is contained in:
@@ -27,7 +27,7 @@ const (
|
||||
type SyncStore interface {
|
||||
provider.RemoteMetadataStore
|
||||
ListGitHubAlpineRemotes(ctx context.Context) ([]models.Remote, error)
|
||||
ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
||||
ClaimGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (claimed bool, etag string, err error)
|
||||
ReleaseGitHubAlpineSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
|
||||
}
|
||||
|
||||
@@ -129,7 +129,8 @@ func (s *Syncer) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// schedule enqueues a periodic check for every github_alpine remote. The DB lease
|
||||
// enforces the per-remote mutable_ttl cadence and cross-replica coordination.
|
||||
// enforces the per-remote sync_interval (else mutable_ttl) cadence and
|
||||
// cross-replica coordination.
|
||||
func (s *Syncer) schedule(ctx context.Context) {
|
||||
remotes, err := s.store.ListGitHubAlpineRemotes(ctx)
|
||||
if err != nil {
|
||||
@@ -142,8 +143,10 @@ func (s *Syncer) schedule(ctx context.Context) {
|
||||
}
|
||||
|
||||
// EnqueuePrime queues an immediate background prime for a freshly created remote.
|
||||
// A sync_interval remote skips it and syncs in its first slot, so remotes
|
||||
// created together do not burst.
|
||||
func (s *Syncer) EnqueuePrime(remote models.Remote) {
|
||||
if s == nil {
|
||||
if s == nil || remote.SyncInterval > 0 {
|
||||
return
|
||||
}
|
||||
s.enqueue(remote, true)
|
||||
@@ -189,16 +192,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
ttl := time.Duration(job.remote.MutableTTL) * time.Second
|
||||
if ttl <= 0 {
|
||||
ttl = defaultSyncFreshness
|
||||
}
|
||||
ttl := job.remote.SyncPeriod(defaultSyncFreshness)
|
||||
freshness := ttl
|
||||
var interval, phase time.Duration
|
||||
if job.prime {
|
||||
freshness = 0
|
||||
} else {
|
||||
interval, phase = job.remote.SyncSchedule()
|
||||
}
|
||||
|
||||
claimed, etag, err := s.store.ClaimGitHubAlpineSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration)
|
||||
claimed, etag, err := s.store.ClaimGitHubAlpineSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration, interval, phase)
|
||||
if err != nil {
|
||||
slog.Error("github_alpine syncer: claim lease", "remote", job.remote.Name, "error", err)
|
||||
return
|
||||
|
||||
@@ -28,6 +28,10 @@ type fakeSyncStore struct {
|
||||
lastSynced map[string]time.Time
|
||||
etags map[string]string
|
||||
retryAt map[string]time.Time
|
||||
freshness time.Duration
|
||||
interval time.Duration
|
||||
phase time.Duration
|
||||
attempts int
|
||||
}
|
||||
|
||||
func newFakeSyncStore() *fakeSyncStore {
|
||||
@@ -47,9 +51,11 @@ func (f *fakeSyncStore) ListGitHubAlpineRemotes(_ context.Context) ([]models.Rem
|
||||
return append([]models.Remote(nil), f.remotes...), nil
|
||||
}
|
||||
|
||||
func (f *fakeSyncStore) ClaimGitHubAlpineSyncLease(_ context.Context, name, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||
func (f *fakeSyncStore) ClaimGitHubAlpineSyncLease(_ context.Context, name, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.freshness, f.interval, f.phase = freshness, interval, phase
|
||||
f.attempts++
|
||||
now := time.Now()
|
||||
ls, hasLS := f.lastSynced[name]
|
||||
exp, hasExp := f.leaseExp[name]
|
||||
@@ -223,7 +229,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
|
||||
s := newSyncer(store, p, testSyncConfig())
|
||||
remote := fx.remote()
|
||||
|
||||
claimed, _, err := store.ClaimGitHubAlpineSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration)
|
||||
claimed, _, err := store.ClaimGitHubAlpineSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration, 0, 0)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
@@ -363,3 +369,37 @@ func TestSyncerFailedAssetRetry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A periodic check claims with sync_interval as its freshness and passes the
|
||||
// remote's slot schedule; unset, it falls back to mutable_ttl with no schedule.
|
||||
func TestSyncerHonoursSyncInterval(t *testing.T) {
|
||||
for _, interval := range []int{0, 7200} {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
store.lastSynced["sched"] = time.Now()
|
||||
remote := models.Remote{Name: "sched", MutableTTL: 90, SyncInterval: interval}
|
||||
wantInterval, wantPhase := remote.SyncSchedule()
|
||||
s.process(context.Background(), syncJob{remote: remote})
|
||||
if want := remote.SyncPeriod(0); store.freshness != want || store.interval != wantInterval || store.phase != wantPhase {
|
||||
t.Errorf("sync_interval=%d: claim freshness/interval/phase = %v/%v/%v, want %v/%v/%v",
|
||||
interval, store.freshness, store.interval, store.phase, want, wantInterval, wantPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create-time primes are skipped for a scheduled remote so remotes created
|
||||
// together spread out; a cold-cache prime still claims with no schedule.
|
||||
func TestSyncerScheduledPrime(t *testing.T) {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
remote := models.Remote{Name: "fresh", SyncInterval: 3600}
|
||||
|
||||
s.EnqueuePrime(remote)
|
||||
if len(s.jobs) != 0 {
|
||||
t.Fatal("create-time prime enqueued for a sync_interval remote")
|
||||
}
|
||||
s.process(context.Background(), syncJob{remote: remote, prime: true})
|
||||
if store.attempts != 1 || store.freshness != 0 || store.interval != 0 {
|
||||
t.Fatalf("cold-cache prime: attempts=%d freshness=%v interval=%v, want 1/0/0", store.attempts, store.freshness, store.interval)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const (
|
||||
type SyncStore interface {
|
||||
provider.RemoteMetadataStore
|
||||
ListGitHubDebRemotes(ctx context.Context) ([]models.Remote, error)
|
||||
ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
||||
ClaimGitHubDebSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (claimed bool, etag string, err error)
|
||||
ReleaseGitHubDebSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
|
||||
}
|
||||
|
||||
@@ -129,7 +129,8 @@ func (s *Syncer) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// schedule enqueues a periodic check for every github_deb remote. The DB lease
|
||||
// enforces the per-remote mutable_ttl cadence and cross-replica coordination.
|
||||
// enforces the per-remote sync_interval (else mutable_ttl) cadence and
|
||||
// cross-replica coordination.
|
||||
func (s *Syncer) schedule(ctx context.Context) {
|
||||
remotes, err := s.store.ListGitHubDebRemotes(ctx)
|
||||
if err != nil {
|
||||
@@ -142,8 +143,10 @@ func (s *Syncer) schedule(ctx context.Context) {
|
||||
}
|
||||
|
||||
// EnqueuePrime queues an immediate background prime for a freshly created remote.
|
||||
// A sync_interval remote skips it and syncs in its first slot, so remotes
|
||||
// created together do not burst.
|
||||
func (s *Syncer) EnqueuePrime(remote models.Remote) {
|
||||
if s == nil {
|
||||
if s == nil || remote.SyncInterval > 0 {
|
||||
return
|
||||
}
|
||||
s.enqueue(remote, true)
|
||||
@@ -189,16 +192,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
ttl := time.Duration(job.remote.MutableTTL) * time.Second
|
||||
if ttl <= 0 {
|
||||
ttl = defaultSyncFreshness
|
||||
}
|
||||
ttl := job.remote.SyncPeriod(defaultSyncFreshness)
|
||||
freshness := ttl
|
||||
var interval, phase time.Duration
|
||||
if job.prime {
|
||||
freshness = 0
|
||||
} else {
|
||||
interval, phase = job.remote.SyncSchedule()
|
||||
}
|
||||
|
||||
claimed, etag, err := s.store.ClaimGitHubDebSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration)
|
||||
claimed, etag, err := s.store.ClaimGitHubDebSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration, interval, phase)
|
||||
if err != nil {
|
||||
slog.Error("github_deb syncer: claim lease", "remote", job.remote.Name, "error", err)
|
||||
return
|
||||
|
||||
@@ -28,6 +28,10 @@ type fakeSyncStore struct {
|
||||
lastSynced map[string]time.Time
|
||||
etags map[string]string
|
||||
retryAt map[string]time.Time
|
||||
freshness time.Duration
|
||||
interval time.Duration
|
||||
phase time.Duration
|
||||
attempts int
|
||||
}
|
||||
|
||||
func newFakeSyncStore() *fakeSyncStore {
|
||||
@@ -47,9 +51,11 @@ func (f *fakeSyncStore) ListGitHubDebRemotes(_ context.Context) ([]models.Remote
|
||||
return append([]models.Remote(nil), f.remotes...), nil
|
||||
}
|
||||
|
||||
func (f *fakeSyncStore) ClaimGitHubDebSyncLease(_ context.Context, name, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||
func (f *fakeSyncStore) ClaimGitHubDebSyncLease(_ context.Context, name, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.freshness, f.interval, f.phase = freshness, interval, phase
|
||||
f.attempts++
|
||||
now := time.Now()
|
||||
ls, hasLS := f.lastSynced[name]
|
||||
exp, hasExp := f.leaseExp[name]
|
||||
@@ -223,7 +229,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
|
||||
s := newSyncer(store, p, testSyncConfig())
|
||||
remote := fx.remote()
|
||||
|
||||
claimed, _, err := store.ClaimGitHubDebSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration)
|
||||
claimed, _, err := store.ClaimGitHubDebSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration, 0, 0)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
@@ -363,3 +369,37 @@ func TestSyncerFailedAssetRetry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A periodic check claims with sync_interval as its freshness and passes the
|
||||
// remote's slot schedule; unset, it falls back to mutable_ttl with no schedule.
|
||||
func TestSyncerHonoursSyncInterval(t *testing.T) {
|
||||
for _, interval := range []int{0, 7200} {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
store.lastSynced["sched"] = time.Now()
|
||||
remote := models.Remote{Name: "sched", MutableTTL: 90, SyncInterval: interval}
|
||||
wantInterval, wantPhase := remote.SyncSchedule()
|
||||
s.process(context.Background(), syncJob{remote: remote})
|
||||
if want := remote.SyncPeriod(0); store.freshness != want || store.interval != wantInterval || store.phase != wantPhase {
|
||||
t.Errorf("sync_interval=%d: claim freshness/interval/phase = %v/%v/%v, want %v/%v/%v",
|
||||
interval, store.freshness, store.interval, store.phase, want, wantInterval, wantPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create-time primes are skipped for a scheduled remote so remotes created
|
||||
// together spread out; a cold-cache prime still claims with no schedule.
|
||||
func TestSyncerScheduledPrime(t *testing.T) {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
remote := models.Remote{Name: "fresh", SyncInterval: 3600}
|
||||
|
||||
s.EnqueuePrime(remote)
|
||||
if len(s.jobs) != 0 {
|
||||
t.Fatal("create-time prime enqueued for a sync_interval remote")
|
||||
}
|
||||
s.process(context.Background(), syncJob{remote: remote, prime: true})
|
||||
if store.attempts != 1 || store.freshness != 0 || store.interval != 0 {
|
||||
t.Fatalf("cold-cache prime: attempts=%d freshness=%v interval=%v, want 1/0/0", store.attempts, store.freshness, store.interval)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
// scan never loses its lease, while a crashed replica's lease still expires.
|
||||
syncLeaseDuration = 15 * time.Minute
|
||||
// defaultSyncFreshness is the periodic re-check interval used when a remote's
|
||||
// mutable_ttl is unset.
|
||||
// sync_interval and mutable_ttl are unset.
|
||||
defaultSyncFreshness = 5 * time.Minute
|
||||
// jobQueueDepth bounds the pending work queue; enqueues past it are dropped
|
||||
// (a later poll re-enqueues), never blocking the caller.
|
||||
@@ -34,7 +34,7 @@ const (
|
||||
type SyncStore interface {
|
||||
provider.RemoteMetadataStore
|
||||
ListGitHubRPMRemotes(ctx context.Context) ([]models.Remote, error)
|
||||
ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease time.Duration) (claimed bool, etag string, err error)
|
||||
ClaimGitHubSyncLease(ctx context.Context, remoteName, owner string, freshness, lease, interval, phase time.Duration) (claimed bool, etag string, err error)
|
||||
ReleaseGitHubSyncLease(ctx context.Context, remoteName, owner string, res provider.SyncResult) error
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ type SyncConfig struct {
|
||||
RatePerSec float64 // global GitHub request rate (req/s)
|
||||
Burst int // token-bucket burst
|
||||
Workers int // concurrent scan workers
|
||||
PollInterval time.Duration // base scheduler tick; per-remote cadence is mutable_ttl
|
||||
PollInterval time.Duration // base scheduler tick; per-remote cadence is sync_interval or mutable_ttl
|
||||
}
|
||||
|
||||
type syncJob struct {
|
||||
@@ -139,9 +139,9 @@ func (s *Syncer) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// schedule enqueues a periodic check for every github_rpm remote. The DB lease
|
||||
// (claimed in the worker) enforces the per-remote mutable_ttl cadence and cross
|
||||
// replica coordination, so enqueuing every tick is cheap: a not-yet-due remote
|
||||
// simply fails to claim and is skipped.
|
||||
// (claimed in the worker) enforces the per-remote sync_interval (else
|
||||
// mutable_ttl) cadence and cross replica coordination, so enqueuing every tick
|
||||
// is cheap: a not-yet-due remote simply fails to claim and is skipped.
|
||||
func (s *Syncer) schedule(ctx context.Context) {
|
||||
remotes, err := s.store.ListGitHubRPMRemotes(ctx)
|
||||
if err != nil {
|
||||
@@ -155,8 +155,10 @@ func (s *Syncer) schedule(ctx context.Context) {
|
||||
|
||||
// EnqueuePrime queues an immediate background prime for a freshly created
|
||||
// remote so its metadata is derived without blocking the create call.
|
||||
// A sync_interval remote skips it and syncs in its first slot, so remotes
|
||||
// created together do not burst.
|
||||
func (s *Syncer) EnqueuePrime(remote models.Remote) {
|
||||
if s == nil {
|
||||
if s == nil || remote.SyncInterval > 0 {
|
||||
return
|
||||
}
|
||||
s.enqueue(remote, true)
|
||||
@@ -205,16 +207,16 @@ func (s *Syncer) process(ctx context.Context, job syncJob) {
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
ttl := time.Duration(job.remote.MutableTTL) * time.Second
|
||||
if ttl <= 0 {
|
||||
ttl = defaultSyncFreshness
|
||||
}
|
||||
ttl := job.remote.SyncPeriod(defaultSyncFreshness)
|
||||
freshness := ttl
|
||||
var interval, phase time.Duration
|
||||
if job.prime {
|
||||
freshness = 0 // prime ignores the recency gate but still respects a live lease
|
||||
} else {
|
||||
interval, phase = job.remote.SyncSchedule()
|
||||
}
|
||||
|
||||
claimed, etag, err := s.store.ClaimGitHubSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration)
|
||||
claimed, etag, err := s.store.ClaimGitHubSyncLease(ctx, job.remote.Name, s.owner, freshness, syncLeaseDuration, interval, phase)
|
||||
if err != nil {
|
||||
slog.Error("github_rpm syncer: claim lease", "remote", job.remote.Name, "error", err)
|
||||
return
|
||||
|
||||
@@ -29,6 +29,10 @@ type fakeSyncStore struct {
|
||||
etags map[string]string
|
||||
retryAt map[string]time.Time
|
||||
lastResult provider.SyncResult
|
||||
freshness time.Duration
|
||||
interval time.Duration
|
||||
phase time.Duration
|
||||
attempts int
|
||||
}
|
||||
|
||||
func newFakeSyncStore() *fakeSyncStore {
|
||||
@@ -48,9 +52,11 @@ func (f *fakeSyncStore) ListGitHubRPMRemotes(_ context.Context) ([]models.Remote
|
||||
return append([]models.Remote(nil), f.remotes...), nil
|
||||
}
|
||||
|
||||
func (f *fakeSyncStore) ClaimGitHubSyncLease(_ context.Context, name, owner string, freshness, lease time.Duration) (bool, string, error) {
|
||||
func (f *fakeSyncStore) ClaimGitHubSyncLease(_ context.Context, name, owner string, freshness, lease, interval, phase time.Duration) (bool, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.freshness, f.interval, f.phase = freshness, interval, phase
|
||||
f.attempts++
|
||||
now := time.Now()
|
||||
ls, hasLS := f.lastSynced[name]
|
||||
exp, hasExp := f.leaseExp[name]
|
||||
@@ -230,7 +236,7 @@ func TestSyncerLeasePreventsSecondReplica(t *testing.T) {
|
||||
remote := fx.remote()
|
||||
|
||||
// Replica 1 holds the lease.
|
||||
claimed, _, err := store.ClaimGitHubSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration)
|
||||
claimed, _, err := store.ClaimGitHubSyncLease(context.Background(), remote.Name, "replica-1", time.Duration(remote.MutableTTL)*time.Second, syncLeaseDuration, 0, 0)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("replica-1 claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
@@ -470,3 +476,37 @@ func TestSyncerFailedAssetRetry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A periodic check claims with sync_interval as its freshness and passes the
|
||||
// remote's slot schedule; unset, it falls back to mutable_ttl with no schedule.
|
||||
func TestSyncerHonoursSyncInterval(t *testing.T) {
|
||||
for _, interval := range []int{0, 7200} {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
store.lastSynced["sched"] = time.Now()
|
||||
remote := models.Remote{Name: "sched", MutableTTL: 90, SyncInterval: interval}
|
||||
wantInterval, wantPhase := remote.SyncSchedule()
|
||||
s.process(context.Background(), syncJob{remote: remote})
|
||||
if want := remote.SyncPeriod(0); store.freshness != want || store.interval != wantInterval || store.phase != wantPhase {
|
||||
t.Errorf("sync_interval=%d: claim freshness/interval/phase = %v/%v/%v, want %v/%v/%v",
|
||||
interval, store.freshness, store.interval, store.phase, want, wantInterval, wantPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create-time primes are skipped for a scheduled remote so remotes created
|
||||
// together spread out; a cold-cache prime still claims with no schedule.
|
||||
func TestSyncerScheduledPrime(t *testing.T) {
|
||||
store := newFakeSyncStore()
|
||||
s := newSyncer(store, newTestProvider(), testSyncConfig())
|
||||
remote := models.Remote{Name: "fresh", SyncInterval: 3600}
|
||||
|
||||
s.EnqueuePrime(remote)
|
||||
if len(s.jobs) != 0 {
|
||||
t.Fatal("create-time prime enqueued for a sync_interval remote")
|
||||
}
|
||||
s.process(context.Background(), syncJob{remote: remote, prime: true})
|
||||
if store.attempts != 1 || store.freshness != 0 || store.interval != 0 {
|
||||
t.Fatalf("cold-cache prime: attempts=%d freshness=%v interval=%v, want 1/0/0", store.attempts, store.freshness, store.interval)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user