From a458977e2da817cf7c0d6fbfbeda36a8a424624f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 28 Feb 2025 11:14:42 +0800 Subject: [PATCH] Support for Dedicated Pooler in Connection Pooling (#33817) * Init * Initial set up for hooking up supavisor and pgbouncer * Hook up pgbouncer status check after swapping pooler type * Add check for nano compute for switching to pg bouncer * Add check for ipv4 addon * Remove expect error tag * Add badge to select options for pooler types * Remove statement mode * Resolve undefined problem with react hook form * Fix * Update UI texts from PgBouncer to Dedicated Pooler * Feex * FEEX * Fix * Small update to UI * Smol update --- .../fields/ComputeSizeField.tsx | 5 +- .../ui/DiskManagement.constants.tsx | 5 - .../ConnectionPooling.constants.ts | 2 + .../ConnectionPooling/ConnectionPooling.tsx | 954 ++++++++++++------ .../components/ui/Forms/Form.constants.ts | 21 +- apps/studio/components/ui/Panel.tsx | 15 +- .../data/database/pgbouncer-config-query.ts | 20 +- .../pgbouncer-config-update-mutation.ts | 73 ++ .../data/database/pgbouncer-status-query.ts | 20 +- .../database/pooling-configuration-query.ts | 22 + .../pooling-configuration-update-mutation.ts | 37 +- .../ui/src/components/radio-group-card.tsx | 2 +- 12 files changed, 823 insertions(+), 353 deletions(-) create mode 100644 apps/studio/data/database/pgbouncer-config-update-mutation.ts diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index 1d93f5fb67..1d211ddc1e 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -5,10 +5,12 @@ import { UseFormReturn } from 'react-hook-form' import { components } from 'api-types' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { DocsButton } from 'components/ui/DocsButton' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' +import { InstanceSpecs } from 'lib/constants' import { cn, FormField_Shadcn_, RadioGroupCard, RadioGroupCardItem, Skeleton } from 'ui' import { ComputeBadge } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -22,8 +24,6 @@ import { import { BillingChangeBadge } from '../ui/BillingChangeBadge' import FormMessage from '../ui/FormMessage' import { NoticeBar } from '../ui/NoticeBar' -import { InstanceSpecs } from 'lib/constants' -import { DocsButton } from 'components/ui/DocsButton' /** * to do: this could be a type from api-types @@ -196,7 +196,6 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { lockedOption && 'opacity-50' )} disabled={disabled || lockedOption} - // @ts-expect-error label={ <> {showUpgradeBadge && compute.identifier === 'ci_micro' && ( diff --git a/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx b/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx index 1b7d7f091c..734dc4f28c 100644 --- a/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx +++ b/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx @@ -57,11 +57,6 @@ export const DISK_LIMITS = { }, } -export const DISK_TYPE_LABELS = { - [DiskType.GP3]: 'General Purpose SSD (gp3)', - [DiskType.IO2]: 'Provisioned IOPS SSD (io2)', -} - interface PlanDetails { includedDiskGB: { gp3: number; io2: number } } diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.constants.ts b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.constants.ts index 6b6e15d41b..c9407d2e88 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.constants.ts +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.constants.ts @@ -1,5 +1,7 @@ // https://supabase.com/docs/guides/platform/performance#optimizing-the-number-of-connections // https://github.com/supabase/infrastructure/blob/develop/worker/src/lib/constants.ts#L544-L596 +// https://github.com/supabase/supabase-admin-api/blob/master/optimizations/pgbouncer.go +// [Joshen] This matches for both Supavisor and PgBouncer export const POOLING_OPTIMIZATIONS = { ci_nano: { diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index 122ff895fb..2645568895 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -1,25 +1,30 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' import { capitalize } from 'lodash' -import { Fragment, useEffect } from 'react' +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' import z from 'zod' -import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' -import { StringToPositiveNumber } from 'components/ui/Forms/Form.constants' +import { setValueAsNullableNumber } from 'components/ui/Forms/Form.constants' import { FormActions } from 'components/ui/Forms/FormActions' +import { InlineLink } from 'components/ui/InlineLink' import Panel from 'components/ui/Panel' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useMaxConnectionsQuery } from 'data/database/max-connections-query' -import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' -import { usePoolingConfigurationUpdateMutation } from 'data/database/pooling-configuration-update-mutation' +import { usePgbouncerConfigQuery } from 'data/database/pgbouncer-config-query' +import { usePgbouncerConfigurationUpdateMutation } from 'data/database/pgbouncer-config-update-mutation' +import { usePgbouncerStatusQuery } from 'data/database/pgbouncer-status-query' +import { useSupavisorConfigurationQuery } from 'data/database/pooling-configuration-query' +import { useSupavisorConfigurationUpdateMutation } from 'data/database/pooling-configuration-update-mutation' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useFlag } from 'hooks/ui/useFlag' +import { toast } from 'sonner' import { useDatabaseSettingsStateSnapshot } from 'state/database-settings' import { AlertDescription_Shadcn_, @@ -27,35 +32,178 @@ import { Alert_Shadcn_, Badge, FormControl_Shadcn_, - FormDescription_Shadcn_, FormField_Shadcn_, - FormItem_Shadcn_, - FormLabel_Shadcn_, - FormMessage_Shadcn_, Form_Shadcn_, Input_Shadcn_, Listbox, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, Separator, + Tooltip, + TooltipContent, + TooltipTrigger, + cn, } from 'ui' import { Admonition } from 'ui-patterns' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { SESSION_MODE_DESCRIPTION, TRANSACTION_MODE_DESCRIPTION } from '../Database.constants' import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants' -const formId = 'connection-pooling-form' +const formId = 'pooling-configuration-form' -const FormSchema = z.object({ - default_pool_size: StringToPositiveNumber, - pool_mode: z.union([z.literal('transaction'), z.literal('session')]), - max_client_conn: StringToPositiveNumber, +const PoolingConfigurationFormSchema = z.object({ + type: z.union([z.literal('Supavisor'), z.literal('PgBouncer')]), + default_pool_size: z.number().nullable(), + pool_mode: z.union([z.literal('transaction'), z.literal('session'), z.literal('statement')]), + max_client_conn: z.number().nullable(), }) +/** + * [Joshen] Some outstanding questions that need clarification for support both type of poolers + * I've left comments in the code itself below, but just leaving a summary here for easier reference + * - How to check for when Supavisor is ready to receive connections? We have pgbouncer/status for PgBouncer + * - Are we currently ensuring the 2 hour window on the BE? I noticed pgbouncer/status flips active to false in a second after setting pgbouncer_enabled to false + * - Existing projects currently have pgbouncer_enabled and supavisor_enabled as true, are we going to backfill? + * - We're using pgbouncer_enabled to determine the pooler type, which means that all projects are going to show on the UI that pgbouncer is being used + * + * Apart from the above, some pointers to note: + * - max_client_conn should be editable for pgbouncer + * - (Nice to have) Show a countdown of 2 hours when the pooler is swapped as a UI indication for users + * - [TODO] Connect UI needs to be updated to show the correct pooler connection string depending on which type is being used + * - [TODO] Project addons IPv4 needs an update on the CTA "You do not need...", needs to now be dependent on the Pooler type + * + * Added a feature flag just in case + * - Toggles visibility of Pooler Type input field + * - Whether to use pgbouncer_enabled to determine pooler type + */ + export const ConnectionPooling = () => { const { ref: projectRef } = useParams() const { project } = useProjectContext() + const org = useSelectedOrganization() const snap = useDatabaseSettingsStateSnapshot() + const allowPgBouncerSelection = useFlag('dualPoolerSupport') + const toastIdRef = useRef() + const [showConfirmation, setShowConfirmation] = useState(false) + const [refetchPgBouncerStatus, setRefetchPgBouncerStatus] = useState(false) + + const canUpdateConnectionPoolingConfiguration = useCheckPermissions( + PermissionAction.UPDATE, + 'projects', + { resource: { project_id: project?.id } } + ) + + const { + data: supavisorPoolingInfo, + error: supavisorConfigError, + isLoading: isLoadingSupavisorConfig, + isError: isErrorSupavisorConfig, + isSuccess: isSuccessSupavisorConfig, + } = useSupavisorConfigurationQuery({ projectRef }) + + const { + data: pgbouncerConfig, + error: pgbouncerConfigError, + isLoading: isLoadingPgbouncerConfig, + isError: isErrorPgbouncerConfig, + isSuccess: isSuccessPgbouncerConfig, + } = usePgbouncerConfigQuery({ + projectRef, + }) + + const { data: maxConnData } = useMaxConnectionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) const { data: addons } = useProjectAddonsQuery({ projectRef }) + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug }) + + usePgbouncerStatusQuery( + { projectRef }, + { + refetchInterval: (data) => { + // [Joshen] Need to clarify the following: + // - How to check for when Supavisor is ready to receive connections when swapping over to Supavisor + // - I notice status goes to false when i swap over to Supavisor in 2 seconds, does this mean that PgBouncer is already offline? + // - Cause we need to consider the 2 hour window that we're providing for users to swap over the pooler connection strings + if (refetchPgBouncerStatus) { + if ( + (!!pgbouncerConfig?.pgbouncer_enabled && !data?.active) || + (!pgbouncerConfig?.pgbouncer_enabled && !!data?.active) + ) { + return 2000 + } else { + toast.success( + `${data?.active ? 'Dedicated Pooler' : 'Supavisor'} is now ready to receive connections!`, + { id: toastIdRef.current } + ) + toastIdRef.current = undefined + setRefetchPgBouncerStatus(false) + return false + } + } else { + return false + } + }, + } + ) + + const { mutate: updateSupavisorConfig, isLoading: isUpdatingSupavisor } = + useSupavisorConfigurationUpdateMutation() + const { + mutate: updatePgbouncerConfig, + mutateAsync: updatePgBouncerConfigAsync, + isLoading: isUpdatingPgBouncer, + } = usePgbouncerConfigurationUpdateMutation() + + const form = useForm>({ + resolver: zodResolver(PoolingConfigurationFormSchema), + defaultValues: { + type: undefined, + pool_mode: undefined, + default_pool_size: undefined, + max_client_conn: null, + }, + }) + const { type, default_pool_size, max_client_conn } = form.watch() + const error = useMemo( + () => (type === 'PgBouncer' ? pgbouncerConfigError : supavisorConfigError), + [type] + ) + const isLoading = useMemo( + () => (type === 'PgBouncer' ? isLoadingPgbouncerConfig : isLoadingSupavisorConfig), + [type] + ) + const isError = useMemo( + () => (type === 'PgBouncer' ? isErrorPgbouncerConfig : isErrorSupavisorConfig), + [type] + ) + const isSuccess = useMemo( + () => (type === 'PgBouncer' ? isSuccessPgbouncerConfig : isSuccessSupavisorConfig), + [type] + ) + const isSaving = isUpdatingSupavisor || isUpdatingPgBouncer + + const currentPooler = allowPgBouncerSelection + ? pgbouncerConfig?.pgbouncer_enabled + ? 'PgBouncer' + : 'Supavisor' + : 'Supavisor' + // [Joshen] These are labels just for user-facing texts + const formattedCurrentPooler = + currentPooler === 'PgBouncer' ? 'the Dedicated Pooler' : currentPooler + const formattedTargetPooler = type === 'PgBouncer' ? 'the Dedicated Pooler' : type + + const hasIpv4Addon = !!addons?.selected_addons.find((addon) => addon.type === 'ipv4') const computeInstance = addons?.selected_addons.find((addon) => addon.type === 'compute_instance') + const computeSize = + computeInstance?.variant.name ?? capitalize(project?.infra_compute_size) ?? 'Nano' const poolingOptimizations = POOLING_OPTIMIZATIONS[ (computeInstance?.variant.identifier as keyof typeof POOLING_OPTIMIZATIONS) ?? @@ -63,80 +211,115 @@ export const ConnectionPooling = () => { ] const defaultPoolSize = poolingOptimizations.poolSize ?? 15 const defaultMaxClientConn = poolingOptimizations.maxClientConn ?? 200 - const computeSize = - computeInstance?.variant.name ?? capitalize(project?.infra_compute_size) ?? 'Micro' - const { - data: poolingInfo, - error, - isLoading, - isError, - isSuccess, - } = usePoolingConfigurationQuery({ projectRef }) + const isFreePlan = subscription?.plan.id === 'free' + const supavisorConfig = supavisorPoolingInfo?.find((x) => x.database_type === 'PRIMARY') + const connectionPoolingUnavailable = + type === 'PgBouncer' ? pgbouncerConfig?.pool_mode === null : supavisorConfig?.pool_mode === null + const disablePoolModeSelection = + type === 'Supavisor' && supavisorConfig?.pool_mode === 'transaction' + const disablePgBouncerSelection = computeSize === 'Nano' + const showPoolModeWarning = type === 'Supavisor' && supavisorConfig?.pool_mode === 'session' + const isChangingPoolerType = + (currentPooler === 'PgBouncer' && type === 'Supavisor') || + (currentPooler === 'Supavisor' && type === 'PgBouncer') - const { data: authConfig } = useAuthConfigQuery({ projectRef }) + const poolerSwitchWarningTitle = + 'Your current pooler will be active for 2 hours before fully deactivated' + const poolerSwitchWarningDescription = `Migrate your applications from ${formattedCurrentPooler} to ${formattedTargetPooler} during this time by switching to ${formattedTargetPooler} connection strings in your applications.` - const { data: maxConnData } = useMaxConnectionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) + const onSubmit: SubmitHandler> = async (data) => { + const { type, pool_mode, default_pool_size, max_client_conn } = data - const poolingConfiguration = poolingInfo?.find((x) => x.database_type === 'PRIMARY') - const connectionPoolingUnavailable = poolingConfiguration?.pool_mode === null - - const canUpdateConnectionPoolingConfiguration = useCheckPermissions( - PermissionAction.UPDATE, - 'projects', - { - resource: { - project_id: project?.id, - }, - } - ) - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - pool_mode: poolingConfiguration?.pool_mode as 'transaction' | 'session', - default_pool_size: poolingConfiguration?.default_pool_size as number | undefined, - max_client_conn: poolingConfiguration?.max_client_conn as number | undefined, - }, - }) - - const { mutate: updateConfiguration, isLoading: isUpdating } = - usePoolingConfigurationUpdateMutation({ - onSuccess: (data) => { - if (data) { - form.reset({ - pool_mode: data.pool_mode, - default_pool_size: data.default_pool_size, - max_client_conn: poolingConfiguration?.max_client_conn, - }) - } - toast.success('Successfully saved settings') - }, - }) - - const onSubmit: SubmitHandler> = async (data) => { if (!projectRef) return console.error('Project ref is required') - if (!poolingInfo) return console.error('Pooling info required') + if (isChangingPoolerType && !showConfirmation) return setShowConfirmation(true) - updateConfiguration({ - ref: projectRef, - default_pool_size: data.default_pool_size as number | undefined, - pool_mode: data.pool_mode, - }) + if (type === 'PgBouncer') { + if (!pgbouncerConfig) return console.error('Pgbouncer configuration is required') + updatePgbouncerConfig( + { + ref: projectRef, + pgbouncer_enabled: true, + ignore_startup_parameters: pgbouncerConfig.ignore_startup_parameters ?? '', + pool_mode: pool_mode as 'transaction' | 'session' | 'statement', + max_client_conn, + default_pool_size: default_pool_size as number | undefined, + }, + { + onSuccess: (data) => { + if (isChangingPoolerType) { + const toastId = toast.loading('Swapping pooler to the Dedicated Pooler') + toastIdRef.current = toastId + setRefetchPgBouncerStatus(true) + } else { + toast.success(`Successfully updated Dedicated Pooler configuration`) + } + + setShowConfirmation(false) + form.reset({ type: 'PgBouncer', ...data }) + }, + } + ) + } else if (type === 'Supavisor') { + if (isChangingPoolerType && pgbouncerConfig) { + await updatePgBouncerConfigAsync({ + ref: projectRef, + pgbouncer_enabled: false, + ignore_startup_parameters: pgbouncerConfig.ignore_startup_parameters ?? '', + pool_mode: pgbouncerConfig.pool_mode as 'transaction' | 'session' | 'statement', + }) + } + updateSupavisorConfig( + { + ref: projectRef, + default_pool_size, + pool_mode: pool_mode as 'transaction' | 'session', + }, + { + onSuccess: (data) => { + if (isChangingPoolerType) { + const toastId = toast.loading('Swapping pooler to Supavisor') + toastIdRef.current = toastId + setRefetchPgBouncerStatus(true) + } else { + toast.success(`Successfully updated Supavisor configuration`) + } + setShowConfirmation(false) + form.reset({ type: 'Supavisor', ...data }) + }, + } + ) + } + } + + const resetForm = () => { + if (currentPooler === 'PgBouncer') { + if (pgbouncerConfig) { + form.reset({ + type: 'PgBouncer', + pool_mode: pgbouncerConfig.pool_mode, + default_pool_size: pgbouncerConfig.default_pool_size, + max_client_conn: pgbouncerConfig.max_client_conn, + }) + } + } else { + if (supavisorConfig) { + form.reset({ + type: 'Supavisor', + pool_mode: supavisorConfig.pool_mode, + default_pool_size: supavisorConfig.default_pool_size, + max_client_conn: supavisorConfig.max_client_conn, + }) + } + } } useEffect(() => { - if (isSuccess) { - form.reset({ - pool_mode: poolingConfiguration?.pool_mode as 'transaction' | 'session', - default_pool_size: poolingConfiguration?.default_pool_size as number | undefined, - max_client_conn: poolingConfiguration?.max_client_conn as number | undefined, - }) + // [Joshen] We're using pgbouncer_enabled from pgbouncer's config to determine the current type + if (isSuccessPgbouncerConfig && isSuccessSupavisorConfig) { + resetForm() } - }, [isSuccess, authConfig]) + }, [isSuccessPgbouncerConfig, isSuccessSupavisorConfig]) return (
@@ -145,12 +328,8 @@ export const ConnectionPooling = () => { title={
-

- {connectionPoolingUnavailable - ? 'Connection Pooling is not available for this project' - : 'Connection pooling configuration'} -

- Supavisor +

Connection pooling configuration

+ {!allowPgBouncerSelection && Supavisor}
@@ -158,9 +337,9 @@ export const ConnectionPooling = () => { footer={ form.reset()} + handleReset={() => resetForm()} helper={ !canUpdateConnectionPoolingConfiguration ? 'You need additional permissions to update connection pooling settings' @@ -169,158 +348,310 @@ export const ConnectionPooling = () => { /> } > - {isLoading && ( - - {Array.from({ length: 4 }).map((_, i) => ( - -
- - -
- -
- ))} + + {isLoading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + +
+ + +
+ +
+ ))} - - - )} - - {isError && ( -
- -
- )} - - {isSuccess && ( - <> - {connectionPoolingUnavailable && ( -

Please start a new project to enable this feature.

- )} - -
- ( - - - Pool Mode - - - field.onChange(value)} - > - -

Transaction mode

-

- {TRANSACTION_MODE_DESCRIPTION} -

-
- -

Session mode

-

- {SESSION_MODE_DESCRIPTION} -

-
-
-
- - {poolingConfiguration?.pool_mode === 'transaction' && ( - - - - Pool mode is permanently set to Transaction on port 6543 - - - You can use Session mode by connecting to the pooler on port 5432 - instead - - - - )} - - {poolingConfiguration?.pool_mode === 'session' && ( - <> - {field.value === 'transaction' ? ( - - - - ) : ( - - {/* [Joshen] Can probably remove this after Feb 28 */} - - - - )} - - )} - - -

- Specify when a connection can be returned to the pool.{' '} - snap.setShowPoolingModeHelper(true)} - className="cursor-pointer underline underline-offset-2" - > - Learn more about pool modes - - . -

-
- - -
- )} + +
+ )} + {isError && ( + + )} + {isSuccess && ( + <> + {connectionPoolingUnavailable && ( + - ( - - - Pool Size - - - - - {maxConnData !== undefined && - Number(form.getValues('default_pool_size') ?? 15) > - maxConnData.maxConnections * 0.8 && ( -
- + )} + + + {allowPgBouncerSelection && ( + ( + + {isChangingPoolerType && ( + + )} + {type === 'PgBouncer' && !hasIpv4Addon && ( + + If you were using Supavisor for IPv6, we recommend purchasing + a dedicated IPv4 address from the{' '} + + add-ons page + + {isChangingPoolerType && ' before changing your pooler type'}. + + } + /> + )} + + } + > + { + field.onChange(e) + if (e === 'Supavisor' && supavisorConfig) { + form.setValue('type', 'Supavisor') + form.setValue('pool_mode', supavisorConfig.pool_mode) + form.setValue( + 'default_pool_size', + supavisorConfig.default_pool_size + ) + form.setValue('max_client_conn', supavisorConfig.max_client_conn) + } else if (e === 'PgBouncer' && pgbouncerConfig) { + form.setValue('type', 'PgBouncer') + form.setValue('pool_mode', pgbouncerConfig.pool_mode as any) + form.setValue( + 'default_pool_size', + pgbouncerConfig.default_pool_size as any + ) + form.setValue( + 'max_client_conn', + pgbouncerConfig.max_client_conn || null + ) + } + }} + > + + + + + + + +
+

Supavisor

+ IPv4 +
+
+ + + +
+

Dedicated Pooler

+
+ {hasIpv4Addon && Dedicated IPv4} + IPv6 +
+
+
+
+ {disablePgBouncerSelection && ( + + Dedicated Pooler can only be used while on a Micro Compute and + above.{' '} + {isFreePlan ? ( + <> + + Upgrade your plan + {' '} + to adjust your project's compute size. + + ) : ( + <> + Upgrade your compute size through your{' '} + + project's settings + + . + + )} + + )} +
+
+
+
+ )} + /> + )} + + ( + + {disablePoolModeSelection && ( + + + Pool mode is permanently set to Transaction on port 6543 + + + You can use Session mode by connecting to the pooler on port 5432 + instead + + + )} + {showPoolModeWarning && ( + <> + {field.value === 'transaction' ? ( + + ) : ( + <> + + + + )} + + )} +

+ Specify when a connection can be returned to the pool.{' '} + snap.setShowPoolingModeHelper(true)} + className="transition cursor-pointer underline underline-offset-2 decoration-foreground-lighter hover:decoration-foreground text-foreground" + > + Learn more about pool modes + + . +

+ + } + > + + field.onChange(value)} + > + +

