Skip to content

feat(config): serve last-known-good values for rejected keys across resync and restart - #874

Merged
membphis merged 3 commits into
mainfrom
claude/red-last-known-good-resync-04c3fb
Aug 4, 2026
Merged

feat(config): serve last-known-good values for rejected keys across resync and restart#874
membphis merged 3 commits into
mainfrom
claude/red-last-known-good-resync-04c3fb

Conversation

@membphis

@membphis membphis commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Part 2 of #871 (the delivery plan's "PR 2 — RED last-known-good across resync/restart"). PR 1 (lenient parse + tri-state reporting) landed as #872.

Problem

A watch put that fails validation already leaves the previous good value serving — but only until the next full resync or restart, which rebuild the snapshot from accepted rows only. A key whose latest etcd bytes are rejected then vanishes: an api_key stops authenticating with a 401 byte-identical to "no such key", days after and decoupled from the write that broke it. This is the step-3 "cliff" in #871, present for every resource kind.

Design

Retention (xDS-NACK style). The supervisor pins the serving bytes as the key's last known good the moment a replacement is rejected (capture_last_good on the rejected-put path; the same capture runs inside apply_resync for keys first observed rejected there). On every resync the pinned bytes re-build through the normal loader and merge into the fresh snapshot, so the typed value and every derived signal stay consistent with what actually serves. A pinned value the running build can no longer parse (a DP downgrade) drops its retention with an ERROR — the same contract as any RED row.

Retention ends exactly when the etcd key stops existing or heals. A successful put for the key, a watch delete, or a resync that no longer carries the key (deletion observed via full re-read after reconnect/compaction) all remove the pin — no zombie config can outlive its key. Pinned values and their staleness clocks ride in the snapshot cache (additive stale section, format version unchanged: old DPs ignore it, new DPs default it), so retention survives restarts.

Design decision 1 — what config_hash / resource_counts say about a rejected-but-serving-stale row. config_hash covers the bytes each key actually serves: observed etcd bytes for accepted keys, the pinned last-known-good bytes for stale-serving keys, and nothing for a rejected key with no last good. The hash therefore never claims the rejected bytes applied (a CP diffing its expected hash sees divergence) and never claims the row stopped serving (two DPs — one serving a pin, one that never had the value — report different hashes). The substitution is decided by the stale map, not the capped rejection buffer, so buffer overflow cannot flip a served key's hash contribution. resource_counts counts served rows, pins included — they are serving — with the stale detail carried separately (rejected[] per-row + a per-kind gauge), and state stays degraded while any rejection exists. Pre-existing boundary, unchanged in scope: a rejected key with no pin that has been evicted from the 256-cap rejection buffer folds its observed bytes back into config_hash; this predates this PR and now applies uniformly to put- and resync-observed rejections.

Design decision 2 — coexistence with PR 1's YELLOW retention and the 256-cap rejection buffer. The same key can simultaneously be "new value rejected (RED)" and "serving old value that is itself YELLOW". Both signals coexist and describe different bytes: rejected[] describes the observed etcd value, partially_compatible[] describes the served pin — on injection the pin re-runs serde_ignored, so its ignored-field paths are regenerated from the same bytes that serve (pinned by stale_last_good_that_was_yellow_keeps_its_partial_compat_signal). The stale map is a third, deliberately uncapped store, independent of both the 256-cap RED buffer and the 1024-cap YELLOW buffer: evicting there truncates a report; evicting a pin would take a served resource offline. Its size is bounded by rows that ever loaded successfully — a subset of the (uncapped) served snapshot, one extra copy of the raw bytes per rejected row.

