Skip to content

fix: rebuild the ADC baseline on leader acquisition#430

Open
AlinsRan wants to merge 1 commit into
masterfrom
fix/adc-conf-version-rebuild
Open

fix: rebuild the ADC baseline on leader acquisition#430
AlinsRan wants to merge 1 commit into
masterfrom
fix/adc-conf-version-rebuild

Conversation

@AlinsRan

@AlinsRan AlinsRan commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

The ADC server runs as a sidecar that outlives the controller process. Losing the lease terminates the manager container (controller-runtime makes mgr.Start() return leader election lost, which exits the process) but not the sidecar, so ADC's per-cacheKey state survives a leadership change.

That state is the baseline ADC diffs against: the last synced content, plus the conf_version it generated. Operator.sync() clones the payload from that cached rawConfig and only refreshes <type>_conf_version for the resource types the current diff touches — every other type carries the version stored in the cache.

So when a pod is elected again:

  1. Its ADC cache is still frozen at the snapshot from its previous term.
  2. The leader in between has since bumped *_conf_version in APISIX.
  3. The re-elected pod pushes a payload carrying its old, smaller versions, and APISIX standalone — which requires *_conf_version to be monotonic — rejects it:
upstreams_conf_version must be greater than or equal to (1779434128737)

The data plane validates the configuration as a whole, so the rejection is not limited to the offending resource type: every later sync keeps failing and no update lands at all, endpoint updates included. Backends running a single pod then return 502/504 as soon as that pod is rescheduled, while multi-pod backends keep serving on their remaining endpoints. It also explains why restarting the pod clears it (the sidecar dies with it) while the manager process restarting on its own does not.

Fix

