Skip to content

Custom domains page polls certificate progress fast only while a certificate is generating - #1802

Merged
dawsontoth merged 8 commits into
claude/1626-preserve-domain-cert-intentfrom
claude/1630-challenge-cert-poll-backoff
Oct 12, 2026
Merged

dawsontoth merged 8 commits into
claude/1626-preserve-domain-cert-intentfrom
claude/1630-challenge-cert-poll-backoff

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1630

Stacked on #1801. Its base is claude/1626-preserve-domain-cert-intent, so this diff is only the polling change. Review and merge #1801 first, then this retargets to stage.

⊙ Problem

The cluster Domains table polled the cluster's ChallengeCertificate rows every 5 seconds for as long as it was open. A cluster without that table answers 404, and getSearchByValue deliberately reads a 404 as an empty result. So the poll never errored, pollUnlessForbidden could never stop it, and one long-lived tab sent 993 doomed requests in 112 minutes. In RUM, /Cluster/{id}/operation 404s rose from 71 to 1,399 a week (#1630). The rows exist only to show a certificate being generated, so the 5s rate was spent exactly when there was nothing to watch.

❓ Your call: is a once-a-minute idle poll the right floor? I kept one so changes made outside this tab (a bind from another tab, a renewal starting) still show up within a minute. Stopping the idle poll entirely (false) would remove the remaining ~1 request/min per open tab, but those changes would then only appear on refocus or remount. Either way it's one constant.

💡 Solution

The poll runs at 5s only while a certificate is generating (no issueDate, or inProgress), or for three minutes after a domain change. Otherwise it polls once a minute. The 403 stop from pollUnlessForbidden still applies on top.

The issue's recommended option 2 needed one correction. Invalidating the rows when the domain-ids mutation succeeds is not enough on its own: central manager answers the PUT before host manager acts on it, and host manager only upserts a row per added domain once it reconciles the change (deploying the cert component and restarting, the first time). An immediate re-read usually finds nothing yet. So the mutation starts a three-minute watch window and re-reads the rows at once.

⚠️ Look hardest: three minutes is a judgment, not a measurement. If host manager takes longer, the new rows are still found by the next idle tick (≤60s later), and from then on the 5s rate applies while the certificate generates.

