Skip to content

fix(settings): revalidate DB resource claims when switching DB nodes - #355

Merged
zjy365 merged 3 commits into
mainfrom
fix/db-settings-card-stale
Sep 17, 2026
Merged

zjy365 merged 3 commits into
mainfrom
fix/db-settings-card-stale

Conversation

@zjy365

@zjy365 zjy365 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

Clicking between different database nodes retargets the right-side DB Settings panel in place (same provider instance, new target). On a quick revisit of a previously viewed node — back inside SWR's default 2s dedupe window — revalidation was suppressed entirely: cache hit, no network request, and the cards (Replicas & Resources, Storage, Connection Address) kept rendering that node's earlier claim. Reproduced by an interaction test: view A (2 replicas / 1 core / 2Gi / 20Gi) → switch to B → backend A changes to (5 / 2 / 8Gi / 50Gi) → switch back within 2s → panel still shows (2 / 1 / 2 / 20) with zero fetches issued. Switching to a node never visited before was never affected (new SWR key fetches normally).

Fix

  • packages/api/src/hooks/use-product-resource.ts: new optional dedupingInterval option. The hook keeps SWR's default window; only callers that retarget in place opt out. The AP workload-settings (1s reconcile poll) and image-update (2s poll) callers are untouched.
  • apps/ui/src/features/resource-settings/settings-provider-db.tsx: DbSettingsProvider passes dedupingInterval: 0 so every node (re)selection revalidates immediately.
  • Defense in depth (not the fix for the above): dbClaimBodyForTarget only accepts a claim whose metadata.name and metadata.namespace both match the current target — failing closed when either is missing — and now also backs the displayName read that previously used the unguarded claim. A foreign claim leaves the pane in its loading/unavailable state instead of rendering another DB's data.

Testing

apps/ui/src/features/resource-settings/db/db-settings-node-switch.test.tsx:

  • quick revisit inside the dedupe window — the regression: must issue a fresh fetch and show the current values (fails on main).
  • claim-mismatch unit tests for dbSettingsDataFromExactResource — wrong name / wrong namespace / missing namespace / no claim / no target.
  • provider-level foreign claim — fetch answers with another DB's claim; no cards render.

bun test src/features/resource-settings/ — 252 pass; packages/api — 15 pass; bun typecheck, bun check clean.

Clicking between DB nodes retargets DbSettingsProvider in place, but SWR's
default 2s dedupe window suppressed revalidation on a quick revisit, so the
cards (Replicas & Resources, Storage, Connection Address) kept rendering the
previously cached claim. Set dedupingInterval to 0 for product-resource
fetches so every node (re)selection revalidates, and guard
dbSettingsDataFromExactResource against claims that do not belong to the
current target so a stale cache can never back another DB's cards.
@norberia

Copy link
Copy Markdown
Contributor

@cursoragent review this

@cursor

cursor Bot commented Sep 17, 2026

Copy link
Copy Markdown

I reviewed PR #355 and posted a summary plus three inline comments.

The same-key SWR bug is real: a quick A→B→A revisit inside the 2s dedupe window serves the cached claim and skips the network. Test 4 is the right regression. I would not merge as-is — dedupingInterval: 0 is hardcoded on useBrainProductResource, so AP settings and image-update lose coalescing too. mutate() on target change, or an opt-in flag like the list hooks, is enough.

The identity guard does not run on that path (name/namespace still match a stale-but-correct DB). It would only help if SWR returned another key’s claim, which 2.4.1 does not do without keepPreviousData. Tests 1–3 would pass on main without this PR.

Open in Web Open in Cursor 

@cursor

cursor Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review

The same-key SWR dedupe bug is real, and test 4 is the right regression for it. I would not merge this as-is: the shared-hook change is broader than the settings retarget, and the identity guard is sold as fixing “another DB’s cards” even though it cannot fire on the path this PR actually tests.

SWR 2.4.1 (apps/ui) defaults: dedupingInterval: 2000, keepPreviousData off. Keys are [route, {name, namespace}, credentialKey]. A name change is a new cache slot, so A→B does not keep A’s data. What the 2s window does suppress is a revisit of A (A→B→A) while FETCH[A] is still live: cache hit, no network, isLoading/isValidating false.