Transaction mode

+

+ {TRANSACTION_MODE_DESCRIPTION} +

+
+ +

Session mode

+

+ {SESSION_MODE_DESCRIPTION} +

+
+
+
+
+ )} + /> + + ( + +

+ The maximum number of connections made to the underlying Postgres + cluster, per user+db combination. Pool size has a default of{' '} + {defaultPoolSize} based on your compute size of {computeSize}. +

+ {type === 'Supavisor' && ( +

+ Please refer to our{' '} + + documentation + {' '} + to find out more. +

+ )} + + } + > + + + + {!!maxConnData && + (default_pool_size ?? 15) > maxConnData.maxConnections * 0.8 && ( + Pool size is greater than 80% of the max connections ( {maxConnData.maxConnections}) on your database @@ -330,72 +661,87 @@ export const ConnectionPooling = () => { connections. -
- )} - - The maximum number of connections made to the underlying Postgres cluster, - per user+db combination. Pool size has a default of {defaultPoolSize} based - on your compute size of {computeSize}. - - - Please refer to our{' '} - - documentation - {' '} - to find out more. - - -
- )} - /> - ( - - - Max Client Connections - - - - - - The maximum number of concurrent client connections allowed. This value is - fixed at {defaultMaxClientConn} based on your compute size of {computeSize}{' '} - and cannot be changed. - - - Please refer to our{' '} - - documentation - {' '} - to find out more. - - - - )} - /> - - - - )} + )} + + )} + /> + + ( + +

+ The maximum number of concurrent client connections allowed.{' '} + {type === 'Supavisor' ? ( + <> + This value is fixed at {defaultMaxClientConn} based on your + compute size of {computeSize} and cannot be changed. + + ) : ( + <> + This has a default of {defaultMaxClientConn} based on your compute + size of {computeSize}. + + )} +

+ {type === 'Supavisor' && ( +

+ Please refer to our{' '} + + documentation + {' '} + to find out more. +

+ )} + + } + > + + + +
+ )} + /> + + + + )} +
+ setShowConfirmation(false)} + onConfirm={() => onSubmit(form.getValues())} + alert={{ + base: { variant: 'warning' }, + title: poolerSwitchWarningTitle, + description: poolerSwitchWarningDescription, + }} + > +

