Pr/sync redis heartbeat#4099
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds cross-replica coordination for on-demand sync in multi-replica deployments by introducing a Redis-backed distributed lock (with TTL refresh heartbeat) and wiring API handlers to return 503 + Retry-After when a sync is already in flight. It also introduces an optional sync.asyncManifest mode to kick off background syncs on manifest cache misses while immediately returning 503 to encourage client retries.
Changes:
- Add Redis distributed lock implementation (
TryLock/Unlock/Refresh/IsLocked) plus unit/integration tests (miniredis-based). - Extend on-demand sync to optionally acquire a distributed lock + run a heartbeat during long syncs; expose
IsSyncInFlight()for routes to short-circuit. - Add blackbox CI coverage for multi-replica coordination and config validation for
sync.asyncManifest.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| test/blackbox/sync_multireplica_lock.bats | New end-to-end blackbox test that spins up Redis + upstream zot + two replicas and asserts exactly one 200 vs one 503 outcome. |
| test/blackbox/ci.sh | Adds the new blackbox test to the CI test matrix. |
| pkg/extensions/sync/redis_lock.go | Implements a Redis-backed distributed lock using SETNX and Lua CAS-style PEXPIRE/DEL. |
| pkg/extensions/sync/redis_lock_test.go | Unit tests for lock refresh/ownership semantics with miniredis. |
| pkg/extensions/sync/redis_lock_coordination_test.go | Integration-style tests validating cross-“replica” dedup and no-backend no-op behavior. |
| pkg/extensions/sync/on_demand.go | Adds distributed-lock support, heartbeat refresh, and IsSyncInFlight; refactors sync entrypoints to run via runWithLock. |
| pkg/extensions/sync/on_demand_disabled.go | Adds stub IsSyncInFlight for non-sync builds. |
| pkg/extensions/extension_sync.go | Auto-enables the Redis distributed lock when storage.cacheDriver.name == "redis". |
| pkg/extensions/config/sync/config.go | Adds AsyncManifest config field. |
| pkg/cli/server/root.go | Validates sync.asyncManifest requires at least one onDemand registry. |
| pkg/api/routes.go | Returns 503 + Retry-After on ErrSyncInFlight; supports async-manifest behavior and referrers in-flight handling. |
| pkg/api/controller.go | Extends SyncOnDemand interface with IsSyncInFlight. |
| examples/config-sync-multireplica.json | Adds an example multi-replica config using Redis cache driver + asyncManifest. |
| errors/errors.go | Introduces ErrSyncInFlight sentinel error. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| handle, release, err := onDemand.acquireDistributedLock(ctx, kind, repo, reference) | ||
| if err != nil { | ||
| close(syncResult) | ||
|
|
||
| return err |
There was a problem hiding this comment.
Fixed in 47f55ac — replaced the shared chan error with a syncResult struct (done chan struct{} + stored err), so all deduped waiters get broadcast semantics including early failures before the sync goroutine starts (e.g. ErrSyncInFlight now reliably surfaces as 503 to every waiter).
| function verify_prerequisites() { | ||
| if [ ! $(command -v curl) ]; then | ||
| echo "you need to install curl as a prerequisite to running the tests" >&3 | ||
| return 1 | ||
| fi | ||
|
|
||
| if [ ! $(command -v docker) ]; then | ||
| echo "you need to install docker as a prerequisite to running the tests" >&3 | ||
| return 1 | ||
| fi | ||
|
|
||
| if [ ! $(command -v skopeo) ]; then | ||
| echo "you need to install skopeo as a prerequisite to running the tests" >&3 | ||
| return 1 | ||
| fi | ||
|
|
||
| return 0 | ||
| } |
There was a problem hiding this comment.
the blackbox test comments can be ignored for now
| function setup_file() { | ||
| if ! $(verify_prerequisites); then | ||
| exit 1 | ||
| fi |
|
cc: @vrajashkr |
|
@mrudnoru pls continue working on this but also give us some time to work through this. This is a very involved PR. |
47f55ac to
a459e9d
Compare
Adds a distributed locking mechanism for the on-demand sync path so that, in multi-replica deployments, only one replica runs a given image sync at a time; concurrent requests on other replicas return 503 + Retry-After. Mechanics: - New RedisDistributedLock wraps go-redis SetNX/EVAL for TryLock/ Unlock/Refresh/IsLocked. Refresh uses a Lua script that only extends TTL when the caller still owns the key (CAS semantics). - BaseOnDemand gains an optional distributedLockBackend; when set, SyncImage and SyncReferrers acquire a key derived from sha256(kind|repo|reference) before delegating to the existing in-process syncImage/syncReferrers. - A heartbeat goroutine refreshes the lock TTL every 30s with a fixed 90s TTL, so a single missed heartbeat doesn't drop the lock and a dead leader's lock expires within ~90s. - New IsSyncInFlight on SyncOnDemand lets the manifest route short-circuit to 503 when another replica holds the lock, rather than starting a parallel upstream sync. Configuration: - The lock backend is auto-enabled when storage.cacheDriver.name == "redis"; no extra config needed. Single-replica/in-memory cache deployments keep the prior in-process dedup behavior unchanged. - Sync.AsyncManifest (new bool) makes manifest GET kick the sync off in the background and return 503 immediately, useful when the client (e.g. kubelet) retries faster than upstream pulls. The existing in-process requestStore dedup is preserved for the fast case (two parallel kubelet retries hitting the same replica). Signed-off-by: Marga Rudno-Rudzińska <marga.rudno@gmail.com>
…back - add IsSyncInFlight stub to the !sync BaseOnDemand so non-sync builds satisfy the SyncOnDemand interface and compile - enforce asyncManifest requires at least one onDemand registry in validateSync, erroring with ErrBadConfig otherwise - add coordination tests covering cross-replica dedup, in-flight detection, and the no-backend no-op path via miniredis Signed-off-by: Marga Rudno-Rudzińska <marga.rudno@gmail.com>
- propagate ErrSyncInFlight from getReferrers so the GetReferrers handler returns 503 + Retry-After instead of serving possibly-stale referrers - document IsSyncInFlight as advisory, with TryLock as the authoritative cross-replica dedup - drop redundant restate-the-code comments from the coordination test Signed-off-by: Marga Rudno-Rudzińska <marga.rudno@gmail.com>
Mirrors examples/config-sync.json style. Combines Redis cache driver, S3 storage, asyncManifest, and an on-demand sync registry — the minimum config that exercises the distributed lock + heartbeat path introduced in this branch. Signed-off-by: Marga Rudno-Rudzińska <marga.rudno@gmail.com>
Spins up 1 Redis + 1 upstream zot + 2 replica zots sharing the Redis cache, fires concurrent manifest GETs against both replicas, asserts exactly one 200 + one 503 and that the loser's log records the distributed in-flight observation. Signed-off-by: Marga Rudno-Rudzińska <marga.rudno@gmail.com>
Replace the shared chan error in requestStore with a shared result object so every deduped caller observes the same outcome, including early lock failures that previously delivered a nil (success) result. Signed-off-by: Marga Rudno-Rudzińska <marga.rudno@gmail.com>
…ireplica example Signed-off-by: Marga Rudno-Rudzińska <marga.rudno@gmail.com>
cd8c732 to
d326b70
Compare
What type of PR is this?
feature
Which issue does this PR fix:
N/A
What does this PR do / Why do we need it:
Adds distributed coordination for the on-demand sync path so that, in multi-replica deployments, only one replica runs a given image sync at a time; concurrent requests on other replicas return
503 + Retry-After.Mechanics:
RedisDistributedLockwraps go-redisSetNX/EVALforTryLock/Unlock/Refresh/IsLocked.Refreshuses a Lua script that only extends TTL when the caller still owns the key (CAS semantics).BaseOnDemandgains an optionaldistributedLockBackend; when set,SyncImageandSyncReferrersacquire a key derived fromsha256(kind | repo | reference)before delegating to the existing in-processsyncImage/syncReferrers.IsSyncInFlightonSyncOnDemandlets the manifest and referrers routes short-circuit to 503 when another replica holds the lock, rather than starting a parallel upstream sync.Configuration:
storage.cacheDriver.name == "redis"; no extra config required. Single-replica / in-memory cache deployments keep the prior in-process dedup behavior unchanged.sync.asyncManifest(bool) makes manifest GET kick the sync off in the background and return 503 immediately — useful when the client (e.g. kubelet) retries faster than upstream pulls complete. Validated to require at least oneonDemandsync registry, otherwiseErrBadConfig.See
examples/config-sync-multireplica.jsonfor a complete configuration.The existing in-process
requestStorededup is preserved for the fast case (two parallel kubelet retries hitting the same replica).If an issue # is not available please add repro steps and logs showing the issue:
Without coordination, N zot replicas behind a load balancer that receive concurrent manifest GETs for the same uncached image will each start an independent upstream pull. Symptoms:
After this change, the first replica to
TryLockruns the sync; other replicas observeIsSyncInFlight == trueand return 503 + Retry-After, which kubelet (and most other OCI clients) honor.Testing done on this change:
Coordination tests use miniredis and cover:
Manual validation on a 3-replica deployment backed by S3 + Redis:
Automation added to e2e:
Yes.
test/blackbox/sync_multireplica_lock.bats` exercises the distributed lock end-to-end: it spins up Redis + an upstream zot + two replica zots configured to sync on-demand from upstream against the shared
Redis cache, fires concurrent manifest GETs against both replicas, and asserts:
distributed on-demand sync already in flight— the direct signal that the Redis lock fired, not just in-process dedup.The new suite is wired into
test/blackbox/ci.shso it runs as part ofmake run-blackbox-ci.Unit + integration coverage also lives in
pkg/extensions/sync/redis_lock_test.goandpkg/extensions/sync/redis_lock_coordination_test.go, usingminiredisto drive the same scenarios in a single process for faster feedback.Will this break upgrades or downgrades?
No.
storage.cacheDriver.name == "redis"; deployments without Redis are unaffected.sync.asyncManifestdefaults to false; existing configs keep synchronous manifest behavior.Does this PR introduce any user-facing change?:
Yes. Multi-replica deployments with the Redis cache driver now coordinate on-demand sync across pods. New optional
sync.asyncManifestflag.Multi-replica deployments with
storage.cacheDriver.name == "redis"now coordinate on-demand sync across pods using a distributed lock with heartbeat. Concurrent requests on other replicas return 503 + Retry-After instead of triggering parallel upstream pulls. New optionalsync.asyncManifestflag returns 503 immediately and runs the sync in the background.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.