From ba552f4bd33302d8f5ddaf0ab2f59469709676e5 Mon Sep 17 00:00:00 2001 From: Omkar Shendge Date: Sat, 19 Sep 2026 09:25:56 +0530 Subject: [PATCH 1/4] Add replicaConnectionPooler spec for replica pooler overrides Signed-off-by: Omkar Shendge --- .../postgres-operator/crds/postgresqls.yaml | 109 ++++++++++++++++++ manifests/postgresql.crd.yaml | 109 ++++++++++++++++++ pkg/apis/acid.zalan.do/v1/postgresql.crd.yaml | 109 ++++++++++++++++++ pkg/apis/acid.zalan.do/v1/postgresql_type.go | 29 ++++- .../acid.zalan.do/v1/zz_generated.deepcopy.go | 36 ++++++ 5 files changed, 386 insertions(+), 6 deletions(-) diff --git a/charts/postgres-operator/crds/postgresqls.yaml b/charts/postgres-operator/crds/postgresqls.yaml index f3dcbab59..0434ff963 100644 --- a/charts/postgres-operator/crds/postgresqls.yaml +++ b/charts/postgres-operator/crds/postgresqls.yaml @@ -2488,6 +2488,115 @@ spec: type: string type: object type: object + replicaConnectionPooler: + description: |- + ReplicaConnectionPooler overrides connection pooler settings for the replica + pooler. Fields left unset fall back to the values under connectionPooler, so a + manifest only has to spell out what differs between the two poolers. The pooler + user and schema are intentionally not exposed here: both poolers authenticate + through the same database objects, which are created once per cluster from + connectionPooler. + properties: + dockerImage: + type: string + maxDBConnections: + format: int32 + type: integer + mode: + enum: + - session + - transaction + type: string + numberOfInstances: + format: int32 + minimum: 1 + type: integer + resources: + description: Resources describes requests and limits for the cluster + resouces. + properties: + limits: + description: ResourceDescription describes CPU and memory + resources defined for a cluster. + properties: + cpu: + description: |- + Decimal natural followed by m, or decimal natural followed by + dot followed by up to three decimal digits. + + This is because the Kubernetes CPU resource has millis as the + maximum precision. The actual values are checked in code + because the regular expression would be huge and horrible and + not very helpful in validation error messages; this one checks + only the format of the given number. + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-cpu + + Note: the value specified here must not be zero or be lower + than the corresponding request. + pattern: ^(\d+m|\d+(\.\d{1,3})?)$ + type: string + hugepages-1Gi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + hugepages-2Mi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + memory: + description: |- + You can express memory as a plain integer or as a fixed-point + integer using one of these suffixes: E, P, T, G, M, k. You can + also use the power-of-two equivalents: Ei, Pi, Ti, Gi, Mi, Ki + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-memory + + Note: the value specified here must not be zero or be higher + than the corresponding limit. + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + type: object + requests: + description: ResourceDescription describes CPU and memory + resources defined for a cluster. + properties: + cpu: + description: |- + Decimal natural followed by m, or decimal natural followed by + dot followed by up to three decimal digits. + + This is because the Kubernetes CPU resource has millis as the + maximum precision. The actual values are checked in code + because the regular expression would be huge and horrible and + not very helpful in validation error messages; this one checks + only the format of the given number. + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-cpu + + Note: the value specified here must not be zero or be lower + than the corresponding request. + pattern: ^(\d+m|\d+(\.\d{1,3})?)$ + type: string + hugepages-1Gi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + hugepages-2Mi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + memory: + description: |- + You can express memory as a plain integer or as a fixed-point + integer using one of these suffixes: E, P, T, G, M, k. You can + also use the power-of-two equivalents: Ei, Pi, Ti, Gi, Mi, Ki + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-memory + + Note: the value specified here must not be zero or be higher + than the corresponding limit. + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + type: object + type: object + type: object replicaNodePort: format: int32 type: integer diff --git a/manifests/postgresql.crd.yaml b/manifests/postgresql.crd.yaml index f3dcbab59..0434ff963 100644 --- a/manifests/postgresql.crd.yaml +++ b/manifests/postgresql.crd.yaml @@ -2488,6 +2488,115 @@ spec: type: string type: object type: object + replicaConnectionPooler: + description: |- + ReplicaConnectionPooler overrides connection pooler settings for the replica + pooler. Fields left unset fall back to the values under connectionPooler, so a + manifest only has to spell out what differs between the two poolers. The pooler + user and schema are intentionally not exposed here: both poolers authenticate + through the same database objects, which are created once per cluster from + connectionPooler. + properties: + dockerImage: + type: string + maxDBConnections: + format: int32 + type: integer + mode: + enum: + - session + - transaction + type: string + numberOfInstances: + format: int32 + minimum: 1 + type: integer + resources: + description: Resources describes requests and limits for the cluster + resouces. + properties: + limits: + description: ResourceDescription describes CPU and memory + resources defined for a cluster. + properties: + cpu: + description: |- + Decimal natural followed by m, or decimal natural followed by + dot followed by up to three decimal digits. + + This is because the Kubernetes CPU resource has millis as the + maximum precision. The actual values are checked in code + because the regular expression would be huge and horrible and + not very helpful in validation error messages; this one checks + only the format of the given number. + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-cpu + + Note: the value specified here must not be zero or be lower + than the corresponding request. + pattern: ^(\d+m|\d+(\.\d{1,3})?)$ + type: string + hugepages-1Gi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + hugepages-2Mi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + memory: + description: |- + You can express memory as a plain integer or as a fixed-point + integer using one of these suffixes: E, P, T, G, M, k. You can + also use the power-of-two equivalents: Ei, Pi, Ti, Gi, Mi, Ki + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-memory + + Note: the value specified here must not be zero or be higher + than the corresponding limit. + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + type: object + requests: + description: ResourceDescription describes CPU and memory + resources defined for a cluster. + properties: + cpu: + description: |- + Decimal natural followed by m, or decimal natural followed by + dot followed by up to three decimal digits. + + This is because the Kubernetes CPU resource has millis as the + maximum precision. The actual values are checked in code + because the regular expression would be huge and horrible and + not very helpful in validation error messages; this one checks + only the format of the given number. + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-cpu + + Note: the value specified here must not be zero or be lower + than the corresponding request. + pattern: ^(\d+m|\d+(\.\d{1,3})?)$ + type: string + hugepages-1Gi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + hugepages-2Mi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + memory: + description: |- + You can express memory as a plain integer or as a fixed-point + integer using one of these suffixes: E, P, T, G, M, k. You can + also use the power-of-two equivalents: Ei, Pi, Ti, Gi, Mi, Ki + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-memory + + Note: the value specified here must not be zero or be higher + than the corresponding limit. + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + type: object + type: object + type: object replicaNodePort: format: int32 type: integer diff --git a/pkg/apis/acid.zalan.do/v1/postgresql.crd.yaml b/pkg/apis/acid.zalan.do/v1/postgresql.crd.yaml index f3dcbab59..0434ff963 100644 --- a/pkg/apis/acid.zalan.do/v1/postgresql.crd.yaml +++ b/pkg/apis/acid.zalan.do/v1/postgresql.crd.yaml @@ -2488,6 +2488,115 @@ spec: type: string type: object type: object + replicaConnectionPooler: + description: |- + ReplicaConnectionPooler overrides connection pooler settings for the replica + pooler. Fields left unset fall back to the values under connectionPooler, so a + manifest only has to spell out what differs between the two poolers. The pooler + user and schema are intentionally not exposed here: both poolers authenticate + through the same database objects, which are created once per cluster from + connectionPooler. + properties: + dockerImage: + type: string + maxDBConnections: + format: int32 + type: integer + mode: + enum: + - session + - transaction + type: string + numberOfInstances: + format: int32 + minimum: 1 + type: integer + resources: + description: Resources describes requests and limits for the cluster + resouces. + properties: + limits: + description: ResourceDescription describes CPU and memory + resources defined for a cluster. + properties: + cpu: + description: |- + Decimal natural followed by m, or decimal natural followed by + dot followed by up to three decimal digits. + + This is because the Kubernetes CPU resource has millis as the + maximum precision. The actual values are checked in code + because the regular expression would be huge and horrible and + not very helpful in validation error messages; this one checks + only the format of the given number. + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-cpu + + Note: the value specified here must not be zero or be lower + than the corresponding request. + pattern: ^(\d+m|\d+(\.\d{1,3})?)$ + type: string + hugepages-1Gi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + hugepages-2Mi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + memory: + description: |- + You can express memory as a plain integer or as a fixed-point + integer using one of these suffixes: E, P, T, G, M, k. You can + also use the power-of-two equivalents: Ei, Pi, Ti, Gi, Mi, Ki + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-memory + + Note: the value specified here must not be zero or be higher + than the corresponding limit. + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + type: object + requests: + description: ResourceDescription describes CPU and memory + resources defined for a cluster. + properties: + cpu: + description: |- + Decimal natural followed by m, or decimal natural followed by + dot followed by up to three decimal digits. + + This is because the Kubernetes CPU resource has millis as the + maximum precision. The actual values are checked in code + because the regular expression would be huge and horrible and + not very helpful in validation error messages; this one checks + only the format of the given number. + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-cpu + + Note: the value specified here must not be zero or be lower + than the corresponding request. + pattern: ^(\d+m|\d+(\.\d{1,3})?)$ + type: string + hugepages-1Gi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + hugepages-2Mi: + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + memory: + description: |- + You can express memory as a plain integer or as a fixed-point + integer using one of these suffixes: E, P, T, G, M, k. You can + also use the power-of-two equivalents: Ei, Pi, Ti, Gi, Mi, Ki + + https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-memory + + Note: the value specified here must not be zero or be higher + than the corresponding limit. + pattern: ^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$ + type: string + type: object + type: object + type: object replicaNodePort: format: int32 type: integer diff --git a/pkg/apis/acid.zalan.do/v1/postgresql_type.go b/pkg/apis/acid.zalan.do/v1/postgresql_type.go index 2ea8c6ba8..4c28a99b3 100644 --- a/pkg/apis/acid.zalan.do/v1/postgresql_type.go +++ b/pkg/apis/acid.zalan.do/v1/postgresql_type.go @@ -43,12 +43,12 @@ type PostgresSpec struct { Patroni `json:"patroni"` *Resources `json:"resources,omitempty"` - EnableConnectionPooler *bool `json:"enableConnectionPooler,omitempty"` - EnableReplicaConnectionPooler *bool `json:"enableReplicaConnectionPooler,omitempty"` - ConnectionPooler *ConnectionPooler `json:"connectionPooler,omitempty"` - - TeamID string `json:"teamId"` - DockerImage string `json:"dockerImage,omitempty"` + EnableConnectionPooler *bool `json:"enableConnectionPooler,omitempty"` + EnableReplicaConnectionPooler *bool `json:"enableReplicaConnectionPooler,omitempty"` + ConnectionPooler *ConnectionPooler `json:"connectionPooler,omitempty"` + ReplicaConnectionPooler *ReplicaConnectionPooler `json:"replicaConnectionPooler,omitempty"` + TeamID string `json:"teamId"` + DockerImage string `json:"dockerImage,omitempty"` // deprecated field storing cluster name without teamId prefix ClusterName string `json:"-"` @@ -334,6 +334,23 @@ type ConnectionPooler struct { *Resources `json:"resources,omitempty"` } +// ReplicaConnectionPooler overrides connection pooler settings for the replica +// pooler. Fields left unset fall back to the values under connectionPooler, so a +// manifest only has to spell out what differs between the two poolers. The pooler +// user and schema are intentionally not exposed here: both poolers authenticate +// through the same database objects, which are created once per cluster from +// connectionPooler. +type ReplicaConnectionPooler struct { + // +kubebuilder:validation:Minimum=1 + NumberOfInstances *int32 `json:"numberOfInstances,omitempty"` + // +kubebuilder:validation:Enum=session;transaction + Mode string `json:"mode,omitempty"` + DockerImage string `json:"dockerImage,omitempty"` + MaxDBConnections *int32 `json:"maxDBConnections,omitempty"` + + *Resources `json:"resources,omitempty"` +} + // Stream defines properties for creating FabricEventStream resources type Stream struct { ApplicationId string `json:"applicationId"` diff --git a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go index 9f5e93976..289b4a599 100644 --- a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go +++ b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go @@ -773,6 +773,11 @@ func (in *PostgresSpec) DeepCopyInto(out *PostgresSpec) { *out = new(ConnectionPooler) (*in).DeepCopyInto(*out) } + if in.ReplicaConnectionPooler != nil { + in, out := &in.ReplicaConnectionPooler, &out.ReplicaConnectionPooler + *out = new(ReplicaConnectionPooler) + (*in).DeepCopyInto(*out) + } if in.SpiloRunAsUser != nil { in, out := &in.SpiloRunAsUser, &out.SpiloRunAsUser *out = new(int64) @@ -1329,6 +1334,37 @@ func (in *PreparedSchema) DeepCopy() *PreparedSchema { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReplicaConnectionPooler) DeepCopyInto(out *ReplicaConnectionPooler) { + *out = *in + if in.NumberOfInstances != nil { + in, out := &in.NumberOfInstances, &out.NumberOfInstances + *out = new(int32) + **out = **in + } + if in.MaxDBConnections != nil { + in, out := &in.MaxDBConnections, &out.MaxDBConnections + *out = new(int32) + **out = **in + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(Resources) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReplicaConnectionPooler. +func (in *ReplicaConnectionPooler) DeepCopy() *ReplicaConnectionPooler { + if in == nil { + return nil + } + out := new(ReplicaConnectionPooler) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceDescription) DeepCopyInto(out *ResourceDescription) { *out = *in From d2e4dd2f2057a273a9269a545463673688e898b5 Mon Sep 17 00:00:00 2001 From: Omkar Shendge Date: Sat, 19 Sep 2026 13:39:14 +0530 Subject: [PATCH 2/4] Resolve connection pooler spec per role Signed-off-by: Omkar Shendge --- pkg/cluster/connection_pooler.go | 68 +++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/pkg/cluster/connection_pooler.go b/pkg/cluster/connection_pooler.go index 89325e1fc..67825a675 100644 --- a/pkg/cluster/connection_pooler.go +++ b/pkg/cluster/connection_pooler.go @@ -89,6 +89,41 @@ func (c *Cluster) needConnectionPoolerUser(oldSpec, newSpec *acidv1.PostgresSpec c.poolerUser(oldSpec) != c.poolerUser(newSpec)) } +// effectiveConnectionPoolerSpec returns the pooler configuration that applies to +// role. For the replica pooler, values set in replicaConnectionPooler win, and +// anything left unset there falls back to connectionPooler - so a manifest that +// does not use replicaConnectionPooler keeps the behaviour it has today. The +// result is never nil, which removes the nil check each caller used to repeat. +func effectiveConnectionPoolerSpec(spec *acidv1.PostgresSpec, role PostgresRole) *acidv1.ConnectionPooler { + effective := &acidv1.ConnectionPooler{} + if spec.ConnectionPooler != nil { + *effective = *spec.ConnectionPooler + } + + if role != Replica || spec.ReplicaConnectionPooler == nil { + return effective + } + + override := spec.ReplicaConnectionPooler + if override.NumberOfInstances != nil { + effective.NumberOfInstances = override.NumberOfInstances + } + if override.Mode != "" { + effective.Mode = override.Mode + } + if override.DockerImage != "" { + effective.DockerImage = override.DockerImage + } + if override.MaxDBConnections != nil { + effective.MaxDBConnections = override.MaxDBConnections + } + if override.Resources != nil { + effective.Resources = override.Resources + } + + return effective +} + func (c *Cluster) poolerUser(spec *acidv1.PostgresSpec) string { connectionPoolerSpec := spec.ConnectionPooler if connectionPoolerSpec == nil { @@ -221,12 +256,9 @@ func (c *Cluster) generateConnectionPoolerAuthSecret(connectionPooler *Connectio // // RESERVE_SIZE is how many additional connections to allow for a pooler. -func (c *Cluster) getConnectionPoolerEnvVars() []v1.EnvVar { +func (c *Cluster) getConnectionPoolerEnvVars(role PostgresRole) []v1.EnvVar { spec := &c.Spec - connectionPoolerSpec := spec.ConnectionPooler - if connectionPoolerSpec == nil { - connectionPoolerSpec = &acidv1.ConnectionPooler{} - } + connectionPoolerSpec := effectiveConnectionPoolerSpec(spec, role) effectiveMode := util.Coalesce( connectionPoolerSpec.Mode, c.OpConfig.ConnectionPooler.Mode) @@ -288,10 +320,7 @@ func (c *Cluster) getConnectionPoolerEnvVars() []v1.EnvVar { func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) ( *v1.PodTemplateSpec, error) { spec := &c.Spec - connectionPoolerSpec := spec.ConnectionPooler - if connectionPoolerSpec == nil { - connectionPoolerSpec = &acidv1.ConnectionPooler{} - } + connectionPoolerSpec := effectiveConnectionPoolerSpec(spec, role) gracePeriod := int64(util.CoalesceDuration(c.OpConfig.PodTerminateGracePeriod, "5m").Seconds()) resources, err := c.generateResourceRequirements( connectionPoolerSpec.Resources, @@ -351,7 +380,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) ( }, }, } - envVars = append(envVars, c.getConnectionPoolerEnvVars()...) + envVars = append(envVars, c.getConnectionPoolerEnvVars(role)...) infraRolesList := make([]string, 0) for infraRoleName := range c.InfrastructureRoles { @@ -501,10 +530,7 @@ func (c *Cluster) generateConnectionPoolerDeployment(connectionPooler *Connectio // default values, initialize it to an empty structure. It could be done // anywhere, but here is the earliest common entry point between sync and // create code, so init here. - connectionPoolerSpec := spec.ConnectionPooler - if connectionPoolerSpec == nil { - connectionPoolerSpec = &acidv1.ConnectionPooler{} - } + connectionPoolerSpec := effectiveConnectionPoolerSpec(spec, connectionPooler.Role) podTemplate, err := c.generateConnectionPoolerPodTemplate(connectionPooler.Role) numberOfInstances := connectionPoolerSpec.NumberOfInstances @@ -1153,23 +1179,17 @@ func (c *Cluster) syncConnectionPoolerWorker(oldSpec, newSpec *acidv1.Postgresql var oldConnectionPooler *acidv1.ConnectionPooler if oldSpec != nil { - oldConnectionPooler = oldSpec.Spec.ConnectionPooler + oldConnectionPooler = effectiveConnectionPoolerSpec(&oldSpec.Spec, role) } - newConnectionPooler := newSpec.Spec.ConnectionPooler + newConnectionPooler := effectiveConnectionPoolerSpec(&newSpec.Spec, role) // sync implementation below assumes that both old and new specs are - // not nil, but it can happen. To avoid any confusion like updating a - // deployment because the specification changed from nil to an empty - // struct (that was initialized somewhere before) replace any nil with - // an empty spec. + // not nil. effectiveConnectionPoolerSpec never returns nil, but + // oldConnectionPooler is still nil when there is no old spec at all. if oldConnectionPooler == nil { oldConnectionPooler = &acidv1.ConnectionPooler{} } - if newConnectionPooler == nil { - newConnectionPooler = &acidv1.ConnectionPooler{} - } - var specSync, updateDeployment bool var specReason []string From ceb188f6f15326afc8f0f79325596630ebdbdedf Mon Sep 17 00:00:00 2001 From: Omkar Shendge Date: Sat, 19 Sep 2026 13:48:32 +0530 Subject: [PATCH 3/4] Add tests for the replica pooler spec override Signed-off-by: Omkar Shendge --- pkg/cluster/connection_pooler_test.go | 207 ++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/pkg/cluster/connection_pooler_test.go b/pkg/cluster/connection_pooler_test.go index 688d96d18..1a5b53831 100644 --- a/pkg/cluster/connection_pooler_test.go +++ b/pkg/cluster/connection_pooler_test.go @@ -268,6 +268,213 @@ func TestNeedConnectionPooler(t *testing.T) { } } +func TestEffectiveConnectionPoolerSpec(t *testing.T) { + masterCPU := "100m" + replicaCPU := "200m" + masterResources := &acidv1.Resources{ + ResourceRequests: acidv1.ResourceDescription{CPU: &masterCPU}, + } + replicaResources := &acidv1.Resources{ + ResourceRequests: acidv1.ResourceDescription{CPU: &replicaCPU}, + } + + master := &acidv1.ConnectionPooler{ + NumberOfInstances: k8sutil.Int32ToPointer(2), + Schema: "pooler", + User: "pooler", + Mode: "transaction", + DockerImage: "master-image", + MaxDBConnections: k8sutil.Int32ToPointer(60), + Resources: masterResources, + } + + tests := []struct { + subTest string + spec acidv1.PostgresSpec + role PostgresRole + expected *acidv1.ConnectionPooler + }{ + { + subTest: "no pooler section at all still yields a non-nil spec", + spec: acidv1.PostgresSpec{}, + role: Master, + expected: &acidv1.ConnectionPooler{}, + }, + { + subTest: "master ignores replicaConnectionPooler entirely", + spec: acidv1.PostgresSpec{ + ConnectionPooler: master, + ReplicaConnectionPooler: &acidv1.ReplicaConnectionPooler{ + NumberOfInstances: k8sutil.Int32ToPointer(5), + Mode: "session", + }, + }, + role: Master, + expected: master, + }, + { + subTest: "replica without an override matches connectionPooler", + spec: acidv1.PostgresSpec{ + ConnectionPooler: master, + }, + role: Replica, + expected: master, + }, + { + subTest: "replica inherits the fields it does not override", + spec: acidv1.PostgresSpec{ + ConnectionPooler: master, + ReplicaConnectionPooler: &acidv1.ReplicaConnectionPooler{ + NumberOfInstances: k8sutil.Int32ToPointer(5), + }, + }, + role: Replica, + expected: &acidv1.ConnectionPooler{ + NumberOfInstances: k8sutil.Int32ToPointer(5), + Schema: "pooler", + User: "pooler", + Mode: "transaction", + DockerImage: "master-image", + MaxDBConnections: k8sutil.Int32ToPointer(60), + Resources: masterResources, + }, + }, + { + subTest: "replica overrides every field it carries, user and schema still inherited", + spec: acidv1.PostgresSpec{ + ConnectionPooler: master, + ReplicaConnectionPooler: &acidv1.ReplicaConnectionPooler{ + NumberOfInstances: k8sutil.Int32ToPointer(5), + Mode: "session", + DockerImage: "replica-image", + MaxDBConnections: k8sutil.Int32ToPointer(10), + Resources: replicaResources, + }, + }, + role: Replica, + expected: &acidv1.ConnectionPooler{ + NumberOfInstances: k8sutil.Int32ToPointer(5), + Schema: "pooler", + User: "pooler", + Mode: "session", + DockerImage: "replica-image", + MaxDBConnections: k8sutil.Int32ToPointer(10), + Resources: replicaResources, + }, + }, + { + subTest: "replicaConnectionPooler alone, with no connectionPooler section", + spec: acidv1.PostgresSpec{ + ReplicaConnectionPooler: &acidv1.ReplicaConnectionPooler{ + NumberOfInstances: k8sutil.Int32ToPointer(5), + }, + }, + role: Replica, + expected: &acidv1.ConnectionPooler{ + NumberOfInstances: k8sutil.Int32ToPointer(5), + }, + }, + } + + for _, tt := range tests { + spec := tt.spec + assert.Equal(t, tt.expected, effectiveConnectionPoolerSpec(&spec, tt.role), tt.subTest) + } +} + +func TestReplicaConnectionPoolerSyncOnOverrideChange(t *testing.T) { + testName := "changing replicaConnectionPooler rolls the replica pooler only" + namespace := "default" + + clientSet := fake.NewSimpleClientset() + acidClientSet := fakeacidv1.NewSimpleClientset() + client := k8sutil.KubernetesClient{ + StatefulSetsGetter: clientSet.AppsV1(), + ServicesGetter: clientSet.CoreV1(), + PodsGetter: clientSet.CoreV1(), + DeploymentsGetter: clientSet.AppsV1(), + PostgresqlsGetter: acidClientSet.AcidV1(), + SecretsGetter: clientSet.CoreV1(), + } + + poolerSpec := func(replicaInstances int32) acidv1.PostgresSpec { + return acidv1.PostgresSpec{ + Volume: acidv1.Volume{Size: "1Gi"}, + EnableConnectionPooler: boolToPointer(true), + EnableReplicaConnectionPooler: boolToPointer(true), + ConnectionPooler: &acidv1.ConnectionPooler{ + NumberOfInstances: k8sutil.Int32ToPointer(2), + }, + ReplicaConnectionPooler: &acidv1.ReplicaConnectionPooler{ + NumberOfInstances: k8sutil.Int32ToPointer(replicaInstances), + }, + } + } + + pg := acidv1.Postgresql{ + ObjectMeta: metav1.ObjectMeta{ + Name: "acid-fake-cluster", + Namespace: namespace, + }, + Spec: poolerSpec(2), + } + + var cluster = New( + Config{ + OpConfig: config.Config{ + ConnectionPooler: config.ConnectionPooler{ + ConnectionPoolerDefaultCPURequest: "100m", + ConnectionPoolerDefaultCPULimit: "100m", + ConnectionPoolerDefaultMemoryRequest: "100Mi", + ConnectionPoolerDefaultMemoryLimit: "100Mi", + NumberOfInstances: k8sutil.Int32ToPointer(1), + }, + PodManagementPolicy: "ordered_ready", + Resources: config.Resources{ + ClusterLabels: map[string]string{"application": "spilo"}, + ClusterNameLabel: "cluster-name", + DefaultCPURequest: "300m", + DefaultCPULimit: "300m", + DefaultMemoryRequest: "300Mi", + DefaultMemoryLimit: "300Mi", + PodRoleLabel: "spilo-role", + }, + }, + }, client, pg, logger, eventRecorder) + + cluster.Name = "acid-fake-cluster" + cluster.Namespace = namespace + + _, err := cluster.createService(Master) + assert.NoError(t, err) + _, err = cluster.createStatefulSet() + assert.NoError(t, err) + + _, err = cluster.createConnectionPooler(mockInstallLookupFunction) + assert.NoError(t, err) + + assert.Equal(t, int32(2), *cluster.ConnectionPooler[Master].Deployment.Spec.Replicas, + testName+": master pooler starts with the connectionPooler instance count") + assert.Equal(t, int32(2), *cluster.ConnectionPooler[Replica].Deployment.Spec.Replicas, + testName+": replica pooler starts with the replicaConnectionPooler instance count") + + // only replicaConnectionPooler changes between the two specs + oldSpec := &acidv1.Postgresql{Spec: poolerSpec(2)} + newSpec := &acidv1.Postgresql{Spec: poolerSpec(4)} + cluster.Spec = newSpec.Spec + + _, err = cluster.syncConnectionPooler(oldSpec, newSpec, mockInstallLookupFunction) + assert.NoError(t, err) + + // if the sync comparison is not role-aware it sees no change here, the + // deployment is never regenerated and the replica pooler silently keeps + // running the old instance count + assert.Equal(t, int32(4), *cluster.ConnectionPooler[Replica].Deployment.Spec.Replicas, + testName+": replica pooler must pick up the new replicaConnectionPooler instance count") + assert.Equal(t, int32(2), *cluster.ConnectionPooler[Master].Deployment.Spec.Replicas, + testName+": master pooler must be unaffected by a replicaConnectionPooler change") +} + func TestConnectionPoolerCreateDeletion(t *testing.T) { testName := "test connection pooler creation and deletion" From 55ec3fe3677761ade7424c56c233925db88c9ab2 Mon Sep 17 00:00:00 2001 From: Omkar Shendge Date: Sat, 19 Sep 2026 13:52:02 +0530 Subject: [PATCH 4/4] Document replicaConnectionPooler Signed-off-by: Omkar Shendge --- docs/reference/cluster_manifest.md | 32 +++++++++++++++++++- docs/user.md | 23 ++++++++++++++ manifests/complete-postgres-manifest.yaml | 14 +++++++++ pkg/apis/acid.zalan.do/v1/postgresql_type.go | 5 +-- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/docs/reference/cluster_manifest.md b/docs/reference/cluster_manifest.md index 7dddd5951..f9ab7da05 100644 --- a/docs/reference/cluster_manifest.md +++ b/docs/reference/cluster_manifest.md @@ -634,7 +634,8 @@ configuration for connection pooler. If this section is not empty, a connection pooler will be created for master service only even if `enableConnectionPooler` is not present. But if this section is present then it defines the configuration for both master and replica pooler services (if `enableReplicaConnectionPooler` - is enabled). + is enabled), unless individual settings are overridden under +`replicaConnectionPooler`. * **numberOfInstances** How many instances of connection pooler to create. @@ -659,6 +660,35 @@ for both master and replica pooler services (if `enableReplicaConnectionPooler` * **resources** Resource configuration for connection pooler deployment. +## Replica connection pooler + +Parameters are grouped under the `replicaConnectionPooler` top-level key and +override the `connectionPooler` settings for the replica pooler only. Any field +left out here falls back to `connectionPooler`, so a manifest only has to spell +out what differs between the two poolers. This section does not create anything +on its own - `enableReplicaConnectionPooler` is still what enables the replica +pooler. + +The `schema` and `user` parameters are deliberately not available here. Both +poolers authenticate through the same database objects, which are created once +per cluster from `connectionPooler`. + +* **numberOfInstances** + How many instances of the replica connection pooler to create. + +* **dockerImage** + Which docker image to use for the replica connection pooler deployment. + +* **maxDBConnections** + How many connections the replica pooler can max hold. This value is divided + among the pooler pods. + +* **mode** + In which mode to run the replica connection pooler, transaction or session. + +* **resources** + Resource configuration for the replica connection pooler deployment. + ## Custom TLS certificates Those parameters are grouped under the `tls` top-level key. Note, you have to diff --git a/docs/user.md b/docs/user.md index c1c2b8dc1..4e7095654 100644 --- a/docs/user.md +++ b/docs/user.md @@ -1218,6 +1218,29 @@ The `enableConnectionPooler` flag is not required when the `connectionPooler` section is present in the manifest. But, it can be used to disable/remove the pooler while keeping its configuration. +By default the `connectionPooler` section configures both poolers. To give the +replica pooler different settings, add a `replicaConnectionPooler` section +containing only the values that should differ: + +``` +spec: + enableConnectionPooler: true + enableReplicaConnectionPooler: true + connectionPooler: + numberOfInstances: 2 + mode: "transaction" + replicaConnectionPooler: + # the replica pooler runs more instances in session mode, everything + # else is inherited from connectionPooler + numberOfInstances: 4 + mode: "session" +``` + +Unlike `connectionPooler`, this section does not enable anything on its own - +`enableReplicaConnectionPooler` is still required. It also does not accept +`schema` or `user`, because both poolers connect through the same database +objects, which are created once per cluster from `connectionPooler`. + By default, [`PgBouncer`](https://www.pgbouncer.org/) is used as connection pooler. To find out about pool modes read the `PgBouncer` [docs](https://www.pgbouncer.org/config.html#pooler_mode) (but it should be the general approach between different implementation). diff --git a/manifests/complete-postgres-manifest.yaml b/manifests/complete-postgres-manifest.yaml index eff115cab..500c38dda 100644 --- a/manifests/complete-postgres-manifest.yaml +++ b/manifests/complete-postgres-manifest.yaml @@ -183,6 +183,20 @@ spec: # memory: 100Mi # limits: # cpu: "1" +# memory: 100Mi + +# override connection pooler properties for the replica pooler only, +# anything left out here is inherited from connectionPooler +# replicaConnectionPooler: +# numberOfInstances: 2 +# mode: "session" +# maxDBConnections: 60 +# resources: +# requests: +# cpu: 300m +# memory: 100Mi +# limits: +# cpu: "1" # memory: 100Mi initContainers: diff --git a/pkg/apis/acid.zalan.do/v1/postgresql_type.go b/pkg/apis/acid.zalan.do/v1/postgresql_type.go index 4c28a99b3..8378aff3f 100644 --- a/pkg/apis/acid.zalan.do/v1/postgresql_type.go +++ b/pkg/apis/acid.zalan.do/v1/postgresql_type.go @@ -47,8 +47,9 @@ type PostgresSpec struct { EnableReplicaConnectionPooler *bool `json:"enableReplicaConnectionPooler,omitempty"` ConnectionPooler *ConnectionPooler `json:"connectionPooler,omitempty"` ReplicaConnectionPooler *ReplicaConnectionPooler `json:"replicaConnectionPooler,omitempty"` - TeamID string `json:"teamId"` - DockerImage string `json:"dockerImage,omitempty"` + + TeamID string `json:"teamId"` + DockerImage string `json:"dockerImage,omitempty"` // deprecated field storing cluster name without teamId prefix ClusterName string `json:"-"`