+ Are you sure you wish to switch your pooler type to {formattedTargetPooler} and apply the + provided configurations? +

+
) } diff --git a/apps/studio/components/ui/Forms/Form.constants.ts b/apps/studio/components/ui/Forms/Form.constants.ts index ad65518ec6..e3910fabb0 100644 --- a/apps/studio/components/ui/Forms/Form.constants.ts +++ b/apps/studio/components/ui/Forms/Form.constants.ts @@ -1,19 +1,5 @@ import * as z from 'zod' -// This validator validates a string to be a positive integer or if it's an empty string, transforms it to a null -export const StringToPositiveNumber = z.union([ - // parse the value if it's a number - z.number().positive().int(), - // parse the value if it's a non-empty string - z.string().min(1).pipe(z.coerce.number().positive().int()), - // transform a non-empty string into a null value - z - .string() - .max(0, 'The field accepts only a number') - .transform((v) => null), - z.null(), -]) - export const StringNumberOrNull = z .string() .transform((v) => (v === '' ? null : v)) @@ -22,3 +8,10 @@ export const StringNumberOrNull = z message: 'Invalid number', }) .transform((value) => (value === null ? null : Number(value))) + +/** + * [Joshen] After wrangling with RHF I think this is the easiest way to handle nullable number fields + * - Declare the field normally as you would in the zod form schema (e.g field: z.number().nullable()) + * - In the InputField, add a form.register call `{...form.register('field_name', { setValueAs: setValueAsNullableNumber })}` + */ +export const setValueAsNullableNumber = (v: any) => (v === '' || v === null ? null : parseInt(v)) diff --git a/apps/studio/components/ui/Panel.tsx b/apps/studio/components/ui/Panel.tsx index dbc51b7dfa..12b2d88f0b 100644 --- a/apps/studio/components/ui/Panel.tsx +++ b/apps/studio/components/ui/Panel.tsx @@ -36,11 +36,7 @@ function Panel(props: PropsWithChildren) { )} {props.children} - {props.footer && ( -
-
{props.footer}
-
- )} + {props.footer &&
{props.footer}
} ) @@ -55,6 +51,14 @@ function Content({ children, className }: { children: ReactNode; className?: str return
{children}
} +function Footer({ children }: { children: ReactNode; className?: string }) { + return ( +
+
{children}
+
+ ) +} + const PanelNotice = forwardRef< HTMLDivElement, { @@ -139,5 +143,6 @@ const PanelNotice = forwardRef< PanelNotice.displayName = 'PanelNotice' Panel.Content = Content +Panel.Footer = Footer Panel.Notice = PanelNotice export default Panel diff --git a/apps/studio/data/database/pgbouncer-config-query.ts b/apps/studio/data/database/pgbouncer-config-query.ts index d3deadb9e3..93809e5ba4 100644 --- a/apps/studio/data/database/pgbouncer-config-query.ts +++ b/apps/studio/data/database/pgbouncer-config-query.ts @@ -4,12 +4,12 @@ import { get, handleError } from 'data/fetchers' import { ResponseError } from 'types' import { databaseKeys } from './keys' -export type ProjectPgbouncerConfigVariables = { +export type PgbouncerConfigVariables = { projectRef?: string } -export async function getProjectPgbouncerConfig( - { projectRef }: ProjectPgbouncerConfigVariables, +export async function getPgbouncerConfig( + { projectRef }: PgbouncerConfigVariables, signal?: AbortSignal ) { if (!projectRef) throw new Error('projectRef is required') @@ -22,19 +22,19 @@ export async function getProjectPgbouncerConfig( return data } -export type ProjectPgbouncerConfigData = Awaited> -export type ProjectPgbouncerConfigError = ResponseError +export type PgbouncerConfigData = Awaited> +export type PgbouncerConfigError = ResponseError -export const useProjectPgbouncerConfigQuery = ( - { projectRef }: ProjectPgbouncerConfigVariables, +export const usePgbouncerConfigQuery = ( + { projectRef }: PgbouncerConfigVariables, { enabled = true, ...options - }: UseQueryOptions = {} + }: UseQueryOptions = {} ) => - useQuery( + useQuery( databaseKeys.pgbouncerConfig(projectRef), - ({ signal }) => getProjectPgbouncerConfig({ projectRef }, signal), + ({ signal }) => getPgbouncerConfig({ projectRef }, signal), { enabled: enabled && typeof projectRef !== 'undefined', ...options, diff --git a/apps/studio/data/database/pgbouncer-config-update-mutation.ts b/apps/studio/data/database/pgbouncer-config-update-mutation.ts new file mode 100644 index 0000000000..3159da1b0b --- /dev/null +++ b/apps/studio/data/database/pgbouncer-config-update-mutation.ts @@ -0,0 +1,73 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { components } from 'data/api' +import { handleError, patch } from 'data/fetchers' +import type { ResponseError } from 'types' +import { databaseKeys } from './keys' + +export type PgbouncerConfigurationUpdateVariables = { + ref: string +} & components['schemas']['UpdatePgbouncerConfigBody'] + +export async function updatePgbouncerConfiguration({ + ref, + pool_mode, + default_pool_size, + pgbouncer_enabled, + ignore_startup_parameters, + max_client_conn, +}: PgbouncerConfigurationUpdateVariables) { + if (!ref) return console.error('Project ref is required') + + const { data, error } = await patch('/platform/projects/{ref}/config/pgbouncer', { + params: { path: { ref } }, + body: { + default_pool_size, + pool_mode, + pgbouncer_enabled, + ignore_startup_parameters, + max_client_conn, + }, + }) + + if (error) handleError(error) + return data +} + +type PgbouncerConfigurationUpdateData = Awaited> + +export const usePgbouncerConfigurationUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + PgbouncerConfigurationUpdateData, + ResponseError, + PgbouncerConfigurationUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + PgbouncerConfigurationUpdateData, + ResponseError, + PgbouncerConfigurationUpdateVariables + >((vars) => updatePgbouncerConfiguration(vars), { + async onSuccess(data, variables, context) { + const { ref } = variables + await queryClient.invalidateQueries(databaseKeys.pgbouncerConfig(ref)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update PgBouncer configuration: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/database/pgbouncer-status-query.ts b/apps/studio/data/database/pgbouncer-status-query.ts index 623808b292..fd178fd8eb 100644 --- a/apps/studio/data/database/pgbouncer-status-query.ts +++ b/apps/studio/data/database/pgbouncer-status-query.ts @@ -4,12 +4,12 @@ import { get, handleError } from 'data/fetchers' import { ResponseError } from 'types' import { databaseKeys } from './keys' -export type ProjectPgbouncerStatusVariables = { +export type PgbouncerStatusVariables = { projectRef?: string } -export async function getProjectPgbouncerStatus( - { projectRef }: ProjectPgbouncerStatusVariables, +export async function getPgbouncerStatus( + { projectRef }: PgbouncerStatusVariables, signal?: AbortSignal ) { if (!projectRef) throw new Error('projectRef is required') @@ -22,19 +22,19 @@ export async function getProjectPgbouncerStatus( return data } -export type ProjectPgbouncerStatusData = Awaited> -export type ProjectPgbouncerStatusError = ResponseError +export type PgbouncerStatusData = Awaited> +export type PgbouncerStatusError = ResponseError -export const useProjectPgbouncerStatusQuery = ( - { projectRef }: ProjectPgbouncerStatusVariables, +export const usePgbouncerStatusQuery = ( + { projectRef }: PgbouncerStatusVariables, { enabled = true, ...options - }: UseQueryOptions = {} + }: UseQueryOptions = {} ) => - useQuery( + useQuery( databaseKeys.pgbouncerStatus(projectRef), - ({ signal }) => getProjectPgbouncerStatus({ projectRef }, signal), + ({ signal }) => getPgbouncerStatus({ projectRef }, signal), { enabled: enabled && typeof projectRef !== 'undefined', ...options, diff --git a/apps/studio/data/database/pooling-configuration-query.ts b/apps/studio/data/database/pooling-configuration-query.ts index 9f58ebad7c..8059e26dcb 100644 --- a/apps/studio/data/database/pooling-configuration-query.ts +++ b/apps/studio/data/database/pooling-configuration-query.ts @@ -31,6 +31,9 @@ export async function getPoolingConfiguration( export type PoolingConfigurationData = Awaited> export type PoolingConfigurationError = ResponseError +/** + * @deprecated use useSupavisorConfigurationQuery isntead + */ export const usePoolingConfigurationQuery = ( { projectRef }: PoolingConfigurationVariables, { @@ -46,3 +49,22 @@ export const usePoolingConfigurationQuery = ( ...options, } ) + +/** + * Just a duplicate of usePoolingConfigurationQuery until we move everything over + */ +export const useSupavisorConfigurationQuery = ( + { projectRef }: PoolingConfigurationVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + databaseKeys.poolingConfiguration(projectRef), + ({ signal }) => getPoolingConfiguration({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/database/pooling-configuration-update-mutation.ts b/apps/studio/data/database/pooling-configuration-update-mutation.ts index f02db60d97..8252daecf9 100644 --- a/apps/studio/data/database/pooling-configuration-update-mutation.ts +++ b/apps/studio/data/database/pooling-configuration-update-mutation.ts @@ -57,7 +57,42 @@ export const usePoolingConfigurationUpdateMutation = ({ }, async onError(data, variables, context) { if (onError === undefined) { - toast.error(`Failed to update pooling configuration: ${data.message}`) + toast.error(`Failed to update PgBouncer configuration: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} + +export const useSupavisorConfigurationUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + PoolingConfigurationUpdateData, + ResponseError, + PoolingConfigurationUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + PoolingConfigurationUpdateData, + ResponseError, + PoolingConfigurationUpdateVariables + >((vars) => updatePoolingConfiguration(vars), { + async onSuccess(data, variables, context) { + const { ref } = variables + await queryClient.invalidateQueries(databaseKeys.poolingConfiguration(ref)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update Supavisor configuration: ${data.message}`) } else { onError(data, variables, context) } diff --git a/packages/ui/src/components/radio-group-card.tsx b/packages/ui/src/components/radio-group-card.tsx index 234817b79c..1f0b8c6adf 100644 --- a/packages/ui/src/components/radio-group-card.tsx +++ b/packages/ui/src/components/radio-group-card.tsx @@ -16,7 +16,7 @@ RadioGroupCard.displayName = RadioGroupPrimitive.Root.displayName interface RadioGroupCardItemProps { image?: React.ReactNode - label: string + label: string | React.ReactNode showIndicator?: boolean }