That is a different bug from the PR opener (“cards kept rendering the previously viewed DB”). Title can move with target.name / loading chrome while cards stay on a claim; subtitle (data.states.displayEngine) would not become “Database Redis” unless data was already the new resource. SettingsHost retargets DbSettingsProvider in place (settings-host.tsx computes entryKey but does not remount on it).

mutate() already bypasses dedupe (MUTATE_EVENTshouldStartNewRequest without opts.dedupe). A target-change mutate() (skip the initial mount) fixes the tested revisit without disabling coalescing for AP settings + image-update.


Request changes

1. Do not hardcode dedupingInterval: 0 on useBrainProductResource.
Callers: DbSettingsProvider, useApWorkloadSettings (1s reconcile poll for 30s), useApImageUpdate (2s while a launch pending update exists). Re-renders do not refetch; focus (5s throttle), reconnect, refreshInterval, mount/key change, and mutate() still do. Interval 0 means those no longer coalesce with a fetch from <2s ago — Strict Mode double-fetch, settings+image-versions on the same AP, poll overlapping focus.

List hooks already pass SWR flags through (use-k8s-namespaced-list.ts, use-dbs-k8s-list.ts). This hook only took refreshInterval, then hardcoded 0 for everyone.

Smallest fix: optional dedupingInterval (default 2000), pass 0 from DB settings (and AP settings if that pane has the same product bug); or void dbResource.mutate() when target.name/namespace changes. Leave image versions on the default window.

2. The identity guard is not the card-stale fix, and it is untested.
dbSettingsDataFromExactResource matching metadata.name/namespace does nothing when the cache is the right DB with stale spec (test 4). It only helps if SWR handed back another key’s claim (keepPreviousData — not used here). Comments/PR text that it “can never render another DB’s spec” overclaim.

If you keep it as defense-in-depth: add a unit test that injects a mismatched claim → null (today settings-section-model.test.tsx only covers the happy path); fail closed when metadata.namespace is missing (namespace !== undefined && currently accepts it, then namespaceFallback: target.namespace makes data.workload look like the target); run displayName (settings-provider-db.tsx ~113–120) through the same check — it still reads the unguarded claim.


Tests

Test Locks this bug?
sections refresh No — no SWR/provider/guard. Would pass before this PR.
provider A→B No — new key already fetches under default SWR.
revisit after 2500ms No — sleeps past the default 2s FETCH TTL. Old code would revalidate too.
quick revisit Yes — the only regression.

Please do not describe “4 tests fail before / pass after.” Keep #4; replace #1–#3 with a cheap mismatch unit test and, if you care about the report, a connection-string assertion. #3 is a 2.5s wall-clock sleep for a window the default already covers.


Nits / questions

  • Comment on the hook should say “same-key cache inside the 2s FETCH TTL,” not imply retarget-to-another-node.
  • Should SettingsHost remount with key={settingsEntryKey(entry)} so Settings Owner identity (ADR-0019) resets provider state instead of retargeting in place? Remount alone does not beat same-key dedupe.
  • Does AP settings show the same quick-switch bug in product? If yes, opt that caller in; if no, do not change it via the shared hook.

Verdict: request changes. Test 4 is worth keeping; the production change should be scoped to the settings retarget, and the guard should either be tested as defense-in-depth or not claimed as the fix.