ADC >= 0.27.0 accepts bypassCache on the /sync task (api7/adc#460): dump() then invalidates the cacheKey, re-fetches the configuration from the data plane, and re-seeds its version floor from the *_conf_version values actually held there. A baseline is rebuilt exactly twice:

On leader acquisition — this is the fix. Winning the election is the one moment a baseline from an earlier term can enter the picture, so that is where it is answered. apisixProvider.Start() only runs once elected (NeedLeaderElection() == true), and it puts every baseline in doubt; the first sync of each cacheKey then re-derives it from the data plane before anything is pushed from it. Only a sync ADC accepts settles the question, so a rebuild that never landed — data plane not up yet, network gone — is attempted again rather than assumed. This depends on nothing but our own lifecycle.

On a rejected conf_version — this is a safety net. Prevention assumes a stale baseline can only come from a leadership change. What it cannot foresee — another writer on this data plane, something we did not anticipate — shows itself the same way: the data plane refuses the push. So that rejection rebuilds the baseline and pushes once more. It is recognised by the field it names, not by the sentence around it: conf_version belongs to the standalone admin API and we send those keys ourselves, while the prose around them is APISIX's to reword. Nothing else rebuilds on it, so if it ever stopped firing the bug would not come back with it.

Everything else is left alone. A connection that never reached the data plane, a configuration the data plane refuses on its merits (an invalid plugin, say) — neither is a question re-deriving the baseline can answer, and both are reported exactly as before. Outside standalone mode conf_version does not exist and none of this runs. In the steady state the request body is byte for byte what it was: bypassCache is omitempty, so a sync that is not rebuilding does not carry it at all.

ADC_VERSION goes to 0.27.1, the earliest release with bypassCache (0.28.0 also flips the differ to v4 by default, which is out of scope here).

Important

The ADC sidecar image must move too. Deployments get their sidecar tag from the chart, not from this repo. A controller running against an ADC older than 0.27.0 has its rebuild answered with a schema error instead of healing — the fix becomes a no-op. A matching chart bump has to ship with this.

Worth calling out for review: a conflict is no longer fail-closed. Today a rejected conf_version stops the controller from writing. It now rebuilds and pushes anyway, so a second party writing to the same data plane gets overwritten rather than backed off from. The alternative it backs off into is a permanently broken ingress, so overwriting is the semantic we want — and the conflict is counted (conf_version_conflict) so a persistent ping-pong shows up in metrics rather than silently. The rebuild is not rate limited, and that counter is what says so.

Test

E2E (test/e2e/apisix/conf_version.go) bumps every *_conf_version the data plane holds straight through its Admin API — what the leader in between would have left behind — and then expects an Ingress update to still reach the data plane. Nothing restarts during the spec, so the baseline rebuilt at election is already current by then: what it exercises is the safety net. Without the fix it reproduces the failure, the sync carrying over the versions of the resource types its diff does not touch:

plugins_conf_version must be greater than or equal to (1783927244865)

Verified against APISIX 3.17.0 and ADC 0.27.1: the spec passes with the fix and fails without it. It needs the data plane Admin API reachable from a spec, so APISIXDeployer now exposes a client for the tunnel the scaffold already forwards.

Unit tests cover the rest: the rebuild happens once per cacheKey per term and not again; a rebuild ADC never accepted is retried rather than recorded; the rejection is recognised however it is worded; an unreachable data plane and an invalid plugin configuration do not trigger one; bypassCache reaches the /sync body, stays off the /validate body (its task schema rejects unknown fields), never writes back into the config the ConfigManager holds; the retry happens once rather than in a loop; a failed rebuild reports both the rejection and the reason it could not answer it, without saying the same thing twice.

Summary by CodeRabbit

  • Bug Fixes

    • Improved APISIX standalone synchronization after leadership changes by rebuilding configuration baselines when needed.
    • Added automatic recovery when the data plane rejects updates due to configuration-version mismatches.
    • Prevented stale cache data from causing synchronization inconsistencies.
  • Testing

    • Added coverage for cache rebuilding, recovery behavior, error reporting, and end-to-end configuration alignment.
  • Documentation

    • Updated development prerequisites to require ADC version 0.27.0 or later.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3d2ddb57-c4ad-43db-96ba-dca67f6f419e

📥 Commits

Reviewing files that changed from the base of the PR and between 52d98df and 817216d.

📒 Files selected for processing (9)
  • Makefile
  • api/adc/types.go
  • docs/en/latest/developer-guide.md
  • internal/adc/client/client.go
  • internal/adc/client/executor.go
  • internal/adc/client/executor_test.go
  • internal/provider/apisix/provider.go
  • test/e2e/apisix/conf_version.go
  • test/e2e/scaffold/apisix_deployer.go
🚧 Files skipped from review as they are similar to previous changes (9)
  • docs/en/latest/developer-guide.md
  • Makefile
  • internal/provider/apisix/provider.go
  • api/adc/types.go
  • test/e2e/scaffold/apisix_deployer.go
  • test/e2e/apisix/conf_version.go
  • internal/adc/client/executor.go
  • internal/adc/client/client.go
  • internal/adc/client/executor_test.go

📝 Walkthrough

Walkthrough

The ADC version requirement is updated, sync requests gain conditional cache bypassing, and standalone clients rebuild baselines after leadership changes or conf_version conflicts. Unit and end-to-end tests validate request serialization, retries, error handling, and data-plane alignment.

Changes

ADC cache recovery

Layer / File(s) Summary
ADC version and bypass contract
Makefile, api/adc/types.go, docs/en/latest/developer-guide.md
ADC defaults and prerequisites require newer versions, while Config gains a non-serialized BypassCache field.
Sync request bypass wiring
internal/adc/client/executor.go
Named sync and validate paths are introduced, and sync requests conditionally include bypassCache.
Conflict detection and retry
internal/adc/client/client.go, internal/provider/apisix/provider.go
Baseline state is invalidated at startup, tracked per config, and rebuilt after standalone conf_version rejections.
Retry and standalone alignment validation
internal/adc/client/executor_test.go, test/e2e/apisix/conf_version.go, test/e2e/scaffold/apisix_deployer.go
Tests cover request construction, baseline reuse, retries, error handling, and standalone data-plane version alignment.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Provider
  participant ADCClient
  participant Executor
  participant APISIX
  Provider->>ADCClient: InvalidateADCCache()
  ADCClient->>Executor: Execute sync with BypassCache
  Executor->>APISIX: Submit sync request
  APISIX-->>ADCClient: Return sync result or conf_version rejection
  ADCClient->>Executor: Retry with BypassCache=true
  Executor->>APISIX: Rebuild baseline and submit sync
  APISIX-->>ADCClient: Return retry result
Loading

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Security Check ❌ Error Verbose logs leak ADC tokens, config payloads, and APISIX admin keys in executor/client/scaffold code. Remove raw body/config/key logging; log only non-sensitive metadata and add redaction/log-safe DTOs for secret-bearing structs.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately reflects the main change: rebuilding the ADC baseline when leadership is acquired.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
E2e Test Quality Review ✅ Passed PASS: The new Ginkgo spec drives a real standalone APISIX/Admin API flow, is readable, uses retry-based assertions, and has no hidden ordering or mocking issues.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/adc-conf-version-rebuild

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/adc/client/client.go`:
- Around line 397-419: Extract the stale conf_version recovery sequence
currently in the per-config path into a shared helper, preserving its logging,
metric recording, cache bypass, retry, and error-reporting behavior. Invoke this
helper for the global-rule ADC execution before that branch returns, as well as
from the existing config path, so both execution paths recover from conflicts.
Add a regression test covering global_rule synchronization with a stale
conf_version.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d5113521-b802-433f-b553-212fb9476afb

📥 Commits

Reviewing files that changed from the base of the PR and between 5f84a00 and a9343b4.

📒 Files selected for processing (8)
  • Makefile
  • api/adc/types.go
  • docs/en/latest/developer-guide.md
  • internal/adc/client/client.go
  • internal/adc/client/executor.go
  • internal/adc/client/executor_test.go
  • test/e2e/apisix/conf_version.go
  • test/e2e/scaffold/apisix_deployer.go

Comment thread internal/adc/client/client.go Outdated
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

conformance test report - apisix-standalone mode

apiVersion: gateway.networking.k8s.io/v1
date: "2026-07-14T05:54:17Z"
gatewayAPIChannel: experimental
gatewayAPIVersion: v1.3.0
implementation:
  contact: null
  organization: APISIX
  project: apisix-ingress-controller
  url: https://github.com/apache/apisix-ingress-controller.git
  version: v2.0.0
kind: ConformanceReport
mode: default
profiles:
- core:
    result: success
    statistics:
      Failed: 0
      Passed: 12
      Skipped: 0
  name: GATEWAY-GRPC
  summary: Core tests succeeded.
- core:
    result: partial
    skippedTests:
    - HTTPRouteHTTPSListener
    statistics:
      Failed: 0
      Passed: 32
      Skipped: 1
  extended:
    result: partial
    skippedTests:
    - HTTPRouteRedirectPortAndScheme
    statistics:
      Failed: 0
      Passed: 11
      Skipped: 1
    supportedFeatures:
    - GatewayAddressEmpty
    - GatewayPort8080
    - HTTPRouteBackendProtocolWebSocket
    - HTTPRouteDestinationPortMatching
    - HTTPRouteHostRewrite
    - HTTPRouteMethodMatching
    - HTTPRoutePathRewrite
    - HTTPRoutePortRedirect
    - HTTPRouteQueryParamMatching
    - HTTPRouteRequestMirror
    - HTTPRouteResponseHeaderModification
    - HTTPRouteSchemeRedirect
    unsupportedFeatures:
    - GatewayHTTPListenerIsolation
    - GatewayInfrastructurePropagation
    - GatewayStaticAddresses
    - HTTPRouteBackendProtocolH2C
    - HTTPRouteBackendRequestHeaderModification
    - HTTPRouteBackendTimeout
    - HTTPRouteParentRefPort
    - HTTPRoutePathRedirect
    - HTTPRouteRequestMultipleMirrors
    - HTTPRouteRequestPercentageMirror
    - HTTPRouteRequestTimeout
  name: GATEWAY-HTTP
  summary: Core tests partially succeeded with 1 test skips. Extended tests partially
    succeeded with 1 test skips.
- core:
    result: partial
    skippedTests:
    - TLSRouteSimpleSameNamespace
    statistics:
      Failed: 0
      Passed: 10
      Skipped: 1
  name: GATEWAY-TLS
  summary: Core tests partially succeeded with 1 test skips.

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

conformance test report - apisix mode

apiVersion: gateway.networking.k8s.io/v1
date: "2026-07-14T05:54:21Z"
gatewayAPIChannel: experimental
gatewayAPIVersion: v1.3.0
implementation:
  contact: null
  organization: APISIX
  project: apisix-ingress-controller
  url: https://github.com/apache/apisix-ingress-controller.git
  version: v2.0.0
kind: ConformanceReport
mode: default
profiles:
- core:
    result: success
    statistics:
      Failed: 0
      Passed: 12
      Skipped: 0
  name: GATEWAY-GRPC
  summary: Core tests succeeded.
- core:
    failedTests:
    - HTTPRouteInvalidBackendRefUnknownKind
    result: failure
    skippedTests:
    - HTTPRouteHTTPSListener
    statistics:
      Failed: 1
      Passed: 31
      Skipped: 1
  extended:
    result: partial
    skippedTests:
    - HTTPRouteRedirectPortAndScheme
    statistics:
      Failed: 0
      Passed: 11
      Skipped: 1
    supportedFeatures:
    - GatewayAddressEmpty
    - GatewayPort8080
    - HTTPRouteBackendProtocolWebSocket
    - HTTPRouteDestinationPortMatching
    - HTTPRouteHostRewrite
    - HTTPRouteMethodMatching
    - HTTPRoutePathRewrite
    - HTTPRoutePortRedirect
    - HTTPRouteQueryParamMatching
    - HTTPRouteRequestMirror
    - HTTPRouteResponseHeaderModification
    - HTTPRouteSchemeRedirect
    unsupportedFeatures:
    - GatewayHTTPListenerIsolation
    - GatewayInfrastructurePropagation
    - GatewayStaticAddresses
    - HTTPRouteBackendProtocolH2C
    - HTTPRouteBackendRequestHeaderModification
    - HTTPRouteBackendTimeout
    - HTTPRouteParentRefPort
    - HTTPRoutePathRedirect
    - HTTPRouteRequestMultipleMirrors
    - HTTPRouteRequestPercentageMirror
    - HTTPRouteRequestTimeout
  name: GATEWAY-HTTP
  summary: Core tests failed with 1 test failures. Extended tests partially succeeded
    with 1 test skips.
- core:
    result: partial
    skippedTests:
    - TLSRouteSimpleSameNamespace
    statistics:
      Failed: 0
      Passed: 10
      Skipped: 1
  name: GATEWAY-TLS
  summary: Core tests partially succeeded with 1 test skips.

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

conformance test report

apiVersion: gateway.networking.k8s.io/v1
date: "2026-07-14T06:11:55Z"
gatewayAPIChannel: experimental
gatewayAPIVersion: v1.3.0
implementation:
  contact: null
  organization: APISIX
  project: apisix-ingress-controller
  url: https://github.com/apache/apisix-ingress-controller.git
  version: v2.0.0
kind: ConformanceReport
mode: default
profiles:
- core:
    failedTests:
    - GatewayModifyListeners
    result: failure
    statistics:
      Failed: 1
      Passed: 11
      Skipped: 0
  name: GATEWAY-GRPC
  summary: Core tests failed with 1 test failures.
- core:
    failedTests:
    - GatewayModifyListeners
    result: failure
    skippedTests:
    - HTTPRouteHTTPSListener
    statistics:
      Failed: 1
      Passed: 31
      Skipped: 1
  extended:
    result: partial
    skippedTests:
    - HTTPRouteRedirectPortAndScheme
    statistics:
      Failed: 0
      Passed: 11
      Skipped: 1
    supportedFeatures:
    - GatewayAddressEmpty
    - GatewayPort8080
    - HTTPRouteBackendProtocolWebSocket
    - HTTPRouteDestinationPortMatching
    - HTTPRouteHostRewrite
    - HTTPRouteMethodMatching
    - HTTPRoutePathRewrite
    - HTTPRoutePortRedirect
    - HTTPRouteQueryParamMatching
    - HTTPRouteRequestMirror
    - HTTPRouteResponseHeaderModification
    - HTTPRouteSchemeRedirect
    unsupportedFeatures:
    - GatewayHTTPListenerIsolation
    - GatewayInfrastructurePropagation
    - GatewayStaticAddresses
    - HTTPRouteBackendProtocolH2C
    - HTTPRouteBackendRequestHeaderModification
    - HTTPRouteBackendTimeout
    - HTTPRouteParentRefPort
    - HTTPRoutePathRedirect
    - HTTPRouteRequestMultipleMirrors
    - HTTPRouteRequestPercentageMirror
    - HTTPRouteRequestTimeout
  name: GATEWAY-HTTP
  summary: Core tests failed with 1 test failures. Extended tests partially succeeded
    with 1 test skips.
- core:
    failedTests:
    - GatewayModifyListeners
    - TLSRouteSimpleSameNamespace
    result: failure
    statistics:
      Failed: 2
      Passed: 9
      Skipped: 0
  name: GATEWAY-TLS
  summary: Core tests failed with 2 test failures.

@AlinsRan AlinsRan force-pushed the fix/adc-conf-version-rebuild branch from a9343b4 to e0a7361 Compare July 13, 2026 09:21

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/adc/client/client.go (1)

384-407: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Global-rule sync path still has no conflict recovery.

This branch executes ADC (c.executor.Execute(ctx, config, args)) and handles its error, but unlike the non-global path added at lines 440-483, it never sets config.BypassCache based on baseline currency and never retries via rejectedByDataPlane. Any config whose task includes global_rule will still permanently fail on a stale conf_version instead of self-healing. This was already flagged on a prior commit of this file; it remains unaddressed in the current diff.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/adc/client/client.go` around lines 384 - 407, Update the global-rule
execution flow around c.executor.Execute and align it with the non-global path’s
conflict recovery: set config.BypassCache based on baseline currency, then retry
rejected stale-conf_version executions through rejectedByDataPlane. Preserve the
existing status, error collection, and execution-duration metrics while allowing
global_rule tasks to self-heal.
internal/adc/client/executor.go (1)

424-448: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prefer a 400 data-plane status when aggregating standalone endpoint failures.
dataPlaneStatus currently takes the first failing endpoint’s status, so a later 400 Bad Request can be hidden behind an earlier non-400 failure and skip the bypassCache retry in rejectedByDataPlane.

🐛 Proposed fix
 			for _, ep := range result.EndpointStatus {
 				if !ep.Success {
 					failedEndpoints = append(failedEndpoints, fmt.Sprintf("%s: %s", ep.Server, ep.Reason))
-					if dataPlaneStatus == 0 {
+					if dataPlaneStatus == 0 || ep.Response.Status == http.StatusBadRequest {
 						dataPlaneStatus = ep.Response.Status
 					}
 				}
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/adc/client/executor.go` around lines 424 - 448, Update the
endpoint-status aggregation in the standalone-mode handling to prefer HTTP 400
when selecting dataPlaneStatus. In the loop over result.EndpointStatus, continue
recording a failure status, but allow a later failing endpoint with status 400
to replace an earlier non-400 status so rejectedByDataPlane can trigger its
bypassCache retry.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/adc/client/client.go`:
- Around line 384-407: Update the global-rule execution flow around
c.executor.Execute and align it with the non-global path’s conflict recovery:
set config.BypassCache based on baseline currency, then retry rejected
stale-conf_version executions through rejectedByDataPlane. Preserve the existing
status, error collection, and execution-duration metrics while allowing
global_rule tasks to self-heal.

In `@internal/adc/client/executor.go`:
- Around line 424-448: Update the endpoint-status aggregation in the
standalone-mode handling to prefer HTTP 400 when selecting dataPlaneStatus. In
the loop over result.EndpointStatus, continue recording a failure status, but
allow a later failing endpoint with status 400 to replace an earlier non-400
status so rejectedByDataPlane can trigger its bypassCache retry.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2716049f-d5cb-4be4-8d9b-9c8d6fc3b394

📥 Commits

Reviewing files that changed from the base of the PR and between a9343b4 and e0a7361.

📒 Files selected for processing (10)
  • Makefile
  • api/adc/types.go
  • docs/en/latest/developer-guide.md
  • internal/adc/client/client.go
  • internal/adc/client/executor.go
  • internal/adc/client/executor_test.go
  • internal/provider/apisix/provider.go
  • internal/types/error.go
  • test/e2e/apisix/conf_version.go
  • test/e2e/scaffold/apisix_deployer.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • docs/en/latest/developer-guide.md
  • Makefile
  • test/e2e/apisix/conf_version.go
  • test/e2e/scaffold/apisix_deployer.go

@AlinsRan AlinsRan force-pushed the fix/adc-conf-version-rebuild branch from e0a7361 to 52d98df Compare July 14, 2026 01:02

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/adc/client/client.go`:
- Around line 435-475: The baseline check, execution, and mark-current sequence
in applySync must be serialized per cache key. Replace the shared-lock
protection used by applySync with an exclusive lock or equivalent per-key guard
covering baselineIsCurrent, Execute (including retry), and markBaselineCurrent,
while preserving concurrency for different keys.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 214a3c9d-6fec-44ae-b2fb-d75898cf4969

📥 Commits

Reviewing files that changed from the base of the PR and between e0a7361 and 52d98df.

📒 Files selected for processing (9)
  • Makefile
  • api/adc/types.go
  • docs/en/latest/developer-guide.md
  • internal/adc/client/client.go
  • internal/adc/client/executor.go
  • internal/adc/client/executor_test.go
  • internal/provider/apisix/provider.go
  • test/e2e/apisix/conf_version.go
  • test/e2e/scaffold/apisix_deployer.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • Makefile
  • internal/provider/apisix/provider.go
  • test/e2e/apisix/conf_version.go
  • test/e2e/scaffold/apisix_deployer.go
  • internal/adc/client/executor.go
  • docs/en/latest/developer-guide.md

Comment thread internal/adc/client/client.go Outdated
Comment on lines +435 to +475
// The ADC sidecar outlives the controller process, so the baseline it holds for this
// cacheKey may be one an earlier leadership term left behind. Re-derive it from the
// data plane the first time this term syncs the key, before it can be pushed from.
standalone := config.BackendType == backendAPISIXStandalone
config.BypassCache = standalone && !c.baselineIsCurrent(config.Name)

err := c.executor.Execute(ctx, config, args)

// Rebuilding on leader acquisition covers where staleness comes from. The safety net
// covers what it cannot foresee -- another writer on this data plane, a desync no
// leadership change explains -- and a conf_version the data plane refuses is the only
// way any of that shows itself. Re-read the data plane and push again.
if standalone && !config.BypassCache && isConfVersionRejection(err) {
c.log.Info("data plane rejected a stale conf_version, rebuilding the ADC baseline",
"config", config.Name, "error", err.Error())
// Keep the rejection visible even when the sync recovers. The rebuild is not rate
// limited, so a rejection on every sync -- someone else writing to this data plane
// -- turns every sync into a full fetch and diff, and this counter is what says so.
pkgmetrics.RecordExecutionError(config.Name, "conf_version_conflict")

config.BypassCache = true
retryErr := c.executor.Execute(ctx, config, args)
if retryErr != nil && retryErr.Error() != err.Error() {
// Report the rejection as well. On its own a failed rebuild says nothing about
// what it was rebuilding for, and it is the rejection that names the cause --
// an ADC server too old to know bypassCache, say, answers with a schema error
// that points nowhere near it. Unless the rebuild was rejected the same way,
// in which case saying it twice only pads the status message.
var rejected types.ADCExecutionError
if errors.As(err, &rejected) {
errs.Errors = append(errs.Errors, rejected)
}
}
err = retryErr
}

// Only a sync ADC accepted proves its baseline is now derived from the data plane.
if err == nil && config.BypassCache {
c.markBaselineCurrent(config.Name)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all call sites of Client.sync / Client.Sync and check whether syncMu guards them.
rg -n --type=go -C3 '\.sync\(|\.Sync\(' internal/ | rg -v '_test.go'
rg -n --type=go -C3 'syncMu' internal/adc/client/

Repository: api7/api7-ingress-controller

Length of output: 5354


🏁 Script executed:

#!/bin/bash
sed -n '150,330p' internal/adc/client/client.go | cat -n

Repository: api7/api7-ingress-controller

Length of output: 6309


🏁 Script executed:

#!/bin/bash
set -e
rg -n --type=go -C2 '\.(Update|Delete|UpdateConfig|DeleteConfig)\(' internal/ | rg -v '_test.go'
printf '\n---\n'
sed -n '1,220p' internal/provider/apisix/provider.go | cat -n
printf '\n---\n'
sed -n '1,220p' internal/provider/api7ee/provider.go | cat -n

Repository: api7/api7-ingress-controller

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -e
sed -n '330,520p' internal/adc/client/client.go | cat -n
printf '\n---\n'
rg -n --type=go 'baselineIsCurrent|markBaselineCurrent|BypassCache|syncMu' internal/adc/client/client.go

Repository: api7/api7-ingress-controller

Length of output: 8402


🏁 Script executed:

#!/bin/bash
set -e
printf 'client helpers:\n'
sed -n '90,120p' internal/adc/client/client.go | cat -n
printf '\nprovider locks:\n'
rg -n --type=go 'Lock\(|Unlock\(|syncCh|syncNotify|applySync\(' internal/provider/apisix/provider.go internal/provider/api7ee/provider.go

Repository: api7/api7-ingress-controller

Length of output: 2847


syncMu still allows concurrent baseline rebuilds. applySync() takes only RLock(), so two Update/Delete calls can overlap baselineIsCurrent(name)Execute()markBaselineCurrent(name) for the same cache key and both push with BypassCache=true. If that first-rebuild invariant matters, serialize that sequence with an exclusive lock or per-key guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/adc/client/client.go` around lines 435 - 475, The baseline check,
execution, and mark-current sequence in applySync must be serialized per cache
key. Replace the shared-lock protection used by applySync with an exclusive lock
or equivalent per-key guard covering baselineIsCurrent, Execute (including
retry), and markBaselineCurrent, while preserving concurrency for different
keys.

@AlinsRan AlinsRan changed the title fix: rebuild the ADC baseline when the data plane rejects a stale conf_version fix: rebuild the ADC baseline on leader acquisition Jul 14, 2026
The ADC server runs as a sidecar that outlives the controller process. Losing the
lease terminates the manager container but not the sidecar, so the baseline ADC
holds per cacheKey -- the last synced content plus the conf_version it generated --
survives a leadership change. Once the pod is elected again, ADC diffs against that
frozen snapshot and carries over conf_versions older than the ones the leader in
between has already pushed. APISIX standalone requires *_conf_version to be
monotonic and rejects the request:

    upstreams_conf_version must be greater than or equal to (1779434128737)

The data plane validates the configuration as a whole, so every later sync keeps
failing and no update lands at all, endpoint updates included. Backends running a
single pod then return 502/504 once that pod is rescheduled, while multi-pod
backends keep serving on their remaining endpoints.

Winning the election is the one moment a baseline from an earlier term can enter the
picture, so that is where to answer it: the provider, which only runs once elected,
puts every baseline in doubt, and the first sync of each cacheKey re-derives it from
the data plane through ADC's bypassCache before anything is pushed from it. Only a
sync ADC accepts settles the question, so a rebuild that never landed is attempted
again rather than assumed.

Prevention cannot cover a desync no leadership change explains -- another writer on
this data plane, something we did not foresee -- and the only way any of that shows
itself is the data plane refusing a conf_version. So that rejection rebuilds the
baseline and pushes once more. It is recognised by the field it names rather than by
the sentence around it: conf_version belongs to the standalone admin API, we send
those keys ourselves, while the prose around them is APISIX's to reword. This is a
net, not the fix: nothing else rebuilds on it, and the reported bug does not come
back if it ever stops firing.

bypassCache needs ADC >= 0.27.0, so bump ADC_VERSION to 0.27.1. The sidecar image
the chart deploys has to move with it, or the rebuild is answered with a schema
error instead of healing.

The e2e bumps every *_conf_version the data plane holds straight through its Admin
API -- what the leader in between would have left behind -- and then expects an
Ingress update to still reach the data plane. Nothing restarts during the spec, so
what it exercises is the safety net. It reproduces the bug without the fix.
@AlinsRan AlinsRan force-pushed the fix/adc-conf-version-rebuild branch from 52d98df to 817216d Compare July 14, 2026 05:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant