mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 18:11:51 +08:00
<!-- ccr-slack-attribution --> _Requested by **Alaister Young** · [Slack thread](https://supabase.slack.com/archives/C0161K73J1J/p1787738517181409?thread_ts=1787635785.354489&cid=C0161K73J1J)_ Follow-up to #49298, which was squash-merged before @joshenlim's last review round was addressed. Picking up the review comments here. ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Chore — dead code removal and comment corrections. No behavior change. ## What is the current behavior? Three of @joshenlim's review comments on #49298 are still open on master: - [Dead code in `ha-cluster-cells-query.ts`](https://github.com/supabase/supabase/pull/49298#discussion_r3861029414) — "seems to be dead code? no one's consuming this file" - [Dead code in `ha-cluster-databases-query.ts`](https://github.com/supabase/supabase/pull/49298#discussion_r3861031230) — "likewise - seems to be dead code" - [`STATUS_BADGE_VARIANTS` statuses](https://github.com/supabase/supabase/pull/49298#discussion_r3861095281) — "just to sanity check these are the only statuses? are there any failure states? e.g 'Failed'" Concretely, on master today: - `apps/studio/data/ha-admin/ha-cluster-cells-query.ts` and `apps/studio/data/ha-admin/ha-cluster-databases-query.ts` ship query options that nothing imports. The diagram only reads `/poolers` and `/gateways`. - `multipoolerSchema.lifecycleStatus` is an undocumented `z.string()`, while its neighbours `type` and `servingStatus` both list their expected proto values in a comment. - The comment above `HA_POOLER_STATUS_LABELS` claims the labels "Matches the status vocabulary of the read replica surfaces (getStatusLabel)". They don't — `getStatusLabel` in `ReadReplicas/ReadReplicas.utils.ts` also returns `Failed`, `Restarting`, `Resizing` and `Restoring`, none of which the HA labels have. ## What is the new behavior? - Deleted both dead query modules and pruned the orphaned `cells` and `databases` factories from `haAdminKeys`, keeping `poolers` and `gateways`. Verified by grep that neither file name nor any of their exported symbols (`haClusterCellsQueryOptions`, `HaClusterCellsData`, `haClusterDatabasesQueryOptions`, `HaClusterDatabasesData`, the `*Variables`/`*Error` types) nor `haAdminKeys.cells` / `haAdminKeys.databases` has a single reference left anywhere outside the deleted files. No re-export shims left behind. `get-ha-admin.ts` stays — `poolers` and `gateways` still use it. - Documented `lifecycleStatus` against the actual enum, `PoolerLifecycleStatus` in [multigres `proto/clustermetadata.proto`](https://github.com/multigres/multigres/blob/main/proto/clustermetadata.proto): `LIFECYCLE_UNKNOWN` (zero value, omitted from JSON) | `STARTING` | `ACTIVE` | `STOPPING` | `SHUTDOWN` | `QUARANTINED`. `getPoolerStatus` already maps every member. - Reworded the `HA_POOLER_STATUS_LABELS` comment to say the labels are a subset drawn from the read replica vocabulary rather than a match for it, and noted where the read replica `Failed` lands on the HA side. **On the `Failed` question:** the answer from the proto is that there is no dedicated failure member. The terminal states are `QUARANTINED` — the pooler "has given up trying to become a healthy replica: it cannot automatically recover to a functioning state (e.g. it could not complete a pg_rewind, could not restore from backup to start postgres, or fell irrecoverably behind on replication)", kept alive for forensics — and `SHUTDOWN`, "durably down". Both already map to `unhealthy` / the `Unhealthy` warning badge, so the four statuses on `STATUS_BADGE_VARIANTS` are complete for the enum as it stands. If we'd rather show `QUARANTINED` as its own `Failed` status with a destructive badge (matching the read replica surface), that's a small follow-up — a product/copy call rather than a gap, so not folded in here. **Not included: [the replication page UX comment](https://github.com/supabase/supabase/pull/49298#discussion_r3861055216)** ("is there any other content we plan to add here? it feels empty atm... it's just a repeat of the home page + settings/infrastructure"). @joshenlim flagged that one himself as "UX feedback which can be addressed separately". It's a product and IA question about what that page is for, not something to answer with a code change here — leaving it for @alaister and design. ## Additional context - Verified locally: `tsc --noEmit` (0 errors), ESLint on the touched files (clean), Prettier check (clean), and `HaTopology.utils.test.ts` + `HaInstanceConfiguration.utils.test.ts` (26/26 passing). CI is green as well. - Exhaustive grep across the repo (excluding `node_modules`/`.git`/build output, covering `apps/**` incl. `lite-studio`, `packages/**` and `e2e/**`) confirmed zero remaining references to the deleted files, their exported symbols, and the removed key factories. - No test changes: the diff deletes unreferenced code and edits comments only, so there's no new behavior to cover. `HaTopology.utils.test.ts` already pins every `lifecycleStatus` value listed in the new comment. --- _Generated by [Claude Code](https://claude.ai/code/session_012StGVQSmPzpGTXrduo9Xyi)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
200 lines
8.0 KiB
TypeScript
200 lines
8.0 KiB
TypeScript
import { groupBy, partition, uniqBy } from 'lodash'
|
|
|
|
import type { Multigateway } from '@/data/ha-admin/ha-cluster-gateways-query'
|
|
import type { HaClusterPoolersData, Multipooler } from '@/data/ha-admin/ha-cluster-poolers-query'
|
|
|
|
/**
|
|
* Pure helpers mapping the multiadmin cluster state (gateways + poolers) onto
|
|
* the shapes the High Availability infrastructure diagram renders. Every field
|
|
* on the multiadmin responses is optional because proto3 JSON omits zero
|
|
* values — an absent field means "the default", not "missing data".
|
|
*/
|
|
|
|
export type HaPoolerStatus = 'healthy' | 'coming_up' | 'going_down' | 'unhealthy'
|
|
|
|
export interface HaShard {
|
|
id: string
|
|
name: string
|
|
primary?: Multipooler
|
|
replicas: Multipooler[]
|
|
}
|
|
|
|
export interface HaTopology {
|
|
gateways: Multigateway[]
|
|
shards: HaShard[]
|
|
}
|
|
|
|
export const getPoolerKey = (pooler: Pick<Multipooler, 'id'>) =>
|
|
`${pooler.id?.cell ?? 'unknown'}-${pooler.id?.name ?? 'unknown'}`
|
|
|
|
export const hasPoolerIdentity = (pooler: Pick<Multipooler, 'id'>) =>
|
|
pooler.id?.cell !== undefined && pooler.id?.name !== undefined
|
|
|
|
/**
|
|
* `routingState.role` is the authoritative writable signal; the deprecated
|
|
* `type` field is derived and only used as a fallback when the routing state is
|
|
* missing. The role is omitted entirely when it is ROUTING_ROLE_UNKNOWN
|
|
* (proto3 zero value).
|
|
*/
|
|
export const isPrimaryPooler = (pooler: Multipooler) => {
|
|
const role = pooler.routingState?.role
|
|
if (role !== undefined) return role === 'ROUTING_ROLE_PRIMARY'
|
|
return pooler.type === 'PRIMARY'
|
|
}
|
|
|
|
/**
|
|
* Health as reported by the pooler's topology record — not a live probe: a
|
|
* pooler that crashes without publishing STOPPING/SHUTDOWN can leave an
|
|
* ACTIVE/SERVING record behind until the topology evicts it, and "healthy"
|
|
* says nothing about replication progress. Live signals (WAL receiver state,
|
|
* replay position) exist on `GET /poolers/{cell}/{name}/status` but require a
|
|
* per-pooler fanout.
|
|
*/
|
|
export const getPoolerStatus = (pooler: Multipooler): HaPoolerStatus => {
|
|
// Lifecycle values may arrive with or without the proto enum prefix.
|
|
const lifecycle = (pooler.lifecycleStatus?.status ?? '').replace(/^LIFECYCLE_/, '')
|
|
|
|
// The proto's two terminal states: QUARANTINED (gave up recovering, kept alive
|
|
// for forensics) and SHUTDOWN (durably down). There is no separate FAILED state.
|
|
if (lifecycle === 'QUARANTINED' || lifecycle === 'SHUTDOWN') return 'unhealthy'
|
|
if (lifecycle === 'STARTING') return 'coming_up'
|
|
if (lifecycle === 'STOPPING') return 'going_down'
|
|
|
|
// Lifecycle is ACTIVE or unknown: fall back to the serving status. An absent
|
|
// servingStatus means SERVING (proto3 zero value), i.e. the node is taking
|
|
// traffic, so the default reads as healthy.
|
|
if (pooler.servingStatus === 'DRAINING') return 'going_down'
|
|
if (pooler.servingStatus === 'DISABLED') return 'unhealthy'
|
|
return 'healthy'
|
|
}
|
|
|
|
// Labels are drawn from the read replica status vocabulary (`getStatusLabel` in
|
|
// ReadReplicas.utils.ts) so both surfaces read the same way, but they are only a
|
|
// subset of it. The read replica labels also cover 'Failed', 'Restarting',
|
|
// 'Resizing' and 'Restoring', which have no one-to-one lifecycle equivalents;
|
|
// the closest to 'Failed' is QUARANTINED, surfaced as 'Unhealthy' alongside
|
|
// SHUTDOWN.
|
|
export const HA_POOLER_STATUS_LABELS: Record<HaPoolerStatus, string> = {
|
|
healthy: 'Healthy',
|
|
coming_up: 'Coming up',
|
|
going_down: 'Going down',
|
|
unhealthy: 'Unhealthy',
|
|
}
|
|
|
|
const AWS_AZ_REGEX = /\b[a-z]{2}(?:-[a-z]+)+-\d[a-z]\b/
|
|
|
|
/**
|
|
* Cells map 1:1 to availability zones in the alpha, but the exact cell naming
|
|
* format is unconfirmed — extract an AZ-shaped substring when there is one and
|
|
* fall back to the raw cell name otherwise.
|
|
*/
|
|
export const formatCellAsAvailabilityZone = (cell: string | undefined) => {
|
|
if (!cell) return undefined
|
|
return AWS_AZ_REGEX.exec(cell)?.[0] ?? cell
|
|
}
|
|
|
|
// Routing-rule terms are proto int64s, serialized as strings in JSON and
|
|
// omitted when zero. Failover counts stay far below Number's safe range.
|
|
const parseTerm = (value: string | undefined) => {
|
|
const parsed = Number(value ?? 0)
|
|
return Number.isFinite(parsed) ? parsed : 0
|
|
}
|
|
|
|
// Orders two primary claimants by routing rule: (coordinator term, leader
|
|
// subterm), greatest wins.
|
|
const compareRoutingRules = (a: Multipooler, b: Multipooler) => {
|
|
const ruleA = a.routingState?.rule
|
|
const ruleB = b.routingState?.rule
|
|
return (
|
|
parseTerm(ruleA?.coordinatorTerm) - parseTerm(ruleB?.coordinatorTerm) ||
|
|
parseTerm(ruleA?.leaderSubterm) - parseTerm(ruleB?.leaderSubterm)
|
|
)
|
|
}
|
|
|
|
export const buildHaTopology = ({
|
|
gateways,
|
|
poolers,
|
|
}: {
|
|
gateways: Multigateway[]
|
|
poolers: Multipooler[]
|
|
}): HaTopology => {
|
|
// The /ha-admin passthrough can return the same record multiple times (one
|
|
// copy per cell it fans out to), so both lists must be deduped by id —
|
|
// duplicate poolers would otherwise produce duplicate React Flow node ids,
|
|
// and extra copies of the primary would render as replicas. Records without
|
|
// a complete identity can't be told apart, so they are never deduped (keying
|
|
// by the record itself keeps each one unique).
|
|
const uniqueGateways = uniqBy(gateways, (gateway) =>
|
|
hasPoolerIdentity(gateway) ? getPoolerKey(gateway) : gateway
|
|
)
|
|
const uniquePoolers = uniqBy(poolers, (pooler) =>
|
|
hasPoolerIdentity(pooler) ? getPoolerKey(pooler) : pooler
|
|
)
|
|
|
|
const sortedPoolers = [...uniquePoolers].sort((a, b) =>
|
|
getPoolerKey(a).localeCompare(getPoolerKey(b))
|
|
)
|
|
const poolersByShard = groupBy(
|
|
sortedPoolers,
|
|
(pooler) =>
|
|
`${pooler.shardKey?.database ?? ''}/${pooler.shardKey?.tableGroup ?? ''}/${pooler.shardKey?.shard ?? ''}`
|
|
)
|
|
|
|
const shards = Object.entries(poolersByShard)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([id, shardPoolers], index) => {
|
|
const [primaries, replicas] = partition(shardPoolers, isPrimaryPooler)
|
|
// During a failover the outgoing and incoming primary can briefly both
|
|
// claim ROUTING_ROLE_PRIMARY — the highest routing rule wins, matching
|
|
// the multigateway's election. Losing claimants render as replicas
|
|
// rather than being dropped; ties keep the first in sorted order so the
|
|
// result stays deterministic.
|
|
const primary = primaries.reduce<Multipooler | undefined>(
|
|
(best, candidate) =>
|
|
best === undefined || compareRoutingRules(candidate, best) > 0 ? candidate : best,
|
|
undefined
|
|
)
|
|
return {
|
|
id,
|
|
name: `Shard ${index + 1}`,
|
|
primary,
|
|
replicas: [...primaries.filter((pooler) => pooler !== primary), ...replicas],
|
|
}
|
|
})
|
|
|
|
return { gateways: uniqueGateways, shards }
|
|
}
|
|
|
|
/**
|
|
* Query `select` projecting poolers down to the fields the topology depends on
|
|
* (identity, shard, routing role) — live status is self-fetched by the
|
|
* individual nodes and edges. React Query's structural sharing then keeps the
|
|
* result referentially stable across polls, so refetches only re-run
|
|
* layout/fitView when the topology actually changes (volatile fields like
|
|
* lifecycle timestamps would otherwise churn the data identity on every poll).
|
|
*/
|
|
const projectRoutingRule = (rule: NonNullable<Multipooler['routingState']>['rule']) =>
|
|
rule === undefined
|
|
? undefined
|
|
: { coordinatorTerm: rule.coordinatorTerm, leaderSubterm: rule.leaderSubterm }
|
|
|
|
const projectRoutingState = (routingState: Multipooler['routingState']) =>
|
|
routingState === undefined
|
|
? undefined
|
|
: { role: routingState.role, rule: projectRoutingRule(routingState.rule) }
|
|
|
|
export const selectTopologyPoolers = (data: HaClusterPoolersData): Multipooler[] =>
|
|
(data.poolers ?? []).map((pooler) => ({
|
|
id: pooler.id === undefined ? undefined : { cell: pooler.id.cell, name: pooler.id.name },
|
|
shardKey:
|
|
pooler.shardKey === undefined
|
|
? undefined
|
|
: {
|
|
database: pooler.shardKey.database,
|
|
tableGroup: pooler.shardKey.tableGroup,
|
|
shard: pooler.shardKey.shard,
|
|
},
|
|
routingState: projectRoutingState(pooler.routingState),
|
|
type: pooler.type,
|
|
}))
|