Staleness is reported every cycle, on the PR 1 surfaces. GET /status/config rejected[] entries gain serving_stale_since (RFC3339) and serving_stale_age_seconds (recomputed per read); absent both when nothing serves for the row, so consumers can tell "serving stale" from "actually down". Heartbeat rejected_resources[] entries gain stale_serving_since_unix_secs (omitted when absent; the CP heartbeat handler tolerates unknown fields — verified in #872). Metrics gain aisix_config_stale_served_resources{kind} with the same stale-label zeroing as the PR 1 gauge. The since instant persists in the cache, so the age is continuous across restarts instead of resetting at boot.

Behavior change: a rejected live put now enters the observed-state map. Previously the bytes only reached source_hash at the next resync; now source_hash reflects the observed etcd state immediately and — the load-bearing part — a restart inside the rejected-put window restores the same rejected-bytes + pinned-value cache shape a post-resync restart would, keeping the staleness clock continuous. The e2e restart leg caught this gap live (the clock reset by 2s across the restart before the fix).

Config: managed.snapshot_cache_path becomes optional. Omitted keeps today's defaults exactly (managed on at /var/lib/aisix/config_cache.json, self-hosted etcd off); an explicit path now enables the cache in self-hosted etcd mode too (previously silently ignored there — anyone who set it wanted it); "" still disables everywhere. This is DP-local config.yaml, not a CP-managed resource, so no cp-admin.yaml counterpart applies.

Testing

Reproducing tests were written first and failed on the pre-PR code at the resync cliff (row count 1 → 0), then flipped green:

  • unit: rejected update keeps serving across resync (with staleness fields asserted via the JSON view), across restart (cache round-trip), dies on watch delete AND on resync-observed absence, YELLOW pin keeps its ignored-field report, config_hash equals the served-bytes hash and diverges from source_hash, pin persists for an immediate restart with a continuous clock, cache format round-trips the stale section and still loads legacy files.
  • e2e (real etcd + real chat traffic): seeded model serves → schema-invalid replacement lands in etcd → chat still 200 with serving_stale_since/age reported and the gauge at 1 → full process restart on the same prefix + cache (a sentinel write proves the live load_all/resync completed against the still-rejected bytes) → chat still 200, serving_stale_since byte-identical across the restart → etcd delete → chat 404, rejected[] empty, gauge zeroed.
  • regressions: full workspace suite, PR 1's forward-compat e2e, and the status-surface e2e files all pass; clippy and fmt clean.

Prior art

The issue's prior-art table (#871) surveys mainstream proxies/gateways and config-delivery systems: the dominant pattern for hard-invalid dynamic config is NACK-with-last-valid-config-retained (the xDS ACK/NACK model), and per-key last-known-good retention at the data plane appears in the surveyed gateway with hybrid CP/DP deployment and in the row-strict gateway of the same family. This PR lands that shape: whole-row rejection stays (RED is still RED), but rejection of an update no longer takes down the running value, and the divergence is continuously observable.

Out of scope / follow-ups

  • CP-side surfacing of the tri-state + staleness fields remains api7/AISIX-Cloud#1227 (non-blocking; the CP tolerates the new fields today).
  • User-facing docs go to the docs repository per repo policy.

Closes #871.

Summary by CodeRabbit

  • New Features

    • Rejected configuration updates now continue serving the last known good values until the affected entries are removed or successfully updated.
    • Stale-serving status includes start time, elapsed age, and affected resource counts.
    • Stale configuration state persists across resynchronization and application restarts.
    • Snapshot caching can now be enabled, disabled, or configured explicitly across deployment modes.
  • Bug Fixes

    • Configuration hashes now accurately reflect the values currently being served.
    • Heartbeat and status responses report stale-serving details for rejected resources.

…esync and restart

A watch put that fails validation already leaves the previous good value
serving, but the retention ended at the next full resync: the snapshot
was rebuilt from accepted rows only, so a key whose latest etcd bytes
are rejected silently vanished — an api_key would 401 identically to no
such key, days after the write that broke it (issue #871, the pre-
existing cliff PR #872 deliberately left in place).

The supervisor now pins the serving bytes as the key's last known good
the moment a replacement is rejected, and re-injects the pinned value on
every resync (xDS-NACK style). Retention ends exactly when the etcd key
loads cleanly again or is deleted — including a key observed absent from
a resynced full read — so a pin can never outlive its key. The pinned
values ride in the snapshot cache (additive stale section, same format
version) so retention and the staleness clock survive restarts.

Accounting picks the honest option on both axes: config_hash covers the
bytes each key actually serves (pinned bytes for stale keys, nothing for
rejected keys with no last good), so it neither claims the rejected
bytes applied nor that the row stopped serving; resource_counts keeps
counting served rows. Staleness is reported every cycle: rejected[] on
/status/config gains serving_stale_since plus a recomputed
serving_stale_age_seconds, the heartbeat rejected_resources entries gain
stale_serving_since_unix_secs, and a per-kind
aisix_config_stale_served_resources gauge lands next to the existing
partially-compatible gauge with the same label-zeroing discipline.

The pinned bytes rebuild through the normal loader on injection, so a
last good value that is itself partially compatible keeps reporting its
ignored fields next to the rejection for the same key. The stale map is
deliberately uncapped and independent of the 256-cap rejection buffer:
overflow there truncates a report, never takes a served row offline.

managed.snapshot_cache_path becomes optional: omitted keeps today's
defaults (managed on at /var/lib/aisix/config_cache.json, self-hosted
etcd off), an explicit path now enables the cache in self-hosted etcd
mode too, and an empty string still disables it everywhere.
…ss clock

Real-etcd coverage for the issue scenario: a seeded model serves a real
chat request, a schema-invalid replacement lands in etcd, and the old
value keeps serving through the rejected watch put, a full process
restart (snapshot-cache replay plus a live load_all resync against the
still-rejected bytes), and dies only when the etcd key is deleted. The
staleness surface is asserted at every step: serving_stale_since and
serving_stale_age_seconds on rejected[], and the per-kind
aisix_config_stale_served_resources gauge including its zeroing.

The restart leg caught a real gap: a rejected watch put neither
mirrored the rejected bytes into the observed-state map nor flushed the
cache, so a restart inside that window rebuilt a cache shape with no
pin and the staleness clock reset at boot. A rejected put now records
its bytes in the state map (source_hash reflects the observed etcd
state immediately, matching what a resync would report) and flushes, so
a restart in any window restores the same rejected-bytes + pinned-value
shape. Pinned by a new unit test asserting stale-since continuity
across a restart with no intervening resync.

Harness: spawnApp gains etcdPrefix and snapshotCachePath overrides and
apps gain stop() (terminate without cleanup) so restart scenarios can
hand etcd state and the cache file to a successor process.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@membphis, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6885e7d6-7e65-4419-b3ec-66fb018adae7

📥 Commits

Reviewing files that changed from the base of the PR and between a37a3ab and 480dbca.

📒 Files selected for processing (2)
  • crates/aisix-core/src/config.rs
  • crates/aisix-etcd/src/supervisor.rs
📝 Walkthrough

Walkthrough

The change preserves last-known-good etcd values after rejected updates. It persists stale-serving state across restart, updates status and metrics, adds heartbeat timestamps, and introduces mode-aware snapshot cache path resolution.

Changes

Stale configuration retention

Layer / File(s) Summary
Snapshot cache configuration
crates/aisix-core/src/config.rs, crates/aisix-server/src/main.rs
snapshot_cache_path is optional. effective_snapshot_cache_path() resolves managed defaults, explicit paths, and disabled caching.
Stale status contracts
crates/aisix-core/src/config_status.rs, crates/aisix-core/src/filesource/status.rs, crates/aisix-admin/src/lib.rs, crates/aisix-etcd/src/loader.rs
Status observations and rejection records now carry stale timestamps and per-kind stale-served counts. Hash definitions distinguish observed bytes from served bytes.
Supervisor stale retention
crates/aisix-etcd/src/supervisor.rs
Rejected etcd updates pin served values, retain them during resync, remove them after clean loads or deletion, and include them in served configuration hashes.
Stale snapshot persistence
crates/aisix-etcd/src/snapshot_cache.rs, crates/aisix-etcd/src/supervisor.rs
Snapshot caches store and restore stale entries. Cache files without the optional stale section remain loadable.
Observability and end-to-end validation
crates/aisix-obs/src/metrics.rs, crates/aisix-server/src/heartbeat.rs, tests/e2e/src/cases/config-last-known-good-e2e.test.ts, tests/e2e/src/harness/app.ts
Metrics and heartbeat responses expose stale-serving state. E2E tests cover rejection, restart, resync, and deletion behavior.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Etcd
  participant Supervisor
  participant SnapshotCache
  participant StatusMetrics
  Etcd->>Supervisor: rejected resource update
  Supervisor->>Supervisor: capture last-known-good value
  Supervisor->>SnapshotCache: persist stale entry
  Supervisor->>StatusMetrics: publish stale timestamp and kind count
  SnapshotCache-->>Supervisor: restore stale entry after restart
  Supervisor->>StatusMetrics: serve retained value and recompute status
Loading

Possibly related PRs

  • api7/aisix#872: Both changes update LoadObservation, config_status, and etcd supervisor status tracking.

Suggested reviewers: moonming, jarvis9443


Important

Pre-merge checks failed

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

❌ Failed checks (1 error, 1 inconclusive)

Check name Status Explanation Resolution
Security Check ❌ Error Category 1 CRITICAL: snapshot_cache.rs:217-227 JSON-serializes base64 RawEntry bytes that can contain ProviderKey.api_key or MCP secrets; atomic_write line 251 sets no restrictive mode or encryption. Encrypt cache contents or store secret references. At minimum create cache and temporary files with 0600, and add permission tests for credential-bearing entries.
E2e Test Quality Review ❓ Inconclusive Investigation is still in progress; no final assessment yet. Inspect the shared E2E state and implementation paths before deciding.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes retaining last-known-good values across resync and restart, which is the primary change.
Linked Issues check ✅ Passed The implementation satisfies #871 PR2 requirements for retention, healing and deletion, restart persistence, stale reporting, served-value hashes, and coverage.
Out of Scope Changes check ✅ Passed All changes support #871 PR2 behavior, persistence, configuration, reporting, or validation, with no unrelated code identified.
✨ 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 claude/red-last-known-good-resync-04c3fb

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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: 2

🧹 Nitpick comments (4)
crates/aisix-etcd/src/supervisor.rs (2)

2015-2059: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the "pinned value no longer parses" branch.

apply_resync drops retention and logs ERROR when a pinned last-known-good value fails to rebuild (Lines 910-922). That branch takes a served row offline, and no test covers it. It is the only new branch in apply_resync without coverage.

The restart shape here is the natural place to pin it: write a cache file whose stale value is invalid for this build, call restore_from_cache, then assert the snapshot is empty and the pin is gone.

🤖 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 `@crates/aisix-etcd/src/supervisor.rs` around lines 2015 - 2059, Add coverage
for the pinned-value parse failure in the restart test around
rejected_update_keeps_last_good_serving_across_restart: seed the cache with a
stale/pinned value that is invalid for the current build, call
restore_from_cache, and assert the restored snapshot is empty and the rejected
pin/retention is removed. Keep the existing valid-cache restart assertions
unchanged.

854-932: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the stale reconciliation block from apply_resync.

apply_resync now performs six distinct steps: build, stale reconciliation, pin re-validation, snapshot merge, publish, and cache flush. The function is about 90 lines. The reconciliation block at Lines 854-932 is self-contained: it consumes stats, entries, and the pre-resync state, and produces the entries to inject.

Extract it into a private helper, for example fn reconcile_stale(&self, stats: &mut BuildStats, entries: &[RawEntry], snap: &AisixSnapshot). apply_resync then reads as build → reconcile → publish. The coding guidelines ask for functions under 20-30 lines and one responsibility per function.

🤖 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 `@crates/aisix-etcd/src/supervisor.rs` around lines 854 - 932, Extract the
stale-serving reconciliation logic from apply_resync into a private helper such
as reconcile_stale, keeping the existing rejected-key filtering, previous-state
capture, stale retention updates, and injected-entry construction together. Pass
the required stats, entries, and snapshot inputs, return the entries to inject,
and leave pin re-validation, snapshot merging, publishing, and cache flushing in
apply_resync so its flow reads build → reconcile → publish.

Source: Coding guidelines

crates/aisix-etcd/src/snapshot_cache.rs (1)

37-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider moving StaleServing out of supervisor.

snapshot_cache now imports StaleServing from supervisor, while supervisor imports SnapshotCache from snapshot_cache. The two modules depend on each other. Rust accepts this inside one crate, so nothing breaks.

StaleServing is a persisted wire type built from RawEntry. Defining it next to RawEntry in provider, or in snapshot_cache itself, makes the dependency one-directional. supervisor would then import both types from the same direction.

Also applies to: 96-103

🤖 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 `@crates/aisix-etcd/src/snapshot_cache.rs` at line 37, Move the persisted wire
type StaleServing out of supervisor and colocate it with RawEntry in provider or
snapshot_cache, then update all imports and references, including the
definitions around the additionally noted lines, so supervisor and
snapshot_cache no longer depend on each other.

Source: Coding guidelines

tests/e2e/src/cases/config-last-known-good-e2e.test.ts (1)

114-252: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the retention lifecycle test self-contained.

The restart test requires the rejected model and staleSinceBeforeRestart from the preceding test. The deletion test also requires that retained model. A focused run or an execution-order change can therefore fail without its precondition or delete a non-stale row.

Put the three stages in one lifecycle test, or create and reject a fresh model in each test.

As per coding guidelines, “Avoid explicit dependencies between tests and hidden execution order assumptions.”

🤖 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 `@tests/e2e/src/cases/config-last-known-good-e2e.test.ts` around lines 114 -
252, The retention lifecycle tests currently depend on state created by earlier
tests and their execution order. Refactor the tests around the rejected
model—especially the tests covering restart and deletion—so each test
establishes its own model and rejection preconditions, or combine the three
stages into one self-contained lifecycle test; ensure the restart stage
initializes staleSinceBeforeRestart before use and the deletion stage only
removes a model created by that lifecycle.

Source: Coding guidelines

🤖 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 `@crates/aisix-server/src/main.rs`:
- Around line 578-586: Move snapshot-cache restoration in the startup flow
before the first required etcd connection established by
EtcdConfigProvider::connect, so Supervisor::restore_from_cache() can recover
configuration before etcd-dependent initialization. Preserve the existing
managed/self-hosted path selection and disabled behavior, and explicitly handle
an unavailable etcd connection when offline startup is supported.

In `@tests/e2e/src/harness/app.ts`:
- Around line 89-103: Update the app harness startup-failure cleanup around
spawnApp and cleanup to distinguish a reused etcdPrefix from the successor’s
temporary directory: when a prefix is supplied, never clean the shared prefix on
successor failure, and clean only successor-owned temporary resources. Require
every previously stopped app to call exit() after the successor exits, then add
a regression case that forces successor startup failure and verifies the
original etcd keys remain.

---

Nitpick comments:
In `@crates/aisix-etcd/src/snapshot_cache.rs`:
- Line 37: Move the persisted wire type StaleServing out of supervisor and
colocate it with RawEntry in provider or snapshot_cache, then update all imports
and references, including the definitions around the additionally noted lines,
so supervisor and snapshot_cache no longer depend on each other.

In `@crates/aisix-etcd/src/supervisor.rs`:
- Around line 2015-2059: Add coverage for the pinned-value parse failure in the
restart test around rejected_update_keeps_last_good_serving_across_restart: seed
the cache with a stale/pinned value that is invalid for the current build, call
restore_from_cache, and assert the restored snapshot is empty and the rejected
pin/retention is removed. Keep the existing valid-cache restart assertions
unchanged.
- Around line 854-932: Extract the stale-serving reconciliation logic from
apply_resync into a private helper such as reconcile_stale, keeping the existing
rejected-key filtering, previous-state capture, stale retention updates, and
injected-entry construction together. Pass the required stats, entries, and
snapshot inputs, return the entries to inject, and leave pin re-validation,
snapshot merging, publishing, and cache flushing in apply_resync so its flow
reads build → reconcile → publish.

In `@tests/e2e/src/cases/config-last-known-good-e2e.test.ts`:
- Around line 114-252: The retention lifecycle tests currently depend on state
created by earlier tests and their execution order. Refactor the tests around
the rejected model—especially the tests covering restart and deletion—so each
test establishes its own model and rejection preconditions, or combine the three
stages into one self-contained lifecycle test; ensure the restart stage
initializes staleSinceBeforeRestart before use and the deletion stage only
removes a model created by that lifecycle.
🪄 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 Plus

Run ID: 6da1fdd1-c80d-4b95-b753-de7c45bf01e9

📥 Commits

Reviewing files that changed from the base of the PR and between c2caaac and a37a3ab.

📒 Files selected for processing (12)
  • crates/aisix-admin/src/lib.rs
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/config_status.rs
  • crates/aisix-core/src/filesource/status.rs
  • crates/aisix-etcd/src/loader.rs
  • crates/aisix-etcd/src/snapshot_cache.rs
  • crates/aisix-etcd/src/supervisor.rs
  • crates/aisix-obs/src/metrics.rs
  • crates/aisix-server/src/heartbeat.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/config-last-known-good-e2e.test.ts
  • tests/e2e/src/harness/app.ts

Comment on lines +578 to +586
// Snapshot cache: persist to disk so the DP can serve traffic
// from the last-known config across CP outages and restarts.
// Disabled outside managed mode and when the operator clears the
// path explicitly.
let snapshot_cache =
if cfg.managed.is_managed() && !cfg.managed.snapshot_cache_path.is_empty() {
SnapshotCache::new(&cfg.managed.snapshot_cache_path)
} else {
SnapshotCache::disabled()
};
// Managed mode defaults to /var/lib/aisix/config_cache.json;
// self-hosted etcd mode enables it only when the operator sets
// a path explicitly; "" disables it in either mode.
let snapshot_cache = match cfg.managed.effective_snapshot_cache_path() {
Some(path) => SnapshotCache::new(path),
None => SnapshotCache::disabled(),
};

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect whether provider construction awaits etcd_client::Client::connect.
rg -n -C 16 'EtcdConfigProvider|async fn connect|pub async fn connect|Client::connect' crates/aisix-etcd/src

# Confirm the startup ordering in the server.
rg -n -C 12 'EtcdConfigProvider::connect|effective_snapshot_cache_path|restore_from_cache' crates/aisix-server/src/main.rs

Repository: api7/aisix

Length of output: 2028


🌐 Web query:

aisix EtcdConfigProvider connect Client::connect

💡 Result:

In the context of the AISIX AI Gateway codebase, EtcdConfigProvider::connect_client is an internal asynchronous function responsible for establishing a connection to an etcd cluster [1][2]. This method utilizes the etcd_client::Client::connect library method to initiate the gRPC connection [1][2]. Key operational details of connect_client include: 1. Connection Logic: It configures connection parameters, such as timeouts and credentials (user/password) using etcd_client::ConnectOptions [1][2]. 2. TLS/mTLS Support: The function dynamically detects whether the provided etcd endpoints use the https:// scheme [1][2]. If HTTPS is detected, it configures an etcd_client::OpenSslClientConfig to handle secure connections, including loading CA certificates and client certificate/key pairs for mTLS [1][3][4]. 3. Connectivity Probing: After establishing the connection, the function performs a connectivity check to verify the etcd server is responsive. As of recent updates, it uses a client.get("probe", None).await call for this purpose, as standard maintenance status calls were found to be incompatible with certain control plane implementations [2]. This provider is configured via the standard AISIX startup configuration (config.yaml), where users define endpoints, prefix, and optional tls settings for mTLS [5][6][7]. The implementation ensures that TLS configurations, if provided, are eagerly validated and parsed before connection attempts [4].

Citations:


🌐 Web query:

crates/aisix-etcd/src EtcdConfigProvider connect

💡 Result:

In the AISIX AI Gateway codebase, EtcdConfigProvider::connect is a method located in crates/aisix-etcd/src/etcd_provider.rs (or within the EtcdConfigProvider implementation block) that handles establishing a gRPC connection to an etcd cluster [1][2]. Key characteristics of this connection process include: 1. Protocol and Client: It utilizes the etcd-client crate (which is built on tonic for gRPC) to establish the connection [1][2]. 2. Connection Probing: Instead of using Maintenance.Status (which can return a 404 error when connecting to specific management proxies like the API7 dp-manager), the method performs a connectivity probe using client.get("probe", None) [2]. 3. TLS Support: The provider supports TLS/mTLS configurations, which are parsed from the gateway's startup configuration (e.g., config.yaml) [3][4][5]. If an https:// scheme is detected in the etcd endpoints, the provider configures an OpenSSL-based TLS stack [2][5]. This implementation includes handling for custom certificate chains, mTLS client certificates, and optional verification overrides (e.g., insecure_skip_verify) [4][2][5]. 4. Context: EtcdConfigProvider is central to how the gateway synchronizes dynamic resources (like models, API keys, and guardrails) from etcd [1][3][6]. Once connected, the supervisor spawns a long-running task to manage the etcd watch stream for real-time configuration updates [2][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git clone --depth 1 "https://github.com/api7/aisix.git" /tmp/aisix > /tmp/clone.log 2>&1 || { cat /tmp/clone.log; exit 1; }

printf '\n--- etcd provider relevant section ---\n'
sed -n '1,240p' /tmp/aisix/crates/aisix-etcd/src/etcd_provider.rs

printf '\n--- server main startup section ---\n'
sed -n '530,610p' /tmp/aisix/crates/aisix-server/src/main.rs

printf '\n--- snapshot cache definitions/usages ---\n'
rg -n -C 6 'struct SnapshotCache|impl SnapshotCache|restore_from_cache|effective_snapshot_cache_path|SnapshotCache::new|SnapshotCache::disabled' /tmp/aisix/crates

Repository: api7/aisix

Length of output: 1999


🌐 Web query:

site:github.com/api7/aisix crates/aisix-etcd/src/etcd_provider.rs async fn connect EtcdConfigProvider

💡 Result:

In the api7/aisix repository, the async function connect_client (located within crates/aisix-etcd/src/etcd_provider.rs as part of the EtcdConfigProvider implementation) is responsible for establishing a connection to an etcd server [1][2]. Key characteristics of this function as of August 2026 include: 1. Connection Logic: It builds a connection using the etcd-client crate, configuring timeouts and optional authentication (user/password) based on the provided configuration [1][2]. 2. TLS Support: The function supports TLS (via OpenSSL) when an https:// scheme is detected in the etcd host configuration [3][4]. It enables mTLS by configuring the etcd-client with ca_cert_pem, client_cert_pem_and_key, or insecure_skip_verify options depending on the settings provided [1][2]. The implementation specifically uses the openssl-tls backend to facilitate custom verification logic, such as allowing specific hostname mismatches [4]. 3. Connectivity Probe: Instead of using Maintenance.Status, the function uses a KV.Get request (specifically fetching a probe key) as a connectivity probe, as this is compatible with the project's gRPC-based management layers [4]. 4. Evolution: The function has undergone significant refinements, including updates to how TLS configurations are parsed and applied to improve robustness and idiomatic Rust usage (e.g., transitioning from ad-hoc parsing to dedicated structs like EtcdTlsConfig) [5]. Top results: [1][3][2][4][5]

Citations:


🌐 Web query:

site:github.com/api7/aisix crates/aisix-server/src/main.rs Supervisor::restore_from_cache EtcdConfigProvider::connect

💡 Result:

In the AISIX project (api7/aisix), the EtcdConfigProvider::connect method—often called within the context of the Supervisor's initialization—is responsible for establishing a connection to an etcd cluster [1][2]. As of recent development (April 2026), this process involves the following key steps: 1. Configuration Validation and Client Setup: The provider validates the etcd connection configuration [1][3]. It specifically checks for HTTPS schemes in the provided endpoints to determine if TLS should be enabled [1][2]. 2. TLS and mTLS Handling: If TLS is required, the provider uses the OpenSSL backend (via etcd_client) to configure the connection [1][2][4]. It handles CA certificates, client certificates, and private keys (for mTLS), and provides options for insecure verification [1][5]. 3. Connectivity Probe: After connecting the client, the provider performs a connectivity check [2]. Instead of using Maintenance.Status (which may return a 404 error depending on the etcd deployment), it executes a KV.Get operation (using a probe key like probe) to verify that the connection is active and functional [2]. 4. Retry Logic: When initializing the supervisor (often in Supervisor::restore_from_cache or similar routines), the provider utilizes a retry mechanism (e.g., using ConstantBuilder) to handle transient connection failures during the initial connection attempt [1][3]. The Supervisor subsequently uses this established provider to maintain watch streams and synchronize configuration state from etcd [2][6].

Citations:


Restore the snapshot cache before awaiting the first required etcd connections.

EtcdConfigProvider::connect establishes the etcd gRPC client, so the current startup path can fail before Supervisor::restore_from_cache() can recover configuration from disk. If etcd is required for run, restore the snapshot cache before that connection. If etcd may be unreachable for offline startup, handle that failure path intentionally.

🤖 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 `@crates/aisix-server/src/main.rs` around lines 578 - 586, Move snapshot-cache
restoration in the startup flow before the first required etcd connection
established by EtcdConfigProvider::connect, so Supervisor::restore_from_cache()
can recover configuration before etcd-dependent initialization. Preserve the
existing managed/self-hosted path selection and disabled behavior, and
explicitly handle an unavailable etcd connection when offline startup is
supported.

Comment on lines +89 to +103
/**
* Reuse a fixed etcd prefix instead of generating a fresh one. For
* restart scenarios: `stop()` the first app (keeps etcd data), then
* spawn a second one with the same prefix so it loads the survivor
* state. The LAST app spawned on the prefix should `exit()` to clean
* it up.
*/
etcdPrefix?: string;
/**
* `managed.snapshot_cache_path` — enables the on-disk snapshot cache
* (#871) without managed mode. Point two sequential apps (same
* `etcdPrefix`) at one path to exercise cache-restored restarts.
* The caller owns the file's lifecycle.
*/
snapshotCachePath?: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve a reused etcd prefix when a successor fails to start.

etcdPrefix can identify state owned by a stopped app. The startup-failure path calls cleanup with that same prefix before spawnApp retries. This deletes retained keys and invalidates the restart scenario.

Define ownership explicitly. If a prefix is reused, a failed successor must clean only its own temporary directory. It must not clean the shared etcd prefix. Also require exit() for every stopped app after the successor exits, because a successor cannot remove the predecessor's temporary directory. Add a regression case that forces a successor startup failure and verifies that the original keys remain.

As per coding guidelines, “State assumptions explicitly” and “ask for clarification when requirements are unclear.”

Also applies to: 130-136, 211-211

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn, type ChildProcess } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 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 `@tests/e2e/src/harness/app.ts` around lines 89 - 103, Update the app harness
startup-failure cleanup around spawnApp and cleanup to distinguish a reused
etcdPrefix from the successor’s temporary directory: when a prefix is supplied,
never clean the shared prefix on successor failure, and clean only
successor-owned temporary resources. Require every previously stopped app to
call exit() after the successor exits, then add a regression case that forces
successor startup failure and verifies the original etcd keys remain.

Source: Coding guidelines

… delete path

Independent audit finding (MEDIUM) on the last-known-good change: a
rejected put now mirrors its bytes into the observed-state map even
when the row never served, so a subsequent watch delete takes the
not-present early return in apply_delete — which cleared the rejection
and the pin but left the key's bytes in the state map and never
re-flushed the cache. The deleted key then haunted source_hash until
the next resync and its document (secret-bearing for provider keys)
persisted in the cache file; a cache-restored boot during an etcd
outage resurrected the rejection for a key deleted long ago.

The state-map removal now runs on both delete branches, and the
not-present branch syncs status and flushes the cache whenever it
actually removed something. Pinned by a test asserting source_hash
returns to its pre-put value the moment the key is deleted.

Also from the audit: merge_snapshot and snapshot_has exhaustively
destructure AisixSnapshot so adding a resource kind without wiring
these paths becomes a compile error instead of a silently unpinnable
kind, and the snapshot_cache_path docs state that a bare key (YAML
null) reads as omitted.
@membphis

membphis commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Independent audit (repo guideline 8)

A fresh, zero-context audit agent reviewed this PR against the six contract claims in the description (correctness, reliability, security, sensitive-info leakage, breaking changes, e2e coverage), re-running the unit suites and the e2e against a live etcd. Findings and disposition:

MEDIUM — deleted never-serving key leaked into observed state (FIXED in 480dbca). Because a rejected put now mirrors its bytes into the observed-state map, a watch delete for a key that never served took the not-present early return in apply_delete, which cleared the rejection and pin but left the key's bytes in state and skipped the cache flush: source_hash kept a phantom key until the next resync, the deleted document persisted in the cache file (secret-bearing for provider_keys), and a cache-restored boot could resurrect the rejection. The state removal now runs on both branches and the not-present branch syncs + flushes when it removed anything; pinned by deleting_a_rejected_never_serving_key_clears_observed_state.

LOW — kind-dispatch drift guard (FIXED in 480dbca). merge_snapshot and snapshot_has (both introduced by this PR) now exhaustively destructure AisixSnapshot, so adding a resource kind without wiring the pin/inject paths is a compile error instead of a silently unpinnable kind. The pre-existing dispatch sites (apply_delete's remove match, resource_counts) are unchanged to keep this PR's diff scoped; they are covered by the existing every-kind regression tests.

LOW — snapshot_cache_path: YAML null (FIXED in 480dbca, docs). A bare key previously failed deserialization and now reads as omitted; the field docs state this explicitly. Behavior direction is benign (managed default on, self-hosted off).

LOW — unordered spawned cache flushes (JUSTIFIED, not fixed). flush_cache spawns each write and the cache's write lock serialises but does not order them, so on a slow disk an older snapshot can land last; a crash in that window restores slightly stale state, now including a possibly missing pin (the staleness clock would then restart at the next resync). This mechanism predates this PR, is self-healing at the first live cycle, and an ordering fix (flush sequence numbers checked under the write lock) is a standalone change to the pre-existing cache writer — deferring rather than growing this PR's blast radius.

All other angles came back clean: the capture invariant held against constructed adversarial sequences, single-lock discipline verified, the cache's stale section is the same data class in the same file as the pre-existing entries (no new secret exposure), new wire fields are timestamps only and strictly additive, cache format compatibility verified in both directions, and the e2e restart leg proves a live resync (sentinel write) rather than cache replay. Audit verdict after fixes: no open HIGH/MEDIUM.

@membphis
membphis merged commit 81e4fc6 into main Aug 4, 2026
11 checks passed
@membphis
membphis deleted the claude/red-last-known-good-resync-04c3fb branch August 4, 2026 13:13
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.

Config forward-compat: unknown fields from a newer CP whole-row-reject resources on older DPs — lenient parse + tri-state compat status

1 participant