⚖️ Alternatives

  • Idle backoff alone (the issue's option 1): it adds up to 60s before a bind's progress appears, in the one flow this table exists for.
  • Treat the 404 as terminal (option 3): it means changing getSearchByValue's shared 404-as-empty contract, or wrapping it. The watch window already covers the only Studio action that creates the table, and the idle rate bounds everything else.
  • Poll fast while any cluster instance is UPDATING: it is a server-side signal, but it couples this query to the cluster record and to statuses that version upgrades also use.

🔧 Changes

✅ Verification

End-to-end route: not observable end-to-end here. It needs a cluster without the ChallengeCertificate table and an open Domains tab over minutes. Verified at the query's own refetchInterval and through the real DomainsManagement:

Pre-push review: one full round against the #1801 branch (codex graded, gemini). The verdict was COMMENTS with no defects found. The Harper-domain adjudicator failed on auth and the Cursor legs failed on the 1Password SSH agent, as on #1801. I took the comment trims. I rejected:

  • .ts import extensions: this repo's imports are extensionless throughout;
  • an unbounded watchingUntil map: it holds one entry per cluster this tab changed domains on;
  • a fake-time leak between tests: each test uses its own cluster id, and the fixed date precedes any real run.

🤖 Generated with Claude Code (Claude Opus 5.5); posted via @dawsontoth.

Related PRs: #1801 overlaps (this PR stacks on it)

Complexity: medium

Review-Coverage: authored=claude; ran=gemini,codex; blocked=cursor-composer(no-receipt),domain(auth); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=1; full=1 @ 5b0d6a8

Review-Attention: read ~3m (raised: degraded review) @ 5b0d6a8

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces dynamic polling for custom domain certificate progress, utilizing a 5-second interval during active generation or immediately after a domain change, and a 1-minute interval during idle periods. It also adds corresponding tests and documentation. The review feedback recommends evicting stale entries from the watchingUntil map in getChallengeCertificates.ts to prevent memory accumulation over time as cluster IDs churn.

Comment on lines +32 to +35
export function watchChallengeCertificates(queryClient: QueryClient, clusterId: string) {
watchingUntil.set(clusterId, Date.now() + CERTIFICATE_WATCH_AFTER_DOMAIN_CHANGE_MS);
return queryClient.invalidateQueries({ queryKey: challengeCertificatesQueryKey(clusterId) });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To prevent memory accumulation over time as cluster IDs churn (e.g., due to deletion, rolling replacements, or autoscaling), stale entries in the watchingUntil map should be evicted once they exceed their validity horizon.

export function watchChallengeCertificates(queryClient: QueryClient, clusterId: string) {
	const now = Date.now();
	for (const [id, until] of watchingUntil.entries()) {
		if (until <= now) {
			watchingUntil.delete(id);
		}
	}
	watchingUntil.set(clusterId, now + CERTIFICATE_WATCH_AFTER_DOMAIN_CHANGE_MS);
	return queryClient.invalidateQueries({ queryKey: challengeCertificatesQueryKey(clusterId) });
}
References
  1. When caching or tracking state (such as last observation times) for dynamic entities (e.g., cluster nodes) processed in chronological order, evict stale entries once they exceed their validity horizon. This prevents memory accumulation when entity IDs churn (e.g., due to autoscaling or rolling replacements). Ensure that evicted entities can be correctly re-registered if they reappear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declining. The only writer is watchChallengeCertificates, called once per successful domain change from setDomainIdsOnCluster's onSuccess (setDomainIdsOnCluster.ts:37). The map is keyed by cluster id, so a repeat overwrites the entry. A tab therefore holds at most one number per cluster its user changed domains on by hand. Nothing here churns the way the referenced autoscaling case does. An expired entry is also already inert, because the read compares it against Date.now() (getChallengeCertificates.ts:41). An eviction pass would add code to save a few bytes on a small, bounded set of entries.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.

@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 71.16% 11385 / 15999
🔵 Statements 71.28% 12136 / 17025
🔵 Functions 65.55% 2998 / 4573
🔵 Branches 66.69% 8883 / 13319
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/cluster/domains/Management.tsx 76.82% 84.31% 81.25% 78.2% 87, 96-97, 189-215, 223, 229-230
src/features/cluster/domains/constants/tableDefinition.tsx 81.39% 81.25% 73.33% 82.92% 38-43, 102, 128, 152, 161, 174
src/features/cluster/domains/queries/getChallengeCertificates.ts 94.44% 91.66% 100% 100% 51
src/features/clusters/hooks/useUpdateCluster.ts 0% 100% 0% 0% 12-23
src/features/clusters/mutations/setDomainIdsOnCluster.ts 100% 100% 100% 100%
src/features/clusters/upsert/ClusterBilling.tsx 0% 0% 0% 0% 33-134
src/features/clusters/upsert/ClusterDetails.tsx 0% 0% 0% 0% 62-260
src/features/clusters/upsert/ClusterForm.tsx 0% 0% 0% 0% 72-590
src/features/organization/mutations/deleteDomain.ts 40% 100% 33.33% 40% 11-12, 17
src/features/organization/queries/getOrganizationDomains.ts 100% 100% 100% 100%
Generated in workflow #2246 for commit b2ee95a by the Vitest Coverage Report Action

dawsontoth and others added 8 commits October 12, 2026 13:03
The Domains table polled the cluster's ChallengeCertificate rows every 5s for
as long as it was open. A cluster without that table answers 404, which
getSearchByValue reads as an empty result, so the poll never errored and
pollUnlessForbidden could never stop it: one tab sent ~1,000 doomed requests
in two hours.

Poll at 5s only while a certificate is generating, or for three minutes after
a domain change, and once a minute otherwise. The domain-ids mutation now
starts that window and re-reads the rows at once, since nothing else ever
invalidated them and the 5s poll was the only way a new challenge was seen.

Closes #1630

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Bind selection was page state that outlived its rows. A ticked domain that was then removed,
or bound to another cluster, was still counted and sent, and central manager refused every later
Bind (404 not found, 400 already associated) until the page was left.

Bind now counts and sends the selection intersected with the list's bindable rows, using the
same predicate that renders the checkbox. A removal takes its row out of the cached list as soon
as it succeeds, so the gap before the refetch lands cannot send it, and Bind waits while a
removal is in flight, including a click queued before the controls disabled. Remove likewise
refuses a click queued behind a Bind that is still sending its domain. Bind reads the list from
the query client at click time, since a removal reaches the button a render later.

Closes #1800

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the guards read

The bind, unbind and remove handlers read mutation state and the domain list from useQueryClient() but still invalidated through the module-level queryClient import. Production provides that same instance, so nothing changed there, but any other provider (every test here) never saw the refetch. Tests now pin that each change refetches the list on the page's own client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A domain change that reads the list before sending it (as the pre-send check stacked on this branch does) would satisfy a count taken at click time with no invalidation at all. Counting from the request makes only the post-success refetch pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the page, and pre-check it

A refused PUT /Cluster/{id} (bind, unbind, or a cluster edit) produced only a toast that faded,
and the domain changes sent whole lists Studio had not checked. RUM showed 39 of 40 such PUTs
refused in a day on one cluster, with users retrying across three pages (#1686).

Domain changes now pass intent (add, remove). The mutation builds the list from a fresh read of
the cluster and the organization's domains rather than from what the page last saw, so a domain
another member bound meanwhile is no longer silently unbound. Before sending, it refuses what
central manager would refuse, mirroring its source:

- the cluster is not RUNNING, or an instance is FAILED or in TRANSITIONING_INSTANCE_STATES
  (assertClusterMutable, sharedHelpers.js:587-606; STOPPED and UPDATED included);
- the cluster is self-managed (updateCluster.js:166);
- a domain the change touches is missing, unverified or bound to another cluster
  (updateCluster.js:173-191), the cluster's existing ones included; those point at support.

It refuses rather than drops: central manager unbinds whatever the new list leaves out without
checking which cluster holds it (updateCluster.js:199-205).

Bind and unbind failures, refusals and central manager's own reasons alike, now stay on the
Domains page; a cluster edit's failure stays beside its submit button. Both mutations skip the
global toast and report real request failures to RUM at mutation level. #1801's cache write of
the accepted list is gone, since every change now re-reads the cluster.

Refs #1686

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ays why

The tooltip's trigger slotted onto a fragment, which can take neither the trigger's props nor its ref, so React dropped every handler and logged an invalid-prop warning on each render. Slot it onto a wrapper around the button instead: a disabled button takes no pointer events, so the hover has to land on something around it. The domain name stays outside the trigger, so reading it does not pop a tooltip about deleting it.

Closes #1843

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oard users get the tooltip

A disabled button can't take focus, so the explanation for why Remove is off was hover-only. The wrapper the tooltip triggers on is now focusable exactly while the button is disabled; while it is enabled the button keeps the only tab stop and its focus still opens the tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Radix puts aria-describedby on the trigger, and with the wrapper always the trigger an enabled Remove took focus while the wrapper held the description, so a screen reader never announced it. The button is now the trigger unless the domain is bound; only then does a focusable wrapper stand in, so an in-flight change no longer gives unbound rows a tab stop whose tip doesn't explain the disabled state. Tests check the focused element's aria-describedby against the tooltip, plus the pending case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review October 12, 2026 17:07
@dawsontoth
dawsontoth requested a review from a team as a code owner October 12, 2026 17:07
@dawsontoth
dawsontoth force-pushed the claude/1630-challenge-cert-poll-backoff branch from 5b0d6a8 to b2ee95a Compare October 12, 2026 17:07
@dawsontoth
dawsontoth merged commit d56bf21 into stage Oct 12, 2026
4 checks passed
@dawsontoth
dawsontoth deleted the claude/1630-challenge-cert-poll-backoff branch October 12, 2026 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant