mirror of
https://github.com/supabase/supabase.git
synced 2026-09-07 02:20:52 +08:00
## 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? Bug fix (performance), plus a regression-guard test suite and docs. ## What is the current behavior? Studio's introspection queries in `@supabase/pg-meta` do `O(catalog)` work for per-table requests. On databases with very large catalogs (hundreds of thousands of relations/constraints — real deployments reach this) they take tens of seconds per dashboard interaction, trip `statement_timeout`, and create heavy CPU/memory pressure when several tabs open concurrently. Two instances of the same bug class: **1. Table Editor query (`getTableEditorSql`)** — fetches metadata for ONE table by OID, but five catalog scans are unscoped and only filtered at the top-level join: - `primary_keys` CTE — scans all of `pg_index` (`where i.indisprimary`) - `index_cols` CTE — scans all unique indexes - `relationships` CTE — scans every FK in `pg_constraint` (and is scanned twice by the two subplans) - `uniques` subquery (inside `columns`) — scans all single-column unique constraints - `check_constraints` subquery (inside `columns`) — scans all single-column check constraints The planner cannot push the outer join qual into grouped / `distinct on` subqueries, so each is computed over the full catalog and thrown away. `tables-paginated.ts` was previously rewritten to avoid exactly this pattern; the single-table query never got the same treatment. **2. Entity definitions (`getTableDefinitionSql` / `getEntityDefinitionsSql`)** — the vendored `pg_get_tabledef` plpgsql function scans the entire `information_schema.columns` view once **per column** (plus `information_schema.tables` once per call) just to decide whether a name needs double-quoting — a pure string property of a name it already holds — and its per-index partial-index lookup casts `relnamespace::regnamespace::text` across every `pg_class` row. On a 12K-table catalog this makes a single entity's DDL cost ~3.7s and a default 100-entity definitions page ~6 minutes. ## What is the new behavior? **Fix 1 — scope the Table Editor CTEs to the requested OID** (`id` is validated non-null and interpolated via `literal()`, same as the existing `base_table_info` filter): - `primary_keys` / `index_cols`: `and i.indrelid = <id>` - `relationships`: `and (c.conrelid = <id> or c.confrelid = <id>)` - `uniques` / `check_constraints`: `and conrelid = <id>` Semantics are unchanged: the top-level select already filtered every CTE to the target table, so rows for other tables were computed and discarded. The `pg_index`/`pg_constraint` lookups become index scans returning a handful of rows. One residual scan is structural: PostgreSQL has no index on `pg_constraint.confrelid`, so the incoming-FK half of `relationships` is a single filtered seq scan of `pg_constraint` — still one cheap pass instead of materializing every FK row twice. **Fix 2 — remove the O(catalog) scans inside `pg_get_tabledef`**: the information_schema uppercase checks are replaced with direct regex tests on the name in hand (preserving the original's `quote_ident` behavior for schemas that need quoting), and the partial-index lookup is scoped by the already-resolved table OID. Original statements are kept as comments, matching the vendored file's convention. **Regression guard** — so this bug class stays out: - `test/db/stress-catalog.ts` builds a synthetic catalog (default 2,000 tables with PKs, unique + check constraints, FK chains and an FK hub; `PG_META_STRESS_TABLES` scales it to incident size). - `test/db/plan-guard.ts` provides `EXPLAIN (ANALYZE, FORMAT JSON)`-based budget assertions: a query's plan may only seq-scan a scaling catalog if its budget entry carries a written structural justification (e.g. no index on `pg_constraint.confrelid`; no index on `pg_class.relnamespace` for per-schema listings), plus a per-query time bound (the only guard available for opaque plpgsql internals like `pg_get_tabledef`). - `test/sql/studio/catalog-plan-guard.test.ts` applies budgets to the hot-path studio queries: table editor, constraints, FK listing, entity types, tables-paginated, columns, indexes, table/entity definitions, views. Reverting either fix makes the suite fail immediately with the offending scans listed. - `test/sql/studio/table-editor.test.ts` (new — none existed) asserts the Table Editor query's semantics: primary keys, unique indexes, both FK directions, `is_unique`, check definitions, column comments. - A new package `README.md` documents the plan-guard budget entry as a requirement for any new introspection query. ### Validation (synthetic 12,000-table catalog, PostgreSQL 17.6) - **Output equivalence, fix 1:** for 12 relation types (regular, composite PK, partitioned parent + partition, view, materialized view, constraint-free table, FK hub/chain/tail, and a fixture with enums/domains/generated/identity columns and duplicate check constraints), the `entity` jsonb from the old and new query is byte-identical. - **Output equivalence, fix 2:** byte-identical DDL across 13 fixture combinations (serial/identity/generated/array columns, case-sensitive and keyword names, mixed-case schemas, partitions, unlogged + reloptions, partial/expression indexes, external PK/FK/comments/trigger variants). - **Performance, fix 1:** Table Editor query `EXPLAIN ANALYZE` ~1,630ms → ~30ms (~50×); the gap grows with catalog size since the old query is O(catalog) per call. - **Performance, fix 2:** single entity definition 3,672ms → 63ms; a 100-entity definitions page ~6min → 0.87s. The plan-guard bound for `getEntityDefinitionsSql` tightens accordingly from 15s/25 entities to 3s/100 entities (330ms measured at default test scale). Verified locally: `catalog-plan-guard` (12 tests), `table-editor`, `tables-paginated` (16 tests) pass; `typecheck` clean. ### Rollout Per review, the new behavior ships **dark** behind the `pgMetaScopedIntrospection` ConfigCat flag (default off = legacy SQL, kept as full duplicated templates in pg-meta and verified byte-identical to the pre-PR queries). Studio reads the flag in the query hooks and threads it through (flag state is part of the React Query keys). The rollout is staged in the ConfigCat dashboard via user-email targeting (like every other ConfigCat flag): target the reporting user's email first, then a percentage rollout, then 100%. Server-side AI callers of `getEntityDefinitionsSql` stay on the legacy path. Once fully rolled out, delete the legacy templates + flag in a cleanup PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Closes: PGMETA-122 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved table editor SQL to correctly scope primary keys, indexes, uniques, checks, and relationships to the selected table. - Optimized table definition SQL to reduce unnecessary catalog scanning for uppercase-name detection and partial-index detection. - **Tests** - Added SQL generator tests for table editor metadata (keys, indexes, relationships, comments, and constraints). - Added catalog query plan guard coverage with a stress catalog and EXPLAIN-based scoping/performance budgets. - **Documentation** - Expanded documentation on catalog query plan safeguards and how to keep new introspection queries properly scoped. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
import { ident, joinSqlFragments, ROLE_IMPERSONATION_NO_RESULTS, safeSql } from '@supabase/pg-meta'
|
|
import { Query, type QueryFilter } from '@supabase/pg-meta/src/query'
|
|
import { getTableRowsSql } from '@supabase/pg-meta/src/query/table-row-query'
|
|
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
|
import { IS_PLATFORM, useFlag } from 'common'
|
|
|
|
import { tableRowKeys } from './keys'
|
|
import { formatFilterValue } from './utils'
|
|
import { parseSupaTable } from '@/components/grid/SupabaseGrid.utils'
|
|
import { Filter, Sort, SupaRow, SupaTable } from '@/components/grid/types'
|
|
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
|
import { handleError } from '@/data/fetchers'
|
|
import { useConnectionStringForReadOps } from '@/data/read-replicas/replicas-query'
|
|
import { executeSql } from '@/data/sql/execute-sql-mutation'
|
|
import {
|
|
PG_META_SCOPED_INTROSPECTION_FLAG,
|
|
prefetchTableEditor,
|
|
} from '@/data/table-editor/table-editor-query'
|
|
import { isMsSqlForeignTable } from '@/data/table-editor/table-editor-types'
|
|
import { timeout } from '@/lib/helpers'
|
|
import { RoleImpersonationState, wrapWithRoleImpersonation } from '@/lib/role-impersonation'
|
|
import { isRoleImpersonationEnabled } from '@/state/role-impersonation-state'
|
|
import { ResponseError, UseCustomQueryOptions } from '@/types'
|
|
|
|
interface GetTableRowsArgs {
|
|
table?: SupaTable
|
|
filters?: Filter[]
|
|
sorts?: Sort[]
|
|
limit?: number
|
|
page?: number
|
|
roleImpersonationState?: RoleImpersonationState
|
|
scoped?: boolean
|
|
}
|
|
|
|
/**
|
|
* Get the preferred columns for sorting of a table.
|
|
*
|
|
* Use the primary key if it exists, otherwise use a unique index with
|
|
* non-nullable columns. If all else fails, fall back to any sortable column.
|
|
*/
|
|
const getPreferredOrderByColumns = (
|
|
table: SupaTable
|
|
): { cursorPaginationEligible: string[][]; cursorPaginationNonEligible: string[] } => {
|
|
const cursorPaginationEligible: string[][] = []
|
|
const cursorPaginationNonEligible: string[] = []
|
|
|
|
const primaryKeyColumns = table.primaryKey
|
|
if (primaryKeyColumns) {
|
|
cursorPaginationEligible.push(primaryKeyColumns)
|
|
}
|
|
|
|
const uniqueIndexes = table.uniqueIndexes
|
|
const cursorFriendlyUniqueIndexes = uniqueIndexes?.filter((index) => {
|
|
return index.every((columnName) => {
|
|
const column = table.columns.find((column) => column.name === columnName)
|
|
return !!column && !column.isNullable
|
|
})
|
|
})
|
|
if (cursorFriendlyUniqueIndexes) {
|
|
cursorPaginationEligible.push(...cursorFriendlyUniqueIndexes)
|
|
}
|
|
|
|
const eligibleColumnsForSorting = table.columns.filter((x) => !x.dataType.includes('json'))
|
|
cursorPaginationNonEligible.push(...eligibleColumnsForSorting.map((col) => col.name))
|
|
|
|
return {
|
|
cursorPaginationEligible,
|
|
cursorPaginationNonEligible,
|
|
}
|
|
}
|
|
|
|
function getErrorCode(error: any): number | undefined {
|
|
// Our custom ResponseError's use 'code' instead of 'status'
|
|
if (error instanceof ResponseError) {
|
|
return error.code
|
|
}
|
|
return error.status
|
|
}
|
|
|
|
function getRetryAfter(error: any): number | undefined {
|
|
if (error instanceof ResponseError) {
|
|
return error.retryAfter
|
|
}
|
|
|
|
const headerRetry = error.headers?.get('retry-after')
|
|
if (headerRetry) {
|
|
return parseInt(headerRetry)
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
export async function executeWithRetry<T>(
|
|
fn: () => Promise<T>,
|
|
maxRetries: number = 3,
|
|
baseDelay: number = 1000
|
|
): Promise<T> {
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
return await fn()
|
|
} catch (error: unknown) {
|
|
const errorCode = getErrorCode(error)
|
|
if (errorCode === 429 && attempt < maxRetries) {
|
|
// Get retry delay from headers or use exponential backoff (1s, then 2s, then 4s)
|
|
const retryAfter = getRetryAfter(error)
|
|
const delayMs = retryAfter ? retryAfter * 1000 : baseDelay * Math.pow(2, attempt)
|
|
|
|
await timeout(delayMs)
|
|
continue
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
throw new Error('Max retries reached without success')
|
|
}
|
|
|
|
const checkIfCtidAvailable = (table: SupaTable): boolean =>
|
|
table.type === ENTITY_TYPE.TABLE ||
|
|
table.type === ENTITY_TYPE.PARTITIONED_TABLE ||
|
|
table.type === ENTITY_TYPE.MATERIALIZED_VIEW
|
|
|
|
export const getAllTableRowsSql = ({
|
|
table,
|
|
filters = [],
|
|
sorts = [],
|
|
}: {
|
|
table: SupaTable
|
|
filters?: Filter[]
|
|
sorts?: Sort[]
|
|
}): { sql: QueryFilter; cursorColumns: string[] | false } => {
|
|
const query = new Query()
|
|
|
|
const arrayBasedColumns = table.columns
|
|
.filter(
|
|
(column) => (column?.enum ?? []).length > 0 && column.dataType.toLowerCase() === 'array'
|
|
)
|
|
.map((column) => safeSql`${ident(column.name)}::text[]`)
|
|
|
|
let queryChains = query
|
|
.from(table.name, table.schema ?? undefined)
|
|
.select(
|
|
arrayBasedColumns.length > 0
|
|
? joinSqlFragments([safeSql`*`, ...arrayBasedColumns], ',')
|
|
: safeSql`*`
|
|
)
|
|
|
|
filters
|
|
.filter((filter) => filter.value && filter.value !== '')
|
|
.forEach((filter) => {
|
|
const value = formatFilterValue(table, filter)
|
|
queryChains = queryChains.filter(filter.column, filter.operator, value)
|
|
})
|
|
|
|
let cursorColumns: string[] | false = false
|
|
const { cursorPaginationEligible, cursorPaginationNonEligible } =
|
|
getPreferredOrderByColumns(table)
|
|
|
|
const hasCtid = checkIfCtidAvailable(table)
|
|
|
|
if (sorts.length === 0) {
|
|
if (cursorPaginationEligible.length > 0) {
|
|
cursorColumns = cursorPaginationEligible[0]
|
|
cursorPaginationEligible[0].forEach((col) => {
|
|
queryChains = queryChains.order(table.name, col)
|
|
})
|
|
// Cursor paginated columns do not require ctid fallback as they
|
|
// guarantee uniqueness
|
|
} else if (cursorPaginationNonEligible.length > 0) {
|
|
queryChains = queryChains.order(table.name, cursorPaginationNonEligible[0])
|
|
if (hasCtid) {
|
|
queryChains = queryChains.order(table.name, 'ctid')
|
|
}
|
|
} else {
|
|
if (hasCtid) {
|
|
queryChains = queryChains.order(table.name, 'ctid')
|
|
}
|
|
}
|
|
} else {
|
|
sorts.forEach((sort) => {
|
|
queryChains = queryChains.order(sort.table, sort.column, sort.ascending, sort.nullsFirst)
|
|
})
|
|
|
|
// Add tie-breakers so page order doesn't shuffle
|
|
const tieBreaker = cursorPaginationEligible[0]
|
|
if (tieBreaker) {
|
|
const sortedColumns = new Set(
|
|
sorts.filter((s) => s.table === table.name).map((s) => s.column)
|
|
)
|
|
tieBreaker
|
|
.filter((col) => !sortedColumns.has(col))
|
|
.forEach((col) => {
|
|
queryChains = queryChains.order(table.name, col)
|
|
})
|
|
} else {
|
|
if (hasCtid) {
|
|
queryChains = queryChains.order(table.name, 'ctid')
|
|
}
|
|
}
|
|
}
|
|
|
|
return { sql: queryChains, cursorColumns }
|
|
}
|
|
|
|
// TODO: fetchAllTableRows is used for CSV export, but since it doesn't actually truncate anything, (compare to getTableRows)
|
|
// this is not suitable and will cause crashes on the pg-meta side given big tables
|
|
// (either when the number of rows exceeds Blob size or if the columns in the rows are too large).
|
|
// We should handle those errors gracefully, maybe adding a hint to the user about how to extract
|
|
// the CSV to their machine via a direct command line connection (e.g., pg_dump), which will be much more
|
|
// reliable for large data extraction.
|
|
export const fetchAllTableRows = async ({
|
|
projectRef,
|
|
connectionString,
|
|
table,
|
|
filters = [],
|
|
sorts = [],
|
|
roleImpersonationState,
|
|
progressCallback,
|
|
}: {
|
|
projectRef: string
|
|
connectionString?: string | null
|
|
table: SupaTable
|
|
filters?: Filter[]
|
|
sorts?: Sort[]
|
|
roleImpersonationState?: RoleImpersonationState
|
|
progressCallback?: (value: number) => void
|
|
}) => {
|
|
if (IS_PLATFORM && !connectionString) {
|
|
console.error('Connection string is required')
|
|
return []
|
|
}
|
|
|
|
const rows: any[] = []
|
|
const { sql: queryChains, cursorColumns } = getAllTableRowsSql({
|
|
table,
|
|
sorts,
|
|
filters,
|
|
})
|
|
|
|
const rowsPerPage = 500
|
|
const THROTTLE_DELAY = 500
|
|
|
|
if (cursorColumns) {
|
|
let cursor: Record<string, any> | null = null
|
|
while (true) {
|
|
let queryChainsWithCursor = queryChains.clone()
|
|
|
|
if (cursor) {
|
|
queryChainsWithCursor = queryChainsWithCursor.filter(
|
|
cursorColumns,
|
|
'>',
|
|
cursorColumns.map((col) => cursor![col])
|
|
)
|
|
}
|
|
const query = wrapWithRoleImpersonation(
|
|
queryChainsWithCursor.range(0, rowsPerPage - 1).toSql(),
|
|
roleImpersonationState
|
|
)
|
|
|
|
try {
|
|
const { result } = await executeWithRetry(async () =>
|
|
executeSql({ projectRef, connectionString, sql: query })
|
|
)
|
|
rows.push(...result)
|
|
progressCallback?.(rows.length)
|
|
|
|
cursor = {}
|
|
for (const col of cursorColumns) {
|
|
cursor[col] = result[result.length - 1]?.[col]
|
|
}
|
|
|
|
if (result.length < rowsPerPage) break
|
|
|
|
await timeout(THROTTLE_DELAY)
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Error fetching all table rows: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
let page = -1
|
|
while (true) {
|
|
page += 1
|
|
const from = page * rowsPerPage
|
|
const to = (page + 1) * rowsPerPage - 1
|
|
const query = wrapWithRoleImpersonation(
|
|
queryChains.range(from, to).toSql(),
|
|
roleImpersonationState
|
|
)
|
|
|
|
try {
|
|
const { result } = await executeWithRetry(async () =>
|
|
executeSql({ projectRef, connectionString, sql: query })
|
|
)
|
|
rows.push(...result)
|
|
progressCallback?.(rows.length)
|
|
|
|
if (result.length < rowsPerPage) break
|
|
|
|
await timeout(THROTTLE_DELAY)
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Error fetching all table rows: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return rows.filter((row) => row[ROLE_IMPERSONATION_NO_RESULTS] !== 1)
|
|
}
|
|
|
|
type TableRows = { rows: SupaRow[] }
|
|
|
|
type TableRowsVariables = Omit<GetTableRowsArgs, 'table'> & {
|
|
queryClient: QueryClient
|
|
projectRef?: string
|
|
connectionString?: string | null
|
|
tableId?: number
|
|
preflightCheck?: boolean
|
|
}
|
|
|
|
export type TableRowsData = TableRows
|
|
type TableRowsError = ResponseError
|
|
|
|
async function getTableRows(
|
|
{
|
|
queryClient,
|
|
projectRef,
|
|
connectionString,
|
|
tableId,
|
|
roleImpersonationState,
|
|
filters,
|
|
sorts,
|
|
limit,
|
|
page,
|
|
preflightCheck = false,
|
|
scoped,
|
|
}: TableRowsVariables,
|
|
signal?: AbortSignal
|
|
) {
|
|
const entity = await prefetchTableEditor(queryClient, {
|
|
projectRef,
|
|
connectionString,
|
|
id: tableId,
|
|
scoped,
|
|
})
|
|
if (!entity) {
|
|
throw new Error('Table not found')
|
|
}
|
|
|
|
const table = parseSupaTable(entity)
|
|
|
|
const equalityFilterColumns = filters
|
|
?.filter((filter) => filter.operator === '=' || filter.operator === 'is')
|
|
.flatMap((filter) => filter.column)
|
|
|
|
// There is an edge case for MS SQL foreign tables, where the Postgres query
|
|
// planner may drop sorts that are redundant with filters, resulting in
|
|
// invalid MS SQL syntax. To prevent this, we exclude potentially conflicting
|
|
// columns from potential default sort columns.
|
|
const excludedColumns = isMsSqlForeignTable(entity)
|
|
? Array.from(new Set(equalityFilterColumns))
|
|
: undefined
|
|
|
|
const sql = wrapWithRoleImpersonation(
|
|
getTableRowsSql({
|
|
table: entity,
|
|
filters,
|
|
sorts,
|
|
limit,
|
|
page,
|
|
sortExcludedColumns: excludedColumns,
|
|
}),
|
|
roleImpersonationState
|
|
)
|
|
|
|
try {
|
|
const { result } = await executeSql(
|
|
{
|
|
projectRef,
|
|
connectionString,
|
|
sql,
|
|
queryKey: ['table-rows', table?.id],
|
|
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
|
|
preflightCheck,
|
|
},
|
|
signal
|
|
)
|
|
|
|
const rows = result.map((x: any, index: number) => {
|
|
return { idx: index, ...x }
|
|
}) as SupaRow[]
|
|
|
|
return { rows }
|
|
} catch (error) {
|
|
throw handleError(error)
|
|
}
|
|
}
|
|
|
|
export const useTableRowsQuery = <TData = TableRowsData>(
|
|
{ projectRef, tableId, ...args }: Omit<TableRowsVariables, 'queryClient' | 'connectionString'>,
|
|
{ enabled = true, ...options }: UseCustomQueryOptions<TableRowsData, TableRowsError, TData> = {}
|
|
) => {
|
|
const queryClient = useQueryClient()
|
|
const { connectionString, identifier: readReplicaIdentifier } = useConnectionStringForReadOps()
|
|
const scoped = !!useFlag(PG_META_SCOPED_INTROSPECTION_FLAG)
|
|
|
|
// [Ali] Exclude preflightCheck from query key — it controls how the query
|
|
// executes (whether an EXPLAIN guard runs first), not what data is returned.
|
|
const { preflightCheck, ...queryKeyArgs } = args
|
|
|
|
return useQuery<TableRowsData, TableRowsError, TData>({
|
|
queryKey: tableRowKeys.tableRows(projectRef, {
|
|
table: { id: tableId },
|
|
readReplicaIdentifier,
|
|
...queryKeyArgs,
|
|
scoped,
|
|
}),
|
|
queryFn: ({ signal }) =>
|
|
getTableRows({ queryClient, projectRef, connectionString, tableId, ...args, scoped }, signal),
|
|
enabled:
|
|
enabled &&
|
|
typeof projectRef !== 'undefined' &&
|
|
typeof tableId !== 'undefined' &&
|
|
(!IS_PLATFORM || typeof connectionString !== 'undefined'),
|
|
...options,
|
|
})
|
|
}
|
|
|
|
type PrefetchTableRowsVariables = Omit<TableRowsVariables, 'queryClient'> & {
|
|
readReplicaIdentifier?: string
|
|
}
|
|
|
|
export function prefetchTableRows(
|
|
client: QueryClient,
|
|
{
|
|
projectRef,
|
|
connectionString,
|
|
tableId,
|
|
readReplicaIdentifier,
|
|
...args
|
|
}: PrefetchTableRowsVariables
|
|
) {
|
|
return client.fetchQuery({
|
|
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- readReplicaIdentifier is used as a stable version of connectionString
|
|
queryKey: tableRowKeys.tableRows(projectRef, {
|
|
table: { id: tableId },
|
|
readReplicaIdentifier,
|
|
...args,
|
|
}),
|
|
queryFn: ({ signal }) =>
|
|
getTableRows({ queryClient: client, projectRef, connectionString, tableId, ...args }, signal),
|
|
})
|
|
}
|