Comment on lines +40 to +50
// Only a claim that actually belongs to the target may back its cards; a
// cached claim for another DB would render that DB's spec under this pane.
const metadata = asRecord(resource.metadata);
const name = typeof metadata?.name === "string" ? metadata.name : undefined;
const namespace =
typeof metadata?.namespace === "string" ? metadata.namespace : undefined;
if (
name !== target.name ||
(namespace !== undefined && namespace !== target.namespace)
) {
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This guard does not run on the bug this PR tests. Same-key stale cache (postgres claim, postgres target, spec changed on the backend) matches metadata.name/namespace, so the cards still render the old spec. Only dedupingInterval: 0 (or mutate() on retarget) fixes that path.

It would only help if SWR returned another key’s claim (keepPreviousData). That option is not set here, and SWR 2.4.1 does not keep previous-key data by default. So on A→B the cache slot is already empty/undefined before this check sees A’s body.

If this stays as defense-in-depth:

  1. Add a unit test that feeds a mismatched claim and expects null. Today settings-section-model.test.tsx only covers the happy path; none of the new node-switch tests inject a wrong-identity payload.
  2. Fail closed on missing namespace. namespace !== undefined && namespace !== target.namespace accepts a claim with no metadata.namespace, then dbResourceToSettingsData(..., { namespaceFallback: target.namespace }) fills in the target namespace so data.workload looks correct.
  3. displayName a few lines below still reads k8sGetClaimBody(dbResource.data) without this check — if the guard is meant to stop another DB’s claim from backing this pane, the title path is a hole.

Please do not comment/PR-describe this as “a stale cache can never render another DB’s spec under this pane’s cards.” That is not what the reproduction demonstrates.

Comment on lines +67 to +70
// Settings surfaces retarget this hook as the user switches resource nodes;
// SWR's default dedupe window would suppress revalidation on a quick
// revisit and keep serving the previously cached claim as card values.
{ dedupingInterval: 0, refreshInterval }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hardcoding dedupingInterval: 0 here disables SWR’s 2s coalescing for every caller of useBrainProductResource, not just DB settings retarget:

  • DbSettingsProvider (the bug)
  • useApWorkloadSettings (1s reconcile poll for 30s after save)
  • useApImageUpdate (2s poll while a launch pending update exists)

Re-renders do not refetch, but focus (5s throttle), reconnect, refreshInterval, remount, and key change still do. Interval 0 means those no longer share a post-flight window — Strict Mode double-fetch in dev, settings + image-versions on the same AP, poll overlapping a focus revalidate.

useK8sNamespacedList / useDbsK8sList already take SWR flags as options. This hook should too (dedupingInterval?: number, default 2000), with 0 passed only from the settings providers that retarget in place.

Even smaller: mutate() already bypasses dedupe. In DbSettingsProvider, void dbResource.mutate() when target.name/namespace changes (skip the initial mount) fixes the A→B→A revisit without touching AP image versions.

The comment also describes the wrong failure mode. The 2s FETCH TTL suppresses a same-key revisit (A→B→A), not “another node’s cached claim.” A name change is a new SWR key; without keepPreviousData (default off in 2.4.1), A→B does not keep A’s data.

Comment on lines +128 to +375
test("DB settings sections refresh card values when the database data switches", async () => {
await withTestDom(async (actAndDrain) => {
let rendered: ReturnType<typeof render> | undefined;

await actAndDrain(() => {
rendered = render(
<DatabaseSettingsPaneContent
data={settingsDataFromClaim(POSTGRES_CLAIM)}
editable={false}
/>
);
});
assert.ok(rendered, "initial render");
const initialView = rendered;
assert.match(
initialView.container.textContent ?? "",
POSTGRES_HEADING_PATTERN
);
assert.deepEqual(sliderValues(initialView.container), [2, 1, 2, 20]);

await actAndDrain(() => {
initialView.rerender(
<DatabaseSettingsPaneContent
data={settingsDataFromClaim(REDIS_CLAIM)}
editable={false}
/>
);
});
assert.match(
initialView.container.textContent ?? "",
REDIS_HEADING_PATTERN
);
assert.deepEqual(
sliderValues(initialView.container),
[4, 2, 4, 8],
"card values must follow the newly selected database"
);
});
});

test("DB settings provider refreshes card values when the target switches", async () => {
await withTestDom(async (actAndDrain) => {
const { calls, override } = stubFetch((url) => {
if (url.includes("affine-redis")) {
return jsonResponse(REDIS_CLAIM);
}
if (url.includes("affine-postgresql")) {
return jsonResponse(POSTGRES_CLAIM);
}
return jsonResponse({});
});
let rendered: ReturnType<typeof render> | undefined;

try {
await actAndDrain(() => {
rendered = render(
providerElement({
kubeconfig: "kubeconfig-switch-test",
target: postgresTarget(),
})
);
});
assert.ok(rendered, "initial render");
const view = rendered;
assert.deepEqual(sliderValues(view.container), [2, 1, 2, 20]);

await actAndDrain(() => {
view.rerender(
providerElement({
kubeconfig: "kubeconfig-switch-test",
target: redisTarget(),
})
);
});
assert.deepEqual(
sliderValues(view.container),
[4, 2, 4, 8],
"card values must follow the newly selected DB node"
);
assert.ok(
calls.some((call) => call.url.includes("affine-redis")),
"the redis DB resource must be fetched after the switch"
);
} finally {
restoreGlobal(override);
}
await actAndDrain(() => undefined);
});
});

test("DB settings provider revalidates when switching back after backend changes", async () => {
await withTestDom(async (actAndDrain) => {
let postgresClaim = dbClaim({
cpuLimit: "1",
engine: "postgresql",
memoryLimit: "2Gi",
name: "affine-postgresql",
replicas: 2,
storageSize: "20Gi",
});
const { override } = stubFetch((url) => {
if (url.includes("affine-redis")) {
return jsonResponse(REDIS_CLAIM);
}
if (url.includes("affine-postgresql")) {
return jsonResponse(postgresClaim);
}
return jsonResponse({});
});
let rendered: ReturnType<typeof render> | undefined;

try {
await actAndDrain(() => {
rendered = render(
providerElement({
kubeconfig: "kubeconfig-switch-test-2",
target: postgresTarget(),
})
);
});
assert.ok(rendered, "initial render");
const view = rendered;
assert.deepEqual(sliderValues(view.container), [2, 1, 2, 20]);

await actAndDrain(() => {
view.rerender(
providerElement({
kubeconfig: "kubeconfig-switch-test-2",
target: redisTarget(),
})
);
});

postgresClaim = dbClaim({
cpuLimit: "2",
engine: "postgresql",
memoryLimit: "8Gi",
name: "affine-postgresql",
replicas: 5,
storageSize: "50Gi",
});

// Wait past SWR's default 2s deduping interval before revisiting.
await actAndDrain(() => undefined, 2500);

await actAndDrain(() => {
view.rerender(
providerElement({
kubeconfig: "kubeconfig-switch-test-2",
target: postgresTarget(),
})
);
});
assert.deepEqual(
sliderValues(view.container),
[5, 2, 8, 50],
"switching back to a node must reflect its current backend state"
);
} finally {
restoreGlobal(override);
}
await actAndDrain(() => undefined);
});
});

test("DB settings provider revalidates a quickly revisited node within the dedupe window", async () => {
await withTestDom(async (actAndDrain) => {
let postgresClaim = dbClaim({
cpuLimit: "1",
engine: "postgresql",
memoryLimit: "2Gi",
name: "affine-postgresql",
replicas: 2,
storageSize: "20Gi",
});
const { calls, override } = stubFetch((url) => {
if (url.includes("affine-redis")) {
return jsonResponse(REDIS_CLAIM);
}
if (url.includes("affine-postgresql")) {
return jsonResponse(postgresClaim);
}
return jsonResponse({});
});
let rendered: ReturnType<typeof render> | undefined;

try {
await actAndDrain(() => {
rendered = render(
providerElement({
kubeconfig: "kubeconfig-switch-test-3",
target: postgresTarget(),
})
);
});
assert.ok(rendered, "initial render");
const view = rendered;
assert.deepEqual(sliderValues(view.container), [2, 1, 2, 20]);

await actAndDrain(() => {
view.rerender(
providerElement({
kubeconfig: "kubeconfig-switch-test-3",
target: redisTarget(),
})
);
});
assert.deepEqual(sliderValues(view.container), [4, 2, 4, 8]);

postgresClaim = dbClaim({
cpuLimit: "2",
engine: "postgresql",
memoryLimit: "8Gi",
name: "affine-postgresql",
replicas: 5,
storageSize: "50Gi",
});

// Switch back immediately: no 2s wait. The revisit must still revalidate.
const postgresFetchCountBefore = calls.filter((call) =>
call.url.includes("affine-postgresql")
).length;
await actAndDrain(() => {
view.rerender(
providerElement({
kubeconfig: "kubeconfig-switch-test-3",
target: postgresTarget(),
})
);
});
const postgresFetchCountAfter = calls.filter((call) =>
call.url.includes("affine-postgresql")
).length;
assert.ok(
postgresFetchCountAfter > postgresFetchCountBefore,
"revisiting a node must issue a fresh resource fetch"
);
assert.deepEqual(
sliderValues(view.container),
[5, 2, 8, 50],
"a quick revisit must not serve the stale cached claim"
);
} finally {
restoreGlobal(override);
}
await actAndDrain(() => undefined);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only this last test locks the regression (quick A→B→A inside the 2s FETCH TTL, backend A changed, must refetch and show 5 / 2 / 8 / 50).

The three above it would pass on main without this PR:

  • sections refresh — rerenders DatabaseSettingsPaneContent with new props. No SWR, no provider, no identity guard. Draft/slider sync was already covered by identityKey reset.
  • provider target switch A→B — new SWR key; default SWR already fetches B.
  • revisit after 2500ms — sleeps past the default 2s dedupe window, so old code would revalidate too. Please drop the wall-clock wait (or keep it only if you are asserting the default window still works when dedupingInterval is not 0).

The PR text that “4 tests fail before, pass after” is not accurate. Keep this test; replace the others with a cheap dbSettingsDataFromExactResource mismatch unit test if the identity guard stays.

Address PR review: useBrainProductResource keeps SWR's default dedupe
window and takes an optional dedupingInterval instead of a hardcoded 0,
so the AP workload-settings and image-update pollers keep coalescing.
Only DbSettingsProvider opts into 0 — it retargets in place and a quick
same-key revisit inside the default window served the cached claim.

The identity guard is now shared (dbClaimBodyForTarget), fails closed
when the claim has no namespace, and also backs the displayName read
that previously used the unguarded claim. Tests: keep the quick-revisit
regression, add claim-mismatch unit coverage and a provider-level
foreign-claim case; drop the cases that also pass on main.
@zjy365

zjy365 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed in 9c19977:

  1. Shared hook: reverted the hardcoded dedupingInterval: 0; useBrainProductResource now takes an optional dedupingInterval and keeps the default window. Only DbSettingsProvider opts into 0 — useApWorkloadSettings (1s reconcile) and useApImageUpdate (2s poll) keep their coalescing. (Went with the opt-in flag rather than mutate() on retarget to avoid ref bookkeeping around the initial mount.)
  2. Identity guard: repositioned as defense in depth — shared dbClaimBodyForTarget, fails closed when metadata.namespace is missing, and now also backs the displayName read that previously used the unguarded claim. Added claim-mismatch unit tests plus a provider-level test where every fetch answers with another DB's claim (no cards render).
  3. Tests: kept only the quick-revisit regression; dropped the three that also pass on main (including the 2.5s sleep). PR description rewritten — same-key revisit is the failure mode, and the guard is no longer claimed as the card-stale fix.

Open question from the review worth a product check: AP settings retargets the same way (SettingsHost never remounts), so it likely has the same quick-switch behavior — if confirmed, that caller should opt into dedupingInterval: 0 as a separate change rather than via the shared default.

@zjy365
zjy365 merged commit 6b7c21f into main Sep 17, 2026
6 checks passed
@zjy365
zjy365 deleted the fix/db-settings-card-stale branch September 17, 2026 09:59
zjy365 added a commit that referenced this pull request Sep 18, 2026
…es (#356)

Follow-up to #355. Revealing a connection string on the DB Settings pane
left the DSN in useRevealedRow state for 30s while the pane retargets in
place, and the row key was connection.id (private/public) — identical
across every DB Service. Switching nodes within the reveal window made
the next DB's connection row display (and copy) the previous DB's full
DSN.

Scope the settings rows' keys by workload identity so a reveal can never
match another DB's row, and clear the revealed row when the identity
changes (useRevealedRow now exposes clearRevealedRow) so the secret does
not linger in state after